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.
- devctk-0.1.0.dist-info/METADATA +10 -0
- devctk-0.1.0.dist-info/RECORD +12 -0
- devctk-0.1.0.dist-info/WHEEL +4 -0
- devctk-0.1.0.dist-info/entry_points.txt +2 -0
- z_ctk/__init__.py +3 -0
- z_ctk/__main__.py +3 -0
- z_ctk/cli.py +96 -0
- z_ctk/commands.py +249 -0
- z_ctk/helpers.py +201 -0
- z_ctk/paths.py +40 -0
- z_ctk/systemd.py +49 -0
- z_ctk/util.py +28 -0
|
@@ -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,,
|
z_ctk/__init__.py
ADDED
z_ctk/__main__.py
ADDED
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()
|