devctk 0.1.0__tar.gz → 0.2.0__tar.gz
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.2.0/.github/workflows/publish.yml +34 -0
- devctk-0.2.0/DESIGN.md +95 -0
- {devctk-0.1.0 → devctk-0.2.0}/PKG-INFO +3 -3
- {devctk-0.1.0 → devctk-0.2.0}/pyproject.toml +5 -5
- devctk-0.2.0/src/devctk/__init__.py +3 -0
- devctk-0.2.0/src/devctk/__main__.py +3 -0
- devctk-0.2.0/src/devctk/agent.py +38 -0
- {devctk-0.1.0/src/z_ctk → devctk-0.2.0/src/devctk}/cli.py +37 -8
- devctk-0.2.0/src/devctk/commands.py +354 -0
- devctk-0.2.0/src/devctk/helpers.py +296 -0
- devctk-0.2.0/src/devctk/nix.py +52 -0
- {devctk-0.1.0/src/z_ctk → devctk-0.2.0/src/devctk}/paths.py +5 -3
- {devctk-0.1.0/src/z_ctk → devctk-0.2.0/src/devctk}/systemd.py +2 -3
- devctk-0.2.0/tests/test_smoke.py +169 -0
- {devctk-0.1.0 → devctk-0.2.0}/uv.lock +15 -15
- devctk-0.1.0/.claude/settings.local.json +0 -11
- devctk-0.1.0/DESIGN.md +0 -52
- devctk-0.1.0/archive/z-ctk-codex +0 -640
- devctk-0.1.0/src/z_ctk/__init__.py +0 -3
- devctk-0.1.0/src/z_ctk/__main__.py +0 -3
- devctk-0.1.0/src/z_ctk/commands.py +0 -249
- devctk-0.1.0/src/z_ctk/helpers.py +0 -201
- devctk-0.1.0/tests/test_smoke.py +0 -87
- {devctk-0.1.0 → devctk-0.2.0}/.gitignore +0 -0
- {devctk-0.1.0/src/z_ctk → devctk-0.2.0/src/devctk}/util.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: pip install build
|
|
17
|
+
- run: python -m build
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: dist
|
|
21
|
+
path: dist/
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: build
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/download-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
devctk-0.2.0/DESIGN.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# devctk
|
|
2
|
+
|
|
3
|
+
One-command rootless Podman dev containers, managed by systemd user services.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
devctk init --image ubuntu:24.04
|
|
7
|
+
devctk init --image ubuntu:24.04 --ssh --authorized-keys-file ~/.ssh/authorized_keys
|
|
8
|
+
devctk init --image alpine:latest --nix --agent claude --mirror --workspace ~/projects/myapp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You get: a running container with your UID mapped in, workspace bind-mounted, passwordless sudo, auto-start via systemd, and optionally SSH + Nix tools + agent configs.
|
|
12
|
+
|
|
13
|
+
## Distribution
|
|
14
|
+
|
|
15
|
+
- PyPI package, runnable via `uvx devctk`
|
|
16
|
+
- Pure Python, minimum 3.11
|
|
17
|
+
|
|
18
|
+
## Host Requirements
|
|
19
|
+
|
|
20
|
+
Podman (rootless), systemd, loginctl. Refuses to run as root.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
**`init`** (default) — create and start a dev container.
|
|
25
|
+
|
|
26
|
+
- `--image` (required)
|
|
27
|
+
- `--name` (default `<user>-dev`)
|
|
28
|
+
- `--ssh` — enable SSH access (requires `--authorized-keys` or `--authorized-keys-file`)
|
|
29
|
+
- `--port` (default 39000, requires `--ssh`)
|
|
30
|
+
- `--nix` — mount Nix store + profiles, set PATH
|
|
31
|
+
- `--agent claude|codex` (repeatable) — mount agent config dirs
|
|
32
|
+
- `--mirror` — workspace at same absolute path as host
|
|
33
|
+
- `--workspace PATH` (default `~/devctk/<name>`)
|
|
34
|
+
- `--no-workspace` — skip workspace mount
|
|
35
|
+
- `--mount`, `--device` (repeatable), extra podman flags after `--`
|
|
36
|
+
|
|
37
|
+
**`ls`** — list managed containers with status.
|
|
38
|
+
|
|
39
|
+
**`rm [NAME] [--all]`** — stop and remove container, units, and state.
|
|
40
|
+
|
|
41
|
+
## What `init` Does
|
|
42
|
+
|
|
43
|
+
1. Write a bootstrap script (container entrypoint) to the state dir
|
|
44
|
+
2. Create rootless Podman container (`--userns keep-id`, `--init`, bootstrap as entrypoint)
|
|
45
|
+
3. Bootstrap runs on every container start (idempotent):
|
|
46
|
+
- Detect package manager (apt/apk), install sudo + bash if missing
|
|
47
|
+
- Create user matching host UID/GID with passwordless sudo
|
|
48
|
+
- If `--nix`: write `/etc/profile.d/99-devctk-nix.sh`
|
|
49
|
+
- If `--ssh`: install sshd, configure key-only auth, write sshd config
|
|
50
|
+
- Signal readiness via `/run/devctk-ready`, then exec `sleep infinity`
|
|
51
|
+
4. Install systemd user unit for the container
|
|
52
|
+
5. If `--ssh`: install a second unit for sshd (waits for bootstrap readiness)
|
|
53
|
+
6. Enable and start
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
### SSH (`--ssh`)
|
|
58
|
+
|
|
59
|
+
SSH access via `ssh user@localhost -p PORT`. Requires authorized keys. Installs sshd inside the container, binds to 127.0.0.1 only. Managed by a separate systemd unit that depends on the container unit.
|
|
60
|
+
|
|
61
|
+
Without `--ssh`, access the container via `podman exec -it NAME bash`.
|
|
62
|
+
|
|
63
|
+
### Nix (`--nix`)
|
|
64
|
+
|
|
65
|
+
Mounts `/nix/store`, `/etc/profiles/per-user/<user>`, and `/run/current-system` read-only. Uses unresolved symlink-tree paths (not `.resolve()`) so mounts survive `nixos-rebuild` + garbage collection. Writes PATH to `/etc/profile.d/` for SSH login shells.
|
|
66
|
+
|
|
67
|
+
### Agent configs (`--agent claude|codex`)
|
|
68
|
+
|
|
69
|
+
Mounts agent config directories into the container user's home (read-write):
|
|
70
|
+
- `--agent claude`: `~/.claude/` + `~/.claude.json`
|
|
71
|
+
- `--agent codex`: `~/.codex/`
|
|
72
|
+
|
|
73
|
+
No preprocessing — mount as-is. Container detection for scripts: check `/run/.containerenv` (podman auto-creates this).
|
|
74
|
+
|
|
75
|
+
### Mirror mode (`--mirror`)
|
|
76
|
+
|
|
77
|
+
Mounts workspace at the same absolute path in host and container. Enables agent session continuity (e.g., Claude's project history is keyed by absolute path). Refuses to mount `$HOME` itself. Default workspace in mirror mode: current directory.
|
|
78
|
+
|
|
79
|
+
## File Layout
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
~/.config/systemd/user/<name>.service[, <name>-sshd.service]
|
|
83
|
+
~/.local/state/devctk/<name>.json, <name>-container.sh, <name>-bootstrap.sh[, <name>-sshd.sh]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Supported Images
|
|
87
|
+
|
|
88
|
+
Debian/Ubuntu (apt) and Alpine (apk). Other images work if sshd + sudo are pre-installed.
|
|
89
|
+
|
|
90
|
+
## Constraints
|
|
91
|
+
|
|
92
|
+
- Rootless only (no root)
|
|
93
|
+
- SSH bound to 127.0.0.1 (when enabled)
|
|
94
|
+
- Container user is always the host user (same name, UID, GID)
|
|
95
|
+
- One-shot CLI; systemd handles lifecycle
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devctk
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: One-command
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: One-command rootless Podman dev containers with Nix and agent support
|
|
5
5
|
Author: y
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Classifier: Environment :: Console
|
|
8
8
|
Classifier: Operating System :: POSIX :: Linux
|
|
9
9
|
Classifier: Topic :: Software Development
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.11
|
|
@@ -4,9 +4,9 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devctk"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "One-command
|
|
9
|
-
requires-python = ">=3.
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "One-command rootless Podman dev containers with Nix and agent support"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
authors = [{ name = "y" }]
|
|
12
12
|
classifiers = [
|
|
@@ -16,10 +16,10 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[tool.hatch.build.targets.wheel]
|
|
19
|
-
packages = ["src/
|
|
19
|
+
packages = ["src/devctk"]
|
|
20
20
|
|
|
21
21
|
[dependency-groups]
|
|
22
22
|
dev = ["pytest"]
|
|
23
23
|
|
|
24
24
|
[project.scripts]
|
|
25
|
-
devctk = "
|
|
25
|
+
devctk = "devctk.cli:main"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Agent config dir mounting for Claude Code and Codex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOME = Path.home()
|
|
8
|
+
|
|
9
|
+
AGENTS: dict[str, dict] = {
|
|
10
|
+
"claude": {
|
|
11
|
+
"dirs": [HOME / ".claude"],
|
|
12
|
+
"files": [HOME / ".claude.json"],
|
|
13
|
+
},
|
|
14
|
+
"codex": {
|
|
15
|
+
"dirs": [HOME / ".codex"],
|
|
16
|
+
"files": [],
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def agent_mounts(agents: list[str], container_home: str) -> list[tuple[str, str, str]]:
|
|
22
|
+
"""Return (host_path, container_path, mode) tuples for agent config mounts.
|
|
23
|
+
|
|
24
|
+
Directories are created on the host if missing (so bind mounts work).
|
|
25
|
+
Files are skipped if they don't exist yet (agent creates them on first run).
|
|
26
|
+
"""
|
|
27
|
+
mounts: list[tuple[str, str, str]] = []
|
|
28
|
+
for name in agents:
|
|
29
|
+
spec = AGENTS.get(name)
|
|
30
|
+
if not spec:
|
|
31
|
+
continue
|
|
32
|
+
for d in spec["dirs"]:
|
|
33
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
mounts.append((str(d), f"{container_home}/{d.name}", "rw"))
|
|
35
|
+
for f in spec["files"]:
|
|
36
|
+
if f.is_file():
|
|
37
|
+
mounts.append((str(f), f"{container_home}/{f.name}", "rw"))
|
|
38
|
+
return mounts
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import os
|
|
5
|
-
import pathlib
|
|
6
5
|
import re
|
|
7
6
|
import sys
|
|
8
7
|
|
|
@@ -12,8 +11,8 @@ COMMANDS = {"init", "ls", "rm"}
|
|
|
12
11
|
|
|
13
12
|
def build_parser() -> argparse.ArgumentParser:
|
|
14
13
|
parser = argparse.ArgumentParser(
|
|
15
|
-
prog="
|
|
16
|
-
description="One-command
|
|
14
|
+
prog="devctk",
|
|
15
|
+
description="One-command rootless Podman dev containers.",
|
|
17
16
|
)
|
|
18
17
|
sub = parser.add_subparsers(dest="command")
|
|
19
18
|
sub.required = True
|
|
@@ -21,14 +20,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
21
20
|
# --- init ---
|
|
22
21
|
p_init = sub.add_parser("init", help="Create and enable a dev container.")
|
|
23
22
|
p_init.add_argument("--image", required=True)
|
|
23
|
+
p_init.add_argument("--name", dest="container_name")
|
|
24
|
+
|
|
25
|
+
# SSH (opt-in)
|
|
26
|
+
p_init.add_argument("--ssh", action="store_true", help="Enable SSH access")
|
|
24
27
|
p_init.add_argument("--port", type=int, default=39000)
|
|
25
|
-
keys = p_init.add_mutually_exclusive_group(
|
|
28
|
+
keys = p_init.add_mutually_exclusive_group()
|
|
26
29
|
keys.add_argument("--authorized-keys", dest="authorized_keys_text")
|
|
27
30
|
keys.add_argument("--authorized-keys-file", dest="authorized_keys_file")
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
|
|
32
|
+
# Features
|
|
33
|
+
p_init.add_argument("--nix", action="store_true", help="Mount Nix store and set PATH")
|
|
34
|
+
p_init.add_argument("--agent", action="append", default=[], choices=["claude", "codex"],
|
|
35
|
+
help="Mount agent config dirs (repeatable)")
|
|
36
|
+
|
|
37
|
+
# Workspace
|
|
30
38
|
p_init.add_argument("--workspace")
|
|
31
39
|
p_init.add_argument("--no-workspace", action="store_true")
|
|
40
|
+
p_init.add_argument("--mirror", action="store_true",
|
|
41
|
+
help="Mount workspace at same absolute path as host")
|
|
42
|
+
|
|
43
|
+
# Extra podman flags
|
|
32
44
|
p_init.add_argument("--mount", action="append", default=[])
|
|
33
45
|
p_init.add_argument("--device", action="append", default=[])
|
|
34
46
|
|
|
@@ -44,7 +56,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
44
56
|
|
|
45
57
|
|
|
46
58
|
def normalize_argv(argv: list[str]) -> list[str]:
|
|
47
|
-
"""Treat bare args (no subcommand) as
|
|
59
|
+
"""Treat bare args (no subcommand) as ``init``."""
|
|
48
60
|
if not argv:
|
|
49
61
|
return argv
|
|
50
62
|
if argv[0] in COMMANDS or argv[0] in {"-h", "--help"}:
|
|
@@ -75,7 +87,7 @@ def validate_passthrough(args: list[str]) -> None:
|
|
|
75
87
|
|
|
76
88
|
|
|
77
89
|
def main() -> int:
|
|
78
|
-
from
|
|
90
|
+
from devctk.commands import cmd_init, cmd_ls, cmd_rm
|
|
79
91
|
|
|
80
92
|
if os.geteuid() == 0:
|
|
81
93
|
raise SystemExit("refuse to run as root")
|
|
@@ -87,9 +99,26 @@ def main() -> int:
|
|
|
87
99
|
|
|
88
100
|
if args.command == "init":
|
|
89
101
|
validate_passthrough(passthrough)
|
|
102
|
+
|
|
103
|
+
# SSH flag dependencies
|
|
104
|
+
if args.ssh:
|
|
105
|
+
if not args.authorized_keys_text and not args.authorized_keys_file:
|
|
106
|
+
raise SystemExit("--ssh requires --authorized-keys or --authorized-keys-file")
|
|
107
|
+
else:
|
|
108
|
+
if args.authorized_keys_text or args.authorized_keys_file:
|
|
109
|
+
raise SystemExit("--authorized-keys requires --ssh")
|
|
110
|
+
|
|
111
|
+
# Workspace conflicts
|
|
112
|
+
if args.no_workspace and args.workspace:
|
|
113
|
+
raise SystemExit("--workspace and --no-workspace conflict")
|
|
114
|
+
if args.no_workspace and args.mirror:
|
|
115
|
+
raise SystemExit("--mirror and --no-workspace conflict")
|
|
116
|
+
|
|
90
117
|
return cmd_init(args, passthrough)
|
|
118
|
+
|
|
91
119
|
if args.command == "ls":
|
|
92
120
|
return cmd_ls()
|
|
121
|
+
|
|
93
122
|
if args.command == "rm":
|
|
94
123
|
return cmd_rm(args)
|
|
95
124
|
|
|
@@ -0,0 +1,354 @@
|
|
|
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 devctk.paths import ManagedPaths, managed_paths, state_root, STATE_DIR_NAME
|
|
13
|
+
from devctk.helpers import render_bootstrap, render_container_helper, render_sshd_helper
|
|
14
|
+
from devctk.systemd import render_unit
|
|
15
|
+
from devctk.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]:
|
|
23
|
+
"""Return (file_path | None, text | None)."""
|
|
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
|
|
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
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_workspace_mount(
|
|
38
|
+
workspace: str | None,
|
|
39
|
+
mirror: bool,
|
|
40
|
+
user: str,
|
|
41
|
+
container_name: str,
|
|
42
|
+
container_home: str,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Return a podman ``--mount`` spec string for the workspace."""
|
|
45
|
+
if mirror:
|
|
46
|
+
src = pathlib.Path(workspace).expanduser().resolve() if workspace else pathlib.Path.cwd().resolve()
|
|
47
|
+
if src == pathlib.Path.home():
|
|
48
|
+
raise SystemExit("--mirror: refusing to mount entire home directory")
|
|
49
|
+
src.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return f"type=bind,src={src},target={src},rw"
|
|
51
|
+
|
|
52
|
+
if workspace:
|
|
53
|
+
src = pathlib.Path(workspace).expanduser().resolve()
|
|
54
|
+
else:
|
|
55
|
+
src = pathlib.Path.home() / "devctk" / container_name
|
|
56
|
+
src.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
return f"type=bind,src={src},target={container_home}/workspace,rw"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_init(args: argparse.Namespace, passthrough: list[str]) -> int:
|
|
61
|
+
from devctk.cli import NAME_RE
|
|
62
|
+
|
|
63
|
+
user = os.environ.get("USER") or pathlib.Path.home().name
|
|
64
|
+
container_name = args.container_name or f"{user}-dev"
|
|
65
|
+
container_home = f"/home/{user}"
|
|
66
|
+
uid = os.getuid()
|
|
67
|
+
gid = os.getgid()
|
|
68
|
+
|
|
69
|
+
if not NAME_RE.match(container_name):
|
|
70
|
+
raise SystemExit(f"invalid container name: {container_name}")
|
|
71
|
+
if args.ssh and not 1 <= args.port <= 65535:
|
|
72
|
+
raise SystemExit(f"invalid port: {args.port}")
|
|
73
|
+
|
|
74
|
+
# Authorized keys
|
|
75
|
+
ak_file: pathlib.Path | None = None
|
|
76
|
+
ak_text: str | None = None
|
|
77
|
+
if args.ssh:
|
|
78
|
+
ak_file, ak_text = resolve_authorized_keys(args)
|
|
79
|
+
|
|
80
|
+
# Workspace
|
|
81
|
+
workspace_mount: str | None = None
|
|
82
|
+
if not args.no_workspace:
|
|
83
|
+
workspace_mount = build_workspace_mount(
|
|
84
|
+
args.workspace, args.mirror, user, container_name, container_home,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# --- Collect all container mounts ---
|
|
88
|
+
container_mounts: list[str] = []
|
|
89
|
+
|
|
90
|
+
# Workspace (first — it's the outermost bind under $HOME)
|
|
91
|
+
if workspace_mount:
|
|
92
|
+
container_mounts.append(workspace_mount)
|
|
93
|
+
|
|
94
|
+
# Nix
|
|
95
|
+
nix_profile_content = ""
|
|
96
|
+
if args.nix:
|
|
97
|
+
from devctk.nix import nix_mounts, nix_profile_script
|
|
98
|
+
for host, target, mode in nix_mounts(user):
|
|
99
|
+
container_mounts.append(f"type=bind,src={host},target={target},{mode}")
|
|
100
|
+
nix_profile_content = nix_profile_script(user)
|
|
101
|
+
|
|
102
|
+
# Agent config dirs
|
|
103
|
+
if args.agent:
|
|
104
|
+
from devctk.agent import agent_mounts
|
|
105
|
+
for host, target, mode in agent_mounts(args.agent, container_home):
|
|
106
|
+
container_mounts.append(f"type=bind,src={host},target={target},{mode}")
|
|
107
|
+
|
|
108
|
+
# User extra mounts
|
|
109
|
+
container_mounts.extend(args.mount)
|
|
110
|
+
|
|
111
|
+
# --- Binaries ---
|
|
112
|
+
podman = require_binary("podman")
|
|
113
|
+
systemctl = require_binary("systemctl")
|
|
114
|
+
loginctl = require_binary("loginctl")
|
|
115
|
+
|
|
116
|
+
# --- Conflict checks ---
|
|
117
|
+
if run([podman, "container", "exists", container_name], check=False).returncode == 0:
|
|
118
|
+
raise SystemExit(f"container already exists: {container_name}")
|
|
119
|
+
|
|
120
|
+
paths = managed_paths(container_name)
|
|
121
|
+
managed = [paths.container_unit, paths.container_helper, paths.bootstrap_helper, paths.metadata]
|
|
122
|
+
if args.ssh:
|
|
123
|
+
managed.extend([paths.sshd_unit, paths.sshd_helper])
|
|
124
|
+
for p in managed:
|
|
125
|
+
if p.exists():
|
|
126
|
+
raise SystemExit(f"managed files already exist for {container_name}")
|
|
127
|
+
|
|
128
|
+
# --- Write bootstrap script (container entrypoint) ---
|
|
129
|
+
write_text(
|
|
130
|
+
paths.bootstrap_helper,
|
|
131
|
+
render_bootstrap(
|
|
132
|
+
user=user,
|
|
133
|
+
uid=uid,
|
|
134
|
+
gid=gid,
|
|
135
|
+
home=container_home,
|
|
136
|
+
ssh=args.ssh,
|
|
137
|
+
nix_profile=nix_profile_content,
|
|
138
|
+
authorized_keys_file="/tmp/devctk-authorized-keys" if ak_file else None,
|
|
139
|
+
authorized_keys_text=ak_text,
|
|
140
|
+
),
|
|
141
|
+
0o755,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# --- Internal mounts (bootstrap script + optional authorized-keys file) ---
|
|
145
|
+
internal_mounts = [
|
|
146
|
+
f"type=bind,src={paths.bootstrap_helper},target=/devctk-bootstrap.sh,ro",
|
|
147
|
+
]
|
|
148
|
+
if ak_file:
|
|
149
|
+
internal_mounts.append(f"type=bind,src={ak_file},target=/tmp/devctk-authorized-keys,ro")
|
|
150
|
+
|
|
151
|
+
all_mounts = internal_mounts + container_mounts
|
|
152
|
+
|
|
153
|
+
# --- Write container helper ---
|
|
154
|
+
write_text(
|
|
155
|
+
paths.container_helper,
|
|
156
|
+
render_container_helper(
|
|
157
|
+
podman=podman,
|
|
158
|
+
name=container_name,
|
|
159
|
+
image=args.image,
|
|
160
|
+
mounts=all_mounts,
|
|
161
|
+
devices=args.device,
|
|
162
|
+
extra=passthrough,
|
|
163
|
+
ssh_port=args.port if args.ssh else None,
|
|
164
|
+
),
|
|
165
|
+
stat.S_IRWXU,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# --- Write container unit ---
|
|
169
|
+
write_text(
|
|
170
|
+
paths.container_unit,
|
|
171
|
+
render_unit(
|
|
172
|
+
"container",
|
|
173
|
+
container_name=container_name,
|
|
174
|
+
container_helper=str(paths.container_helper),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# --- SSH-specific files ---
|
|
179
|
+
if args.ssh:
|
|
180
|
+
write_text(
|
|
181
|
+
paths.sshd_helper,
|
|
182
|
+
render_sshd_helper(podman=podman, name=container_name),
|
|
183
|
+
stat.S_IRWXU,
|
|
184
|
+
)
|
|
185
|
+
write_text(
|
|
186
|
+
paths.sshd_unit,
|
|
187
|
+
render_unit(
|
|
188
|
+
"sshd",
|
|
189
|
+
container_name=container_name,
|
|
190
|
+
container_unit=paths.container_unit.name,
|
|
191
|
+
sshd_helper=str(paths.sshd_helper),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# --- Metadata ---
|
|
196
|
+
write_text(paths.metadata, json.dumps({
|
|
197
|
+
"container_name": container_name,
|
|
198
|
+
"image": args.image,
|
|
199
|
+
"ssh": args.ssh,
|
|
200
|
+
"port": args.port if args.ssh else None,
|
|
201
|
+
"container_home": container_home,
|
|
202
|
+
"workspace_mount": workspace_mount or "",
|
|
203
|
+
"mirror": args.mirror,
|
|
204
|
+
"nix": args.nix,
|
|
205
|
+
"agents": args.agent,
|
|
206
|
+
}, indent=2, sort_keys=True) + "\n")
|
|
207
|
+
|
|
208
|
+
# --- Enable and start ---
|
|
209
|
+
try:
|
|
210
|
+
run([systemctl, "--user", "daemon-reload"])
|
|
211
|
+
run([systemctl, "--user", "enable", "--now", paths.container_unit.name])
|
|
212
|
+
if args.ssh:
|
|
213
|
+
run([systemctl, "--user", "enable", "--now", paths.sshd_unit.name])
|
|
214
|
+
except Exception:
|
|
215
|
+
print(f"startup failed, cleaning up {container_name}", file=sys.stderr)
|
|
216
|
+
_cleanup(podman, systemctl, paths, container_name)
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
# --- Success ---
|
|
220
|
+
print(f"started {container_name}")
|
|
221
|
+
if args.ssh:
|
|
222
|
+
print(f" ssh {user}@localhost -p {args.port}")
|
|
223
|
+
print(f" podman exec -it {container_name} bash")
|
|
224
|
+
|
|
225
|
+
# Linger check
|
|
226
|
+
res = run([loginctl, "show-user", user, "-p", "Linger"], check=False, capture=True)
|
|
227
|
+
if res.returncode == 0 and res.stdout.strip().endswith("no"):
|
|
228
|
+
print(f" hint: sudo loginctl enable-linger {user}", file=sys.stderr)
|
|
229
|
+
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# ls
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def cmd_ls() -> int:
|
|
238
|
+
names = list_names()
|
|
239
|
+
if not names:
|
|
240
|
+
print("no devctk containers")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
podman = require_binary("podman")
|
|
244
|
+
|
|
245
|
+
for name in names:
|
|
246
|
+
paths = managed_paths(name)
|
|
247
|
+
meta = _read_meta(paths.metadata)
|
|
248
|
+
parts = [
|
|
249
|
+
name,
|
|
250
|
+
f"podman={_container_status(podman, name)}",
|
|
251
|
+
f"image={meta.get('image', '-')}",
|
|
252
|
+
]
|
|
253
|
+
if meta.get("ssh"):
|
|
254
|
+
parts.append(f"port={meta.get('port', '-')}")
|
|
255
|
+
if meta.get("nix"):
|
|
256
|
+
parts.append("nix")
|
|
257
|
+
agents = meta.get("agents", [])
|
|
258
|
+
if agents:
|
|
259
|
+
parts.append(f"agents={','.join(agents)}")
|
|
260
|
+
if meta.get("mirror"):
|
|
261
|
+
parts.append("mirror")
|
|
262
|
+
print(" ".join(parts))
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# rm
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def cmd_rm(args: argparse.Namespace) -> int:
|
|
271
|
+
user = os.environ.get("USER") or pathlib.Path.home().name
|
|
272
|
+
|
|
273
|
+
if args.all and args.container_name:
|
|
274
|
+
raise SystemExit("rm accepts either a name or --all, not both")
|
|
275
|
+
|
|
276
|
+
if args.all:
|
|
277
|
+
names = list_names()
|
|
278
|
+
if not names:
|
|
279
|
+
print("no devctk containers")
|
|
280
|
+
return 0
|
|
281
|
+
else:
|
|
282
|
+
names = [args.container_name or f"{user}-dev"]
|
|
283
|
+
|
|
284
|
+
podman = require_binary("podman")
|
|
285
|
+
systemctl = require_binary("systemctl")
|
|
286
|
+
|
|
287
|
+
for name in names:
|
|
288
|
+
paths = managed_paths(name)
|
|
289
|
+
all_paths = [
|
|
290
|
+
paths.metadata, paths.container_helper, paths.bootstrap_helper,
|
|
291
|
+
paths.sshd_helper, paths.container_unit, paths.sshd_unit,
|
|
292
|
+
]
|
|
293
|
+
exists = any(p.exists() for p in all_paths)
|
|
294
|
+
exists = exists or run([podman, "container", "exists", name], check=False).returncode == 0
|
|
295
|
+
|
|
296
|
+
if not exists:
|
|
297
|
+
if args.all:
|
|
298
|
+
continue
|
|
299
|
+
raise SystemExit(f"not found: {name}")
|
|
300
|
+
|
|
301
|
+
_cleanup(podman, systemctl, paths, name)
|
|
302
|
+
print(f"removed {name}")
|
|
303
|
+
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# helpers
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _cleanup(podman: str, systemctl: str, paths: ManagedPaths, name: str) -> None:
|
|
312
|
+
"""Stop services, remove container, delete managed files."""
|
|
313
|
+
run([systemctl, "--user", "disable", "--now", paths.sshd_unit.name], check=False, capture=True)
|
|
314
|
+
run([systemctl, "--user", "disable", "--now", paths.container_unit.name], check=False, capture=True)
|
|
315
|
+
res = run([podman, "rm", "-f", "--ignore", name], check=False, capture=True)
|
|
316
|
+
if res.returncode != 0:
|
|
317
|
+
print(f"warning: podman rm failed for {name}: {res.stderr.strip()}", file=sys.stderr)
|
|
318
|
+
|
|
319
|
+
for p in [paths.container_unit, paths.sshd_unit, paths.sshd_helper,
|
|
320
|
+
paths.container_helper, paths.bootstrap_helper, paths.metadata]:
|
|
321
|
+
unlink_if_exists(p)
|
|
322
|
+
|
|
323
|
+
if paths.helper_dir.exists() and not any(paths.helper_dir.iterdir()):
|
|
324
|
+
paths.helper_dir.rmdir()
|
|
325
|
+
|
|
326
|
+
run([systemctl, "--user", "daemon-reload"], check=False)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def list_names() -> list[str]:
|
|
330
|
+
d = state_root() / STATE_DIR_NAME
|
|
331
|
+
if not d.exists():
|
|
332
|
+
return []
|
|
333
|
+
names: set[str] = set()
|
|
334
|
+
for p in d.glob("*.json"):
|
|
335
|
+
names.add(p.stem)
|
|
336
|
+
for p in d.glob("*-container.sh"):
|
|
337
|
+
names.add(p.name.removesuffix("-container.sh"))
|
|
338
|
+
for p in d.glob("*-bootstrap.sh"):
|
|
339
|
+
names.add(p.name.removesuffix("-bootstrap.sh"))
|
|
340
|
+
return sorted(names)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _read_meta(path: pathlib.Path) -> dict:
|
|
344
|
+
if not path.exists():
|
|
345
|
+
return {}
|
|
346
|
+
try:
|
|
347
|
+
return json.loads(path.read_text())
|
|
348
|
+
except json.JSONDecodeError:
|
|
349
|
+
return {}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _container_status(podman: str, name: str) -> str:
|
|
353
|
+
res = run([podman, "inspect", "-f", "{{.State.Status}}", name], check=False, capture=True)
|
|
354
|
+
return res.stdout.strip() if res.returncode == 0 else "missing"
|