devctk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: devctk
3
+ Version: 0.1.0
4
+ Summary: One-command SSH-accessible rootless Podman dev containers
5
+ Author: y
6
+ License-Expression: MIT
7
+ Classifier: Environment :: Console
8
+ Classifier: Operating System :: POSIX :: Linux
9
+ Classifier: Topic :: Software Development
10
+ Requires-Python: >=3.10
@@ -0,0 +1,12 @@
1
+ z_ctk/__init__.py,sha256=Te0i8IdN4qfeBZ-YPfEisrLwimd7vsTzbvR2nEhBGEI,80
2
+ z_ctk/__main__.py,sha256=AQOIm9RJ1CpoWEwW4uwQ_QP9zSXueBD9SPohkqONLtc,53
3
+ z_ctk/cli.py,sha256=bm8ZsU8Hx-hBb6S2pjih16Pabb7hSkr10CyC4usXlAk,3057
4
+ z_ctk/commands.py,sha256=RcOWj6Ka6IZ8qX_xV2GBy2hSWx9ozc_krwp-vm77RVM,9395
5
+ z_ctk/helpers.py,sha256=oCH7a2F3nYlRR_K5zTyIdnrd_uLvuFQEPNT67Rssd2E,6087
6
+ z_ctk/paths.py,sha256=5BQFFluP6lZCP99ZCkKz05P3JKCYOB1h-nUYWeLHvgk,1074
7
+ z_ctk/systemd.py,sha256=lrmIpL60emizjUd4lXJGpWVeF59wBRbanWDSSL-lxWQ,935
8
+ z_ctk/util.py,sha256=QQcbskmzYCk0Lj_5B8dUAbqx9axmt22VLyX-TK9qxmM,743
9
+ devctk-0.1.0.dist-info/METADATA,sha256=IpxrQ5XBkS4K530VvsXOrlX1bqDs79BOslu9Kxg0wX8,299
10
+ devctk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ devctk-0.1.0.dist-info/entry_points.txt,sha256=hqtJ6HZRZUHdypiloJeqq4KbrAN3it1jykJe8axXc80,42
12
+ devctk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devctk = z_ctk.cli:main
z_ctk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """z-ctk: one-command rootless Podman dev containers."""
2
+
3
+ __version__ = "0.1.0"
z_ctk/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from z_ctk.cli import main
2
+
3
+ raise SystemExit(main())
z_ctk/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import pathlib
6
+ import re
7
+ import sys
8
+
9
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
10
+ COMMANDS = {"init", "ls", "rm"}
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="z-ctk",
16
+ description="One-command SSH-accessible rootless Podman dev containers.",
17
+ )
18
+ sub = parser.add_subparsers(dest="command")
19
+ sub.required = True
20
+
21
+ # --- init ---
22
+ p_init = sub.add_parser("init", help="Create and enable a dev container.")
23
+ p_init.add_argument("--image", required=True)
24
+ p_init.add_argument("--port", type=int, default=39000)
25
+ keys = p_init.add_mutually_exclusive_group(required=True)
26
+ keys.add_argument("--authorized-keys", dest="authorized_keys_text")
27
+ keys.add_argument("--authorized-keys-file", dest="authorized_keys_file")
28
+ p_init.add_argument("--container-name")
29
+ p_init.add_argument("--container-user")
30
+ p_init.add_argument("--workspace")
31
+ p_init.add_argument("--no-workspace", action="store_true")
32
+ p_init.add_argument("--mount", action="append", default=[])
33
+ p_init.add_argument("--device", action="append", default=[])
34
+
35
+ # --- ls ---
36
+ sub.add_parser("ls", help="List managed containers.")
37
+
38
+ # --- rm ---
39
+ p_rm = sub.add_parser("rm", help="Remove a managed container.")
40
+ p_rm.add_argument("container_name", nargs="?")
41
+ p_rm.add_argument("--all", action="store_true")
42
+
43
+ return parser
44
+
45
+
46
+ def normalize_argv(argv: list[str]) -> list[str]:
47
+ """Treat bare args (no subcommand) as `init`."""
48
+ if not argv:
49
+ return argv
50
+ if argv[0] in COMMANDS or argv[0] in {"-h", "--help"}:
51
+ return argv
52
+ return ["init", *argv]
53
+
54
+
55
+ def split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]:
56
+ if "--" not in argv:
57
+ return argv, []
58
+ idx = argv.index("--")
59
+ return argv[:idx], argv[idx + 1 :]
60
+
61
+
62
+ BANNED_PASSTHROUGH = {
63
+ "--mount", "--volume", "--publish", "--name", "--device", "--user",
64
+ "--userns", "--restart", "--rm", "--replace", "--entrypoint",
65
+ "--init", "--stop-timeout",
66
+ }
67
+
68
+
69
+ def validate_passthrough(args: list[str]) -> None:
70
+ for tok in args:
71
+ if tok in BANNED_PASSTHROUGH or any(tok.startswith(f + "=") for f in BANNED_PASSTHROUGH):
72
+ raise SystemExit(f"unsupported passthrough flag: {tok}")
73
+ if tok.startswith(("-p", "-v", "-u")):
74
+ raise SystemExit(f"unsupported passthrough flag: {tok}")
75
+
76
+
77
+ def main() -> int:
78
+ from z_ctk.commands import cmd_init, cmd_ls, cmd_rm
79
+
80
+ if os.geteuid() == 0:
81
+ raise SystemExit("refuse to run as root")
82
+
83
+ argv = normalize_argv(sys.argv[1:])
84
+ ours, passthrough = split_passthrough(argv)
85
+ parser = build_parser()
86
+ args = parser.parse_args(ours)
87
+
88
+ if args.command == "init":
89
+ validate_passthrough(passthrough)
90
+ return cmd_init(args, passthrough)
91
+ if args.command == "ls":
92
+ return cmd_ls()
93
+ if args.command == "rm":
94
+ return cmd_rm(args)
95
+
96
+ raise SystemExit(f"unknown command: {args.command}")
z_ctk/commands.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command implementations: init, ls, rm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import stat
10
+ import sys
11
+
12
+ from z_ctk.paths import ManagedPaths, managed_paths, state_root, STATE_DIR_NAME
13
+ from z_ctk.helpers import render_container_helper, render_sshd_helper
14
+ from z_ctk.systemd import render_unit
15
+ from z_ctk.util import require_binary, run, write_text, unlink_if_exists
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # init
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def resolve_authorized_keys(args: argparse.Namespace) -> tuple[pathlib.Path | None, str | None, str]:
23
+ """Return (file_path | None, text | None, source_tag)."""
24
+ if args.authorized_keys_file is not None:
25
+ p = pathlib.Path(args.authorized_keys_file).expanduser().resolve()
26
+ if not p.is_file():
27
+ raise SystemExit(f"authorized_keys file not found: {p}")
28
+ if p.stat().st_size == 0:
29
+ raise SystemExit(f"authorized_keys file is empty: {p}")
30
+ return p, None, "file"
31
+ text = args.authorized_keys_text
32
+ if not text or not text.strip():
33
+ raise SystemExit("authorized_keys text is empty")
34
+ return None, text, "inline"
35
+
36
+
37
+ def build_workspace_mount(workspace: str | None, no_workspace: bool, container_user: str) -> tuple[str | None, str]:
38
+ home = f"/home/{container_user}"
39
+ if no_workspace:
40
+ return None, home
41
+ if workspace is None:
42
+ d = pathlib.Path.home() / "dev-container"
43
+ d.mkdir(parents=True, exist_ok=True)
44
+ return f"type=bind,src={d},target={home},rw", home
45
+
46
+ # Check if it's a full mount spec
47
+ fields = {k: v for k, _, v in (p.partition("=") for p in workspace.split(",")) if v}
48
+ if {"type", "src", "source", "target", "destination", "dst"} & fields.keys():
49
+ target = fields.get("target") or fields.get("destination") or fields.get("dst")
50
+ if not target:
51
+ raise SystemExit("--workspace mount spec must include target=...")
52
+ return workspace, target
53
+
54
+ d = pathlib.Path(workspace).expanduser().resolve()
55
+ d.mkdir(parents=True, exist_ok=True)
56
+ return f"type=bind,src={d},target={home},rw", home
57
+
58
+
59
+ def cmd_init(args: argparse.Namespace, passthrough: list[str]) -> int:
60
+ from z_ctk.cli import NAME_RE
61
+
62
+ user = os.environ.get("USER") or pathlib.Path.home().name
63
+ container_user = args.container_user or user
64
+ container_name = args.container_name or f"{user}-dev"
65
+
66
+ if not NAME_RE.match(container_user):
67
+ raise SystemExit(f"invalid container user: {container_user}")
68
+ if not NAME_RE.match(container_name):
69
+ raise SystemExit(f"invalid container name: {container_name}")
70
+ if not 1 <= args.port <= 65535:
71
+ raise SystemExit(f"invalid port: {args.port}")
72
+ if args.no_workspace and args.workspace:
73
+ raise SystemExit("--workspace and --no-workspace conflict")
74
+
75
+ ak_file, ak_text, ak_source = resolve_authorized_keys(args)
76
+ workspace_mount, container_home = build_workspace_mount(args.workspace, args.no_workspace, container_user)
77
+
78
+ mounts = list(args.mount)
79
+ if workspace_mount:
80
+ mounts.insert(0, workspace_mount)
81
+
82
+ podman = require_binary("podman")
83
+ systemctl = require_binary("systemctl")
84
+ loginctl = require_binary("loginctl")
85
+
86
+ # Conflict checks
87
+ if run([podman, "container", "exists", container_name], check=False).returncode == 0:
88
+ raise SystemExit(f"container already exists: {container_name}")
89
+
90
+ paths = managed_paths(container_name)
91
+ for p in [paths.container_unit, paths.sshd_unit, paths.container_helper, paths.sshd_helper, paths.metadata]:
92
+ if p.exists():
93
+ raise SystemExit(f"managed files already exist for {container_name}")
94
+
95
+ # Write helpers + units
96
+ write_text(
97
+ paths.container_helper,
98
+ render_container_helper(podman, container_name, args.image, args.port, mounts, args.device, passthrough),
99
+ stat.S_IRWXU,
100
+ )
101
+ write_text(
102
+ paths.sshd_helper,
103
+ render_sshd_helper(podman, container_name, container_user, os.getuid(), os.getgid(), container_home, ak_file, ak_text),
104
+ stat.S_IRWXU,
105
+ )
106
+ write_text(paths.container_unit, render_unit("container", container_name=container_name, container_helper=str(paths.container_helper)))
107
+ write_text(paths.sshd_unit, render_unit("sshd", container_name=container_name, container_unit=paths.container_unit.name, sshd_helper=str(paths.sshd_helper)))
108
+
109
+ # Metadata
110
+ write_text(paths.metadata, json.dumps({
111
+ "container_name": container_name,
112
+ "container_user": container_user,
113
+ "image": args.image,
114
+ "port": args.port,
115
+ "container_home": container_home,
116
+ "workspace_mount": workspace_mount or "",
117
+ "authorized_keys_source": ak_source,
118
+ }, indent=2, sort_keys=True) + "\n")
119
+
120
+ # Enable — rollback on failure
121
+ try:
122
+ run([systemctl, "--user", "daemon-reload"])
123
+ run([systemctl, "--user", "enable", "--now", paths.container_unit.name])
124
+ run([systemctl, "--user", "enable", "--now", paths.sshd_unit.name])
125
+ except Exception:
126
+ print(f"startup failed, cleaning up {container_name}", file=sys.stderr)
127
+ _cleanup(podman, systemctl, paths, container_name)
128
+ raise
129
+
130
+ print(f"started {container_name}")
131
+ print(f" ssh {container_user}@localhost -p {args.port}")
132
+
133
+ # Linger check
134
+ res = run([loginctl, "show-user", user, "-p", "Linger"], check=False, capture=True)
135
+ if res.returncode == 0 and res.stdout.strip().endswith("no"):
136
+ print(f" hint: sudo loginctl enable-linger {user}", file=sys.stderr)
137
+
138
+ return 0
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # ls
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def cmd_ls() -> int:
146
+ names = list_names()
147
+ if not names:
148
+ print("no z-ctk containers")
149
+ return 0
150
+
151
+ podman = require_binary("podman")
152
+ systemctl = require_binary("systemctl")
153
+
154
+ for name in names:
155
+ paths = managed_paths(name)
156
+ meta = _read_meta(paths.metadata)
157
+ parts = [
158
+ name,
159
+ f"user={meta.get('container_user', '-')}",
160
+ f"podman={_container_status(podman, name)}",
161
+ f"port={meta.get('port', '-')}",
162
+ f"image={meta.get('image', '-')}",
163
+ ]
164
+ print(" ".join(parts))
165
+ return 0
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # rm
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def cmd_rm(args: argparse.Namespace) -> int:
173
+ user = os.environ.get("USER") or pathlib.Path.home().name
174
+
175
+ if args.all and args.container_name:
176
+ raise SystemExit("rm accepts either a name or --all, not both")
177
+
178
+ if args.all:
179
+ names = list_names()
180
+ if not names:
181
+ print("no z-ctk containers")
182
+ return 0
183
+ else:
184
+ names = [args.container_name or f"{user}-dev"]
185
+
186
+ podman = require_binary("podman")
187
+ systemctl = require_binary("systemctl")
188
+
189
+ for name in names:
190
+ paths = managed_paths(name)
191
+ exists = any(p.exists() for p in [paths.metadata, paths.container_helper, paths.sshd_helper, paths.container_unit, paths.sshd_unit])
192
+ exists = exists or run([podman, "container", "exists", name], check=False).returncode == 0
193
+
194
+ if not exists:
195
+ if args.all:
196
+ continue
197
+ raise SystemExit(f"not found: {name}")
198
+
199
+ _cleanup(podman, systemctl, paths, name)
200
+ print(f"removed {name}")
201
+
202
+ return 0
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # helpers
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def _cleanup(podman: str, systemctl: str, paths: ManagedPaths, name: str) -> None:
210
+ """Stop services, remove container, delete managed files."""
211
+ run([systemctl, "--user", "disable", "--now", paths.sshd_unit.name], check=False, capture=True)
212
+ run([systemctl, "--user", "disable", "--now", paths.container_unit.name], check=False, capture=True)
213
+ res = run([podman, "rm", "-f", "--ignore", name], check=False, capture=True)
214
+ if res.returncode != 0:
215
+ print(f"warning: podman rm failed for {name}: {res.stderr.strip()}", file=sys.stderr)
216
+
217
+ for p in [paths.container_unit, paths.sshd_unit, paths.container_helper, paths.sshd_helper, paths.metadata]:
218
+ unlink_if_exists(p)
219
+
220
+ if paths.helper_dir.exists() and not any(paths.helper_dir.iterdir()):
221
+ paths.helper_dir.rmdir()
222
+
223
+ run([systemctl, "--user", "daemon-reload"], check=False)
224
+
225
+
226
+ def list_names() -> list[str]:
227
+ d = state_root() / STATE_DIR_NAME
228
+ if not d.exists():
229
+ return []
230
+ names: set[str] = set()
231
+ for p in d.glob("*.json"):
232
+ names.add(p.stem)
233
+ for p in d.glob("*-container.sh"):
234
+ names.add(p.name.removesuffix("-container.sh"))
235
+ return sorted(names)
236
+
237
+
238
+ def _read_meta(path: pathlib.Path) -> dict:
239
+ if not path.exists():
240
+ return {}
241
+ try:
242
+ return json.loads(path.read_text())
243
+ except json.JSONDecodeError:
244
+ return {}
245
+
246
+
247
+ def _container_status(podman: str, name: str) -> str:
248
+ res = run([podman, "inspect", "-f", "{{.State.Status}}", name], check=False, capture=True)
249
+ return res.stdout.strip() if res.returncode == 0 else "missing"
z_ctk/helpers.py ADDED
@@ -0,0 +1,201 @@
1
+ """Generate the container and sshd helper shell scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import shlex
7
+
8
+
9
+ def _sq(s: str) -> str:
10
+ return shlex.quote(s)
11
+
12
+
13
+ def _shell_join(parts: list[str]) -> str:
14
+ return " ".join(shlex.quote(p) for p in parts)
15
+
16
+
17
+ def render_container_helper(
18
+ podman: str,
19
+ name: str,
20
+ image: str,
21
+ port: int,
22
+ mounts: list[str],
23
+ devices: list[str],
24
+ extra: list[str],
25
+ ) -> str:
26
+ create_cmd = [podman, "create", "--name", name, "--userns", "keep-id", "--init", "--stop-timeout", "5"]
27
+ for d in devices:
28
+ create_cmd.extend(["--device", d])
29
+ for m in mounts:
30
+ create_cmd.extend(["--mount", m])
31
+ create_cmd.extend(["--publish", f"127.0.0.1:{port}:22"])
32
+ create_cmd.extend(extra)
33
+ create_cmd.extend([image, "sleep", "infinity"])
34
+
35
+ return f"""\
36
+ #!/bin/sh
37
+ set -eu
38
+
39
+ podman={_sq(podman)}
40
+ name={_sq(name)}
41
+
42
+ create() {{
43
+ if "$podman" container exists "$name"; then exit 0; fi
44
+ exec {_shell_join(create_cmd)}
45
+ }}
46
+
47
+ start() {{
48
+ running=$("$podman" inspect -f '{{{{.State.Running}}}}' "$name" 2>/dev/null || printf 'false\\n')
49
+ if [ "$running" = "true" ]; then exec "$podman" attach "$name"; fi
50
+ exec "$podman" start --attach "$name"
51
+ }}
52
+
53
+ stop() {{
54
+ exec "$podman" stop --ignore -t 10 "$name"
55
+ }}
56
+
57
+ case "${{1:-}}" in
58
+ create) create ;; start) start ;; stop) stop ;;
59
+ *) echo "usage: $0 {{create|start|stop}}" >&2; exit 2 ;;
60
+ esac
61
+ """
62
+
63
+
64
+ def render_sshd_helper(
65
+ podman: str,
66
+ name: str,
67
+ container_user: str,
68
+ uid: int,
69
+ gid: int,
70
+ container_home: str,
71
+ authorized_keys_file: pathlib.Path | None,
72
+ authorized_keys_text: str | None,
73
+ ) -> str:
74
+ ak_path = f"/etc/ssh/authorized_keys/{container_user}"
75
+ sudoers = f"/etc/sudoers.d/90-{container_user}"
76
+
77
+ # Build the copy-keys command.
78
+ # Use shell builtins only (no cat/cp) — systemd user services on NixOS
79
+ # have a minimal PATH that excludes coreutils.
80
+ if authorized_keys_file is not None:
81
+ copy_keys = (
82
+ f'"$podman" exec --user root -i "$name" /bin/sh -c \'cat >{ak_path}\''
83
+ f' < {_sq(str(authorized_keys_file))}'
84
+ )
85
+ else:
86
+ copy_keys = (
87
+ f'printf \'%s\\n\' {_sq(authorized_keys_text or "")} | '
88
+ f'"$podman" exec --user root -i "$name" /bin/sh -c \'cat >{ak_path}\''
89
+ )
90
+
91
+ # The bootstrap script runs entirely inside the container via heredoc.
92
+ # All variables are hardcoded literals — no host-side expansion.
93
+ return f"""\
94
+ #!/bin/sh
95
+ set -eu
96
+ # pipefail: catch failures in pipe left-hand side (e.g. missing binary)
97
+ ( set -o pipefail 2>/dev/null ) && set -o pipefail
98
+
99
+ podman={_sq(podman)}
100
+ name={_sq(name)}
101
+
102
+ exec_root() {{
103
+ "$podman" exec --user root "$name" /bin/sh -lc "$@"
104
+ }}
105
+
106
+ stop_sshd() {{
107
+ exec_root 'if [ -f /run/sshd.pid ]; then kill "$(cat /run/sshd.pid)"; fi' >/dev/null 2>&1 || true
108
+ }}
109
+
110
+ bootstrap() {{
111
+ # Check apt-get
112
+ exec_root 'command -v apt-get >/dev/null 2>&1' >/dev/null 2>&1 || {{
113
+ echo "apt-get is required inside $name" >&2; exit 1
114
+ }}
115
+
116
+ # Install packages if needed
117
+ packages=""
118
+ exec_root 'test -x /usr/sbin/sshd' || packages="$packages openssh-server"
119
+ exec_root 'command -v sudo >/dev/null 2>&1' || packages="$packages sudo"
120
+ if [ -n "$packages" ]; then
121
+ "$podman" exec --user root "$name" /bin/sh -lc \\
122
+ "export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y --no-install-recommends $packages"
123
+ fi
124
+
125
+ # Create user and configure sshd — all values are literals, no host expansion
126
+ "$podman" exec --user root -i "$name" /bin/sh <<'BOOTSTRAP'
127
+ set -eu
128
+ container_user={container_user}
129
+ container_uid={uid}
130
+ container_gid={gid}
131
+ container_home={_sq(container_home)}
132
+
133
+ # Handle GID — reuse or create
134
+ existing_group=$(getent group "$container_gid" 2>/dev/null | cut -d: -f1 || true)
135
+ if [ -n "$existing_group" ]; then
136
+ group_name="$existing_group"
137
+ elif getent group "$container_user" >/dev/null 2>&1; then
138
+ # Group name exists with different GID — rename it
139
+ groupmod -g "$container_gid" "$(getent group "$container_user" | cut -d: -f1)"
140
+ group_name="$container_user"
141
+ else
142
+ groupadd -g "$container_gid" "$container_user"
143
+ group_name="$container_user"
144
+ fi
145
+
146
+ # Handle UID — reuse, rename, or create
147
+ uid_owner=$(getent passwd "$container_uid" 2>/dev/null | cut -d: -f1 || true)
148
+ if [ -n "$uid_owner" ] && [ "$uid_owner" != "$container_user" ]; then
149
+ # UID taken by another user (e.g. 'ubuntu') — rename it
150
+ usermod -l "$container_user" -d "$container_home" -m -g "$container_gid" -s /bin/bash "$uid_owner"
151
+ elif id -u "$container_user" >/dev/null 2>&1; then
152
+ usermod -u "$container_uid" -d "$container_home" -g "$container_gid" -s /bin/bash "$container_user"
153
+ else
154
+ useradd -M -d "$container_home" -s /bin/bash -u "$container_uid" -g "$container_gid" "$container_user"
155
+ fi
156
+
157
+ mkdir -p "$container_home" /run/sshd /etc/ssh/authorized_keys /etc/ssh/sshd_config.d /etc/sudoers.d
158
+ chown "$container_uid:$container_gid" "$container_home" >/dev/null 2>&1 || true
159
+
160
+ # Sudoers
161
+ printf '%s ALL=(ALL) NOPASSWD:ALL\\n' "$container_user" >{sudoers}
162
+ chmod 440 {sudoers}
163
+
164
+ # SSH authorized keys dir
165
+ chmod 755 /etc/ssh/authorized_keys
166
+ ssh-keygen -A
167
+ BOOTSTRAP
168
+
169
+ # Copy authorized keys from host
170
+ {copy_keys}
171
+ exec_root 'chmod 644 {ak_path} && chown root:root {ak_path}'
172
+
173
+ # sshd config
174
+ "$podman" exec --user root -i "$name" /bin/sh -c 'cat >/etc/ssh/sshd_config.d/10-rootless-dev.conf' <<'SSHDCONF'
175
+ PermitRootLogin no
176
+ PasswordAuthentication no
177
+ KbdInteractiveAuthentication no
178
+ ChallengeResponseAuthentication no
179
+ PubkeyAuthentication yes
180
+ AuthorizedKeysFile /etc/ssh/authorized_keys/%u
181
+ AllowUsers {container_user}
182
+ PidFile /run/sshd.pid
183
+ SSHDCONF
184
+
185
+ exec_root '/usr/sbin/sshd -t'
186
+ }}
187
+
188
+ start() {{
189
+ stop_sshd
190
+ exec "$podman" exec --user root "$name" /usr/sbin/sshd -D -e -o PidFile=/run/sshd.pid
191
+ }}
192
+
193
+ stop() {{
194
+ stop_sshd
195
+ }}
196
+
197
+ case "${{1:-}}" in
198
+ bootstrap) bootstrap ;; start) start ;; stop) stop ;;
199
+ *) echo "usage: $0 {{bootstrap|start|stop}}" >&2; exit 2 ;;
200
+ esac
201
+ """
z_ctk/paths.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ from dataclasses import dataclass
6
+
7
+ STATE_DIR_NAME = "z-ctk"
8
+
9
+
10
+ def state_root() -> pathlib.Path:
11
+ xdg = os.environ.get("XDG_STATE_HOME")
12
+ if xdg:
13
+ return pathlib.Path(xdg).expanduser()
14
+ return pathlib.Path.home() / ".local" / "state"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ManagedPaths:
19
+ units_dir: pathlib.Path
20
+ helper_dir: pathlib.Path
21
+ metadata: pathlib.Path
22
+ container_unit: pathlib.Path
23
+ sshd_unit: pathlib.Path
24
+ container_helper: pathlib.Path
25
+ sshd_helper: pathlib.Path
26
+
27
+
28
+ def managed_paths(name: str) -> ManagedPaths:
29
+ home = pathlib.Path.home()
30
+ units = home / ".config" / "systemd" / "user"
31
+ helpers = state_root() / STATE_DIR_NAME
32
+ return ManagedPaths(
33
+ units_dir=units,
34
+ helper_dir=helpers,
35
+ metadata=helpers / f"{name}.json",
36
+ container_unit=units / f"{name}.service",
37
+ sshd_unit=units / f"{name}-sshd.service",
38
+ container_helper=helpers / f"{name}-container.sh",
39
+ sshd_helper=helpers / f"{name}-sshd.sh",
40
+ )
z_ctk/systemd.py ADDED
@@ -0,0 +1,49 @@
1
+ """Render systemd user unit files from embedded templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from string import Template
6
+
7
+ TEMPLATES = {
8
+ "container": """\
9
+ [Unit]
10
+ Description=z-ctk container $container_name
11
+
12
+ [Service]
13
+ Type=simple
14
+ TimeoutStartSec=300
15
+ TimeoutStopSec=15
16
+ Restart=on-failure
17
+ RestartSec=5
18
+ ExecStartPre=$container_helper create
19
+ ExecStart=$container_helper start
20
+ ExecStop=$container_helper stop
21
+
22
+ [Install]
23
+ WantedBy=default.target
24
+ """,
25
+ "sshd": """\
26
+ [Unit]
27
+ Description=z-ctk OpenSSH server in $container_name
28
+ Requires=$container_unit
29
+ After=$container_unit
30
+ BindsTo=$container_unit
31
+ PartOf=$container_unit
32
+
33
+ [Service]
34
+ Type=simple
35
+ TimeoutStartSec=300
36
+ Restart=always
37
+ RestartSec=5
38
+ ExecStartPre=$sshd_helper bootstrap
39
+ ExecStart=$sshd_helper start
40
+ ExecStop=$sshd_helper stop
41
+
42
+ [Install]
43
+ WantedBy=default.target
44
+ """,
45
+ }
46
+
47
+
48
+ def render_unit(kind: str, **values: str) -> str:
49
+ return Template(TEMPLATES[kind]).substitute(values)
z_ctk/util.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ import shutil
5
+ import subprocess
6
+
7
+
8
+ def require_binary(name: str) -> str:
9
+ path = shutil.which(name)
10
+ if not path:
11
+ raise SystemExit(f"missing required binary: {name}")
12
+ return path
13
+
14
+
15
+ def run(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]:
16
+ return subprocess.run(cmd, check=check, text=True, capture_output=capture)
17
+
18
+
19
+ def write_text(path: pathlib.Path, content: str, mode: int | None = None) -> None:
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ path.write_text(content)
22
+ if mode is not None:
23
+ path.chmod(mode)
24
+
25
+
26
+ def unlink_if_exists(path: pathlib.Path) -> None:
27
+ if path.exists():
28
+ path.unlink()