diff --git a/bin/repro b/bin/repro index dab56c3..fbb54e6 100755 --- a/bin/repro +++ b/bin/repro @@ -108,7 +108,7 @@ import signal import subprocess import sys import tempfile -from pathlib import Path +from pathlib import Path, PurePath from typing import Dict, List, Optional, Tuple REPO_ROOT = Path(__file__).resolve().parent.parent @@ -153,16 +153,16 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: "the docker exec command to re-enter it later.") p.add_argument("--devshell", action="store_true", help="provision the recipe install + sibling ccache + " - "matching LLVM source (per the manifest's " - "SRC_COMMIT) into ~/.cache/ci-workflows/devshell/" - "/ and drop into a container shell with " - "CCACHE_DIR + CMAKE_*_COMPILER_LAUNCHER preset. " - "Does NOT run the workflow -- this is the " - "dev-bootstrap mode for editing LLVM source and " - "rebuilding incrementally with ccache hits.") + "matching LLVM source into a per-cell named " + "docker volume and drop into a hermetic container " + "shell (non-root, host UID/GID, $PWD bound at " + "/patches). No host paths leak in except $PWD " + "and (opt-in) --devshell-host-cache. Does NOT run " + "the workflow.") p.add_argument("--devshell-refetch", action="store_true", help="with --devshell: re-download install/ccache/" - "manifest even if the local copy already exists.") + "manifest even if the volume is already " + "populated.") p.add_argument("--devshell-script", metavar="PATH", help="with --devshell: instead of dropping into an " "interactive shell, run this script (host path) " @@ -172,8 +172,29 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: "the default when this flag is omitted.") p.add_argument("--devshell-rm", action="store_true", help="with --devshell: remove the persistent container " - "and exit (does NOT delete ~/.cache/ci-workflows/" - "devshell//).") + "and exit. Named volumes are kept (use " + "`docker volume rm devshell-` to reclaim).") + p.add_argument("--devshell-host-cache", nargs="?", + const="__default__", default=None, metavar="DIR", + help="with --devshell: bind a single host directory " + "at /cache for persistence and AI tooling. " + "Bare flag uses ~/.cache/ci-workflows/" + "devshell-cache/. Expected layout: " + "cells//{src,build,ccache}, " + "ai/{skills,settings.json,memory//}.") + p.add_argument("--devshell-patches-out", metavar="DIR", + help="with --devshell: host directory bound at " + "/patches inside the container (rw). Defaults " + "to the current working directory. AI writes " + "patches here; you `git am` them on the host.") + p.add_argument("--devshell-image", metavar="IMAGE", + help="with --devshell: container image (digest " + "preferred). Defaults to the cell's OS-matched " + "catthehacker tag.") + p.add_argument("--devshell-as-root", action="store_true", + help="with --devshell: run as root inside the " + "container. Escape hatch; files written to " + "/patches will be root-owned on the host.") advanced = p.add_argument_group( "advanced", @@ -928,28 +949,36 @@ def remove_container(cid: str) -> None: # --- devshell mode ------------------------------------------------------- # # `bin/repro --devshell ` provisions the cell's recipe install + -# sibling ccache + matching LLVM source under a persistent host dir, then -# drops into a container shell with CCACHE_DIR + CMAKE_*_COMPILER_LAUNCHER -# preset. Devs editing LLVM source for cppyy/cppinterop debugging can -# rebuild incrementally with ~all unmodified objects served from the -# recipe's pre-warmed ccache. +# sibling ccache + matching LLVM source into a per-cell named docker +# volume, then drops into a hermetic container shell. The host sees +# only $PWD (bound at /patches for the git-am handoff) and, if +# --devshell-host-cache is given, a single cache directory (bound at +# /cache) that carries persistent cell state and AI tooling. # # Distinct from the act-driven repro path: no workflow runs, no -# nektos/act involvement, no GHA cache emulation. The container is a -# bare runner image with the workdir bind-mounted. +# nektos/act involvement, no GHA cache emulation. -DEVSHELL_HOME = Path.home() / ".cache" / "ci-workflows" / "devshell" - -# Fallback bind-mount path for pre-#51 manifests that don't carry -# build_env.ccache.base_dir. _devshell_workspace prefers the manifest -# value when present. +# Default location for --devshell-host-cache (bare flag). Layout: +# cells//{src,build,ccache} +# ai/{skills,settings.json,memory///} +DEVSHELL_HOST_CACHE_DEFAULT = ( + Path.home() / ".cache" / "ci-workflows" / "devshell-cache" +) +# Pre-#51 manifests that don't carry build_env.ccache.base_dir fall +# back to this in-container workspace path. _devshell_workspace prefers +# the manifest value when present so ccache keys match the producer. DEVSHELL_RUNNER_WORKSPACE = "/home/runner/work/ci-workflows/ci-workflows" DEVSHELL_CCACHE_SUBDIR = ".ccache" DEVSHELL_SRC_SUBDIR = "_recipe_work/llvm-project" DEVSHELL_BUILD_SUBDIR = "_recipe_work/llvm-project/build" DEVSHELL_INSTALL_SUBDIR = "_recipe_out/install" +DEVSHELL_PATCHES_MOUNT = "/patches" +DEVSHELL_CACHE_MOUNT = "/cache" +DEVSHELL_DEV_HOME = "/home/dev" + DEVSHELL_CONFIG_SCRIPT = REPO_ROOT / "scripts" / "repro-config" +DEVSHELL_INIT_SCRIPT = REPO_ROOT / "scripts" / "devshell-init" def _devshell_cell(args: argparse.Namespace) -> Dict[str, str]: @@ -1159,6 +1188,10 @@ def _devshell_container_name(cell_id: str) -> str: return f"devshell-{cell_id}" +def _devshell_volume_name(cell_id: str) -> str: + return f"devshell-{cell_id}" + + def _devshell_container_running(name: str) -> bool: r = subprocess.run( ["docker", "container", "inspect", "-f", "{{.State.Running}}", name], @@ -1175,21 +1208,132 @@ def _devshell_container_exists(name: str) -> bool: return r.returncode == 0 -def _devshell_ensure_container(name: str, image: str, work: Path, - manifest: Dict) -> None: - """Idempotently start a long-lived container named `name` with - `work` bind-mounted at the manifest's recorded workspace path and - the dev-env vars exported. Tool installation + build-dir configure - happens via scripts/repro-config -- see _devshell_run_config(). +def _devshell_volume_exists(name: str) -> bool: + r = subprocess.run( + ["docker", "volume", "inspect", name], + capture_output=True, text=True, + ) + return r.returncode == 0 + + +def _devshell_volume_has_manifest(volume: str, image: str, ws: str) -> bool: + """True iff a populated volume already carries the producer + manifest -- the gate for skipping the host-side fetch on re-entry. + """ + r = subprocess.run( + ["docker", "run", "--rm", "-v", f"{volume}:{ws}", + image, "test", "-f", f"{ws}/manifest.json"], + capture_output=True, + ) + return r.returncode == 0 + + +def _devshell_host_uid_gid() -> Tuple[int, int]: + """Host UID/GID for --user injection. Falls back to (1000, 1000) + on platforms without os.getuid (Windows): files in /patches would + still land owned by the host docker user in that case. + """ + try: + return os.getuid(), os.getgid() + except AttributeError: + return 1000, 1000 + + +def _devshell_resolve_host_cache(args: argparse.Namespace) -> Optional[Path]: + """Map --devshell-host-cache (None/bare/explicit) to an absolute Path, + or None for the fully ephemeral default. + """ + v = args.devshell_host_cache + if v is None: + return None + if v == "__default__": + return DEVSHELL_HOST_CACHE_DEFAULT + return Path(v).expanduser().resolve() + + +def _devshell_resolve_patches_out(args: argparse.Namespace) -> Path: + """Resolve the host-side patches drop. Defaults to $PWD. + + Refuses obviously-wrong choices ($HOME, root '/') so a stray + invocation can't accidentally pollute the host home dir with + container-written files. + """ + raw = args.devshell_patches_out or os.getcwd() + p = Path(raw).expanduser().resolve() + if not p.is_dir(): + sys.exit(f"error: --devshell-patches-out: {p} is not a directory") + home = Path.home().resolve() + if p == home: + sys.exit(f"error: --devshell-patches-out resolves to $HOME ({p}); " + f"pick a project subdir or pass --devshell-patches-out " + f" explicitly") + if str(p) in ("/", ""): + sys.exit(f"error: --devshell-patches-out resolves to '{p}'; refuse") + return p + + +def _devshell_ai_repo_key(patches_out: PurePath) -> Tuple[str, str]: + """Repo basename + path-encoded host path for the AI memory key. + + Matches claude's project-key encoding (`/` -> `-`); `as_posix()` + normalises Windows `\\` and the colon-replace keeps drive + letters from breaking the on-disk host-cache dir (`:` is illegal + in NTFS filenames). + """ + repo = patches_out.name or "root" + encoded = patches_out.as_posix().replace(":", "-").replace("/", "-") + return repo, encoded + + +def _devshell_ensure_volume(name: str) -> None: + if not _devshell_volume_exists(name): + subprocess.run(["docker", "volume", "create", name], + check=True, stdout=subprocess.DEVNULL) + + +def _devshell_seed_volume(volume: str, image: str, ws: str, + src: Path) -> None: + """Copy `src`/. into the named volume at `ws`. + + One-shot container, not host `tar` piped through stdin, so the + volume's owner+perms match what the long-lived devshell container + will see -- a host `cp` would tag everything with the host UID + and trip dev-user reads later. + """ + subprocess.run( + ["docker", "run", "--rm", + "-v", f"{volume}:{ws}", + "-v", f"{src}:/seed:ro", + image, + "bash", "-c", + f"mkdir -p {ws} && cp -a /seed/. {ws}/"], + check=True, stdout=subprocess.DEVNULL, + ) + + +def _devshell_ensure_container(args: argparse.Namespace, name: str, + image: str, ws: str, manifest: Dict, + *, volume_name: Optional[str], + work_host_bind: Optional[Path], + host_cache: Optional[Path], + patches_out: Path) -> None: + """Idempotently start the devshell container. + + `volume_name` and `work_host_bind` are mutually exclusive: the + workspace at `ws` is backed either by a named docker volume + (hermetic default) or a host bind (--devshell-host-cache mode). + `host_cache`, when given, is additionally bound at /cache. + `patches_out` is always bound at /patches. """ if _devshell_container_exists(name): if not _devshell_container_running(name): subprocess.run(["docker", "start", name], check=True, stdout=subprocess.DEVNULL) return - ws = _devshell_workspace(manifest) cfg = _devshell_ccache_cfg(manifest) no_hash_dir = "true" if cfg.get("hash_dir") == "false" else "false" + uid, gid = _devshell_host_uid_gid() + repo, encoded = _devshell_ai_repo_key(patches_out) env_args: list[str] = [ "-e", "CC=clang", "-e", "CXX=clang++", @@ -1203,18 +1347,41 @@ def _devshell_ensure_container(name: str, image: str, work: Path, "-e", f"DEVSHELL_BUILD={ws}/{DEVSHELL_BUILD_SUBDIR}", "-e", f"DEVSHELL_MANIFEST={ws}/manifest.json", "-e", f"CCACHE_LOGFILE={ws}/{DEVSHELL_CCACHE_SUBDIR}/ccache.log", + "-e", f"HOME={DEVSHELL_DEV_HOME}", + "-e", f"DEVSHELL_DEV_HOME={DEVSHELL_DEV_HOME}", + "-e", f"DEVSHELL_DEV_UID={uid}", + "-e", f"DEVSHELL_DEV_GID={gid}", + "-e", f"DEVSHELL_PATCHES_MOUNT={DEVSHELL_PATCHES_MOUNT}", + "-e", f"DEVSHELL_AI_REPO={repo}", + "-e", f"DEVSHELL_AI_REPO_ENCODED={encoded}", ] + if host_cache is not None: + env_args += ["-e", f"DEVSHELL_CACHE_MOUNT={DEVSHELL_CACHE_MOUNT}"] cc = cfg.get("compiler_check", "") if cc and cc != "unknown": # Producer's ccache.compiler_check verbatim. repro-config # applies it on the consumer side and warns on $CC --version # divergence. env_args += ["-e", f"DEVSHELL_PRODUCER_COMPILER_CHECK={cc}"] + + mount_args: list[str] = [] + if volume_name is not None: + mount_args += ["-v", f"{volume_name}:{ws}"] + else: + assert work_host_bind is not None + mount_args += ["-v", f"{work_host_bind}:{ws}"] + mount_args += ["-v", f"{REPO_ROOT}:/ci-workflows:ro"] + mount_args += ["-v", f"{patches_out}:{DEVSHELL_PATCHES_MOUNT}"] + if host_cache is not None: + mount_args += ["-v", f"{host_cache}:{DEVSHELL_CACHE_MOUNT}"] + + # Start as root: repro-config needs apt-install + chown. `--user` + # is deferred to the interactive `docker exec` so the AI runs as + # `dev`. subprocess.run( ["docker", "run", "-d", "--name", name, - "-v", f"{work}:{ws}", - "-v", f"{REPO_ROOT}:/ci-workflows:ro", + *mount_args, "-w", ws, *env_args, image, "sleep", "infinity"], @@ -1222,41 +1389,62 @@ def _devshell_ensure_container(name: str, image: str, work: Path, ) -def _devshell_run_config(name: str, work: Path, manifest: Dict) -> None: - """Stage scripts/repro-config into /work and run it inside the - container. Idempotent on re-runs (script is no-op when build dir - already configured + tools already installed). Emits the hint - block for the dev to copy-paste. +def _devshell_run_config(name: str, ws: str) -> None: + """Copy scripts/repro-config into the container and run it as root. + + `docker cp` (not bind) keeps the script out of the workspace + volume -- the volume's contents should be exactly what the recipe + produced, plus user edits, nothing else. Idempotent on re-runs. """ - if not DEVSHELL_CONFIG_SCRIPT.is_file(): - sys.exit(f"error: {DEVSHELL_CONFIG_SCRIPT} not found " - f"-- ci-workflows checkout incomplete?") - staged = work / "repro-config" - shutil.copy(DEVSHELL_CONFIG_SCRIPT, staged) - os.chmod(staged, 0o755) - ws = _devshell_workspace(manifest) + for src in (DEVSHELL_CONFIG_SCRIPT, DEVSHELL_INIT_SCRIPT): + if not src.is_file(): + sys.exit(f"error: {src} not found " + f"-- ci-workflows checkout incomplete?") + subprocess.run( + ["docker", "cp", str(DEVSHELL_CONFIG_SCRIPT), + f"{name}:/usr/local/bin/devshell-repro-config"], + check=True, stdout=subprocess.DEVNULL, + ) + subprocess.run( + ["docker", "cp", str(DEVSHELL_INIT_SCRIPT), + f"{name}:/usr/local/bin/devshell-init"], + check=True, stdout=subprocess.DEVNULL, + ) subprocess.run( - ["docker", "exec", "-w", ws, name, - "bash", f"{ws}/repro-config"], + ["docker", "exec", "-u", "0", "-w", ws, name, + "bash", "/usr/local/bin/devshell-repro-config"], check=True, ) + # Self-check the hermetic-init outputs; failures here mean the + # dev user / mounts / AI symlinks are not in the state bin/repro + # promised. Cheap (~50ms) so we run every session. + r = subprocess.run( + ["docker", "exec", "-u", "0", name, + "bash", "/usr/local/bin/devshell-init", "--verify"], + ) + if r.returncode != 0: + sys.exit(f"error: devshell-init --verify reported " + f"{r.returncode} failure(s); container {name} is " + f"not in the expected state") def cmd_devshell(args: argparse.Namespace) -> int: """Provision install + ccache + LLVM source for the cell, then - drop into a container shell. See module-level comment. + drop into the hermetic devshell container. See module-level + comment. """ sys.path.insert(0, str(REPO_ROOT / "actions" / "lib")) import cache_io # noqa: E402 coord = _devshell_cell(args) - image = _devshell_image(coord["os"]) + image = args.devshell_image or _devshell_image(coord["os"]) key = _devshell_compute_key(coord) base = cache_io.resolve_cache_base() cell_id = "-".join(coord[k] for k in ("recipe", "version", "os", "arch")) name = _devshell_container_name(cell_id) - work = DEVSHELL_HOME / cell_id + volume = _devshell_volume_name(cell_id) + host_cache = _devshell_resolve_host_cache(args) if args.devshell_rm: if _devshell_container_exists(name): @@ -1265,57 +1453,109 @@ def cmd_devshell(args: argparse.Namespace) -> int: check=True, stdout=subprocess.DEVNULL) else: _log(f"repro: no container named {name}; nothing to remove") - _log(f"repro: workdir at {work} preserved") + if _devshell_volume_exists(volume): + _log(f"repro: volume {volume} preserved (docker volume rm " + f"{volume} to reclaim)") + if host_cache is not None: + _log(f"repro: host cache {host_cache} preserved") return 0 - work.mkdir(parents=True, exist_ok=True) - - manifest = _devshell_fetch(base, key, work, args.devshell_refetch) - src_dir = _devshell_source(work, manifest) + patches_out = _devshell_resolve_patches_out(args) + + using_host_cache = host_cache is not None + if using_host_cache: + host_cache.mkdir(parents=True, exist_ok=True) + (host_cache / "ai").mkdir(exist_ok=True) + stage = host_cache / "cells" / cell_id + stage.mkdir(parents=True, exist_ok=True) + manifest = _devshell_fetch(base, key, stage, args.devshell_refetch) + _devshell_source(stage, manifest) + ws = _devshell_workspace(manifest) + work_host_bind: Optional[Path] = stage + volume_for_container: Optional[str] = None + else: + # Default mode: workspace is a named docker volume. Stage the + # fetch in a host tempdir, then seed the volume via a one-shot + # container. The manifest must land on disk before container + # creation (it carries the workspace path that drives the + # bind target). + _devshell_ensure_volume(volume) + ws_probe_dir = Path(tempfile.mkdtemp(prefix="devshell-stage-")) + try: + manifest = _devshell_fetch(base, key, ws_probe_dir, + args.devshell_refetch) + ws = _devshell_workspace(manifest) + already_populated = ( + not args.devshell_refetch + and _devshell_volume_has_manifest(volume, image, ws) + ) + if already_populated: + _log(f"repro: volume {volume} already populated; " + f"skipping host fetch") + else: + _devshell_source(ws_probe_dir, manifest) + _devshell_seed_volume(volume, image, ws, ws_probe_dir) + finally: + shutil.rmtree(ws_probe_dir, ignore_errors=True) + work_host_bind = None + volume_for_container = volume fresh = not _devshell_container_exists(name) - _devshell_ensure_container(name, image, work, manifest) - _devshell_run_config(name, work, manifest) + _devshell_ensure_container( + args, name, image, ws, manifest, + volume_name=volume_for_container, + work_host_bind=work_host_bind, + host_cache=host_cache, + patches_out=patches_out, + ) + _devshell_run_config(name, ws) - ws = _devshell_workspace(manifest) _log("") _log(f"repro: --devshell ready ({cell_id})") - _log(f" {ws}/{DEVSHELL_INSTALL_SUBDIR}") - _log(f" <- {key}.tar.zst (install tree)") - _log(f" {ws}/{DEVSHELL_CCACHE_SUBDIR}") - _log(f" <- {key}.ccache.tar.zst (warm ccache, paths match recipe build)") - _log(f" {ws}/{DEVSHELL_SRC_SUBDIR}") - _log(f" <- {manifest['source']['repo']} @ " - f"{manifest['source']['commit'][:12]}") - _log(f" container {name} ({'fresh' if fresh else 'reused'})") + _log(f" workspace ({ws}):") + if using_host_cache: + _log(f" host bind: {work_host_bind}") + else: + _log(f" volume: {volume}") + _log(f" patches drop ({DEVSHELL_PATCHES_MOUNT}):") + _log(f" host bind: {patches_out}") + if host_cache is not None: + _log(f" host cache ({DEVSHELL_CACHE_MOUNT}):") + _log(f" host bind: {host_cache}") + _log(f" container {name} ({'fresh' if fresh else 'reused'})") + _log(f" user {'root' if args.devshell_as_root else 'dev (host UID/GID)'}") _log("") _log(f"repro: re-enter later with `bin/repro --devshell ` " - f"or `docker exec -it {name} bash`") - _log(f"repro: tear down with `bin/repro --devshell --devshell-rm `") + f"or `docker exec -it -u dev -w {ws} {name} bash`") + _log(f"repro: tear down with `bin/repro --devshell --devshell-rm ` " + f"(volume kept)") _log("") + exec_user = ["-u", "0"] if args.devshell_as_root else ["-u", "dev"] + if args.devshell_script: - # Batch mode: stage the host script into the bind-mounted workdir - # so the container can exec it without a second mount, run it, - # exit with its rc. No interactive shell. + # /tmp keeps the script out of the workspace volume -- the + # volume should hold recipe output + user edits only. script_host = Path(args.devshell_script).resolve() if not script_host.is_file(): sys.exit(f"error: --devshell-script: {script_host} not a file") - staged = work / ".devshell-script.sh" - shutil.copy(script_host, staged) - os.chmod(staged, 0o755) + subprocess.run( + ["docker", "cp", str(script_host), + f"{name}:/tmp/devshell-script.sh"], + check=True, stdout=subprocess.DEVNULL, + ) + subprocess.run( + ["docker", "exec", "-u", "0", name, + "chmod", "755", "/tmp/devshell-script.sh"], + check=True, stdout=subprocess.DEVNULL, + ) _log(f"repro: running --devshell-script ({script_host.name})...") return subprocess.run( - ["docker", "exec", "-w", ws, name, - "bash", f"{ws}/.devshell-script.sh"]).returncode - - # scripts/repro-config printed its own next-step hints above; - # don't duplicate. The quickstart-once marker is no longer - # needed. - - return _run_interactive(["docker", "exec", "-it", name, "bash"]) - + ["docker", "exec", *exec_user, "-w", ws, name, + "bash", "/tmp/devshell-script.sh"]).returncode + return _run_interactive( + ["docker", "exec", "-it", *exec_user, "-w", ws, name, "bash"]) def _container_tree_dirty(cid: str, workspace: str) -> bool: diff --git a/bin/test_repro.py b/bin/test_repro.py index b779d34..cd1d0db 100644 --- a/bin/test_repro.py +++ b/bin/test_repro.py @@ -16,9 +16,10 @@ import signal as _signal import subprocess import sys +import tempfile import unittest from contextlib import redirect_stderr -from pathlib import Path +from pathlib import Path, PureWindowsPath from unittest import mock REPRO_PATH = Path(__file__).resolve().parent / "repro" @@ -1458,5 +1459,159 @@ def test_unsupported_os_exits_with_explanation(self): self.assertIn("Linux Ubuntu cells", str(cm.exception)) +class DevshellHermeticResolverTests(unittest.TestCase): + """Pin --devshell host-cache, patches-out, and AI-key resolution. + + These are the load-bearing inputs to the hermetic model: a typo + here decides what bind-mounts the container gets, so the contract + is worth nailing. + """ + + def setUp(self): + self.repro = _load_repro() + + def _ns(self, **kw): + kw.setdefault("devshell_host_cache", None) + kw.setdefault("devshell_patches_out", None) + return argparse.Namespace(**kw) + + def test_host_cache_none_returns_none(self): + self.assertIsNone(self.repro._devshell_resolve_host_cache( + self._ns(devshell_host_cache=None))) + + def test_host_cache_bare_uses_default(self): + p = self.repro._devshell_resolve_host_cache( + self._ns(devshell_host_cache="__default__")) + self.assertEqual(p, self.repro.DEVSHELL_HOST_CACHE_DEFAULT) + + def test_host_cache_explicit_dir_resolves_absolute(self): + with tempfile.TemporaryDirectory() as td: + p = self.repro._devshell_resolve_host_cache( + self._ns(devshell_host_cache=td)) + self.assertTrue(p.is_absolute()) + + def test_patches_out_defaults_to_cwd(self): + # cwd must be restored BEFORE TemporaryDirectory cleanup: + # Windows refuses to delete a dir that's the cwd of any + # process, so a finally-block restore is too late. + old = os.getcwd() + with tempfile.TemporaryDirectory() as td: + try: + os.chdir(td) + p = self.repro._devshell_resolve_patches_out(self._ns()) + self.assertEqual(p, Path(td).resolve()) + finally: + os.chdir(old) + + def test_patches_out_refuses_home(self): + with self.assertRaises(SystemExit) as cm: + self.repro._devshell_resolve_patches_out(self._ns( + devshell_patches_out=str(Path.home()))) + self.assertIn("$HOME", str(cm.exception)) + + def test_patches_out_refuses_missing_dir(self): + with self.assertRaises(SystemExit) as cm: + self.repro._devshell_resolve_patches_out(self._ns( + devshell_patches_out="/nonexistent-devshell-test-dir")) + self.assertIn("not a directory", str(cm.exception)) + + def test_ai_repo_key_encodes_path_with_dashes(self): + repo, encoded = self.repro._devshell_ai_repo_key( + Path("/Users/vv/workspace/sources/CppInterOp2")) + self.assertEqual(repo, "CppInterOp2") + self.assertEqual(encoded, "-Users-vv-workspace-sources-CppInterOp2") + + def test_ai_repo_key_handles_windows_drive_and_separators(self): + # The host-cache dir lives on the host filesystem; ':' is + # NTFS-illegal in filenames, so the encoded key must not + # carry the drive letter's colon through. + repo, encoded = self.repro._devshell_ai_repo_key( + PureWindowsPath(r"C:\Users\vv\proj")) + self.assertEqual(repo, "proj") + self.assertNotIn(":", encoded) + self.assertNotIn("\\", encoded) + self.assertEqual(encoded, "C--Users-vv-proj") + + +class DevshellEnsureContainerArgvTests(unittest.TestCase): + """Pin the `docker run` argv shape: which `-v` / `-e` flags + appear in volume mode vs host-cache mode. The hermetic contract + lives in this argv -- a missed bind or a leaked host path slips + in silently otherwise. + """ + + def setUp(self): + self.repro = _load_repro() + + def _args(self, **kw): + kw.setdefault("devshell_as_root", False) + return argparse.Namespace(**kw) + + def _run_ensure(self, *, volume_name, work_host_bind, host_cache, + patches_out): + manifest = {"build_env": {"ccache": { + "base_dir": "/home/runner/work/x/x", "hash_dir": "false", + }}} + with mock.patch.object(self.repro, "_devshell_container_exists", + return_value=False), \ + mock.patch.object(self.repro.subprocess, "run") as run, \ + mock.patch.object(self.repro, "_devshell_host_uid_gid", + return_value=(1000, 1000)): + self.repro._devshell_ensure_container( + self._args(), "devshell-x", "img:tag", + "/home/runner/work/x/x", manifest, + volume_name=volume_name, + work_host_bind=work_host_bind, + host_cache=host_cache, + patches_out=patches_out, + ) + return run.call_args_list[-1][0][0] + + def test_volume_mode_binds_volume_at_workspace(self): + patches = Path("/tmp/proj") + argv = self._run_ensure( + volume_name="devshell-x", + work_host_bind=None, + host_cache=None, + patches_out=patches, + ) + self.assertIn("-v", argv) + self.assertIn("devshell-x:/home/runner/work/x/x", argv) + # Use Path-derived expected: on Windows the host side + # serialises with `\`, on Posix with `/`. Either is what + # docker would receive. + self.assertIn(f"{patches}:{self.repro.DEVSHELL_PATCHES_MOUNT}", + argv) + self.assertFalse(any(":/cache" in a for a in argv)) + + def test_host_cache_mode_binds_workspace_and_cache(self): + bind = Path("/tmp/hc/cells/x") + cache = Path("/tmp/hc") + patches = Path("/tmp/proj") + argv = self._run_ensure( + volume_name=None, + work_host_bind=bind, + host_cache=cache, + patches_out=patches, + ) + self.assertIn(f"{bind}:/home/runner/work/x/x", argv) + self.assertIn(f"{cache}:{self.repro.DEVSHELL_CACHE_MOUNT}", argv) + self.assertIn(f"{patches}:{self.repro.DEVSHELL_PATCHES_MOUNT}", + argv) + self.assertFalse(any(a.startswith("devshell-x:") for a in argv)) + + def test_ai_envs_carry_repo_key_to_container(self): + argv = self._run_ensure( + volume_name="devshell-x", + work_host_bind=None, + host_cache=None, + patches_out=Path("/Users/v/work/CppInterOp2"), + ) + self.assertIn("DEVSHELL_AI_REPO=CppInterOp2", argv) + self.assertIn( + "DEVSHELL_AI_REPO_ENCODED=-Users-v-work-CppInterOp2", argv) + self.assertIn(f"HOME={self.repro.DEVSHELL_DEV_HOME}", argv) + + if __name__ == "__main__": unittest.main() diff --git a/docs/developer-guide.md b/docs/developer-guide.md index a24df11..5b2e741 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -401,28 +401,106 @@ Either form works: The cell is validated against `cells.yaml`; a typo fails fast rather than 404'ing on Releases. -### Workdir layout +### Storage model — hermetic by default + +The host sees only two paths from the running container: + +1. **`$PWD` bound at `/patches` (rw).** Always on. AI inside writes + `git format-patch -o /patches …`; you `git am` from `$PWD` on + the host with your own identity. Refuses to launch if `$PWD == + $HOME` or resolves to `/`. +2. **`` bound at `/cache` (rw).** Opt-in via + `--devshell-host-cache [DIR]`. Carries persistent per-cell state + AND the user's AI tooling. Layout: + + ``` + / default: ~/.cache/ci-workflows/devshell-cache/ + cells// per-cell working data + _recipe_out/install/ install tree (LLVM_PREFIX) + .ccache/ producer's sibling ccache + _recipe_work/llvm-project/ shallow llvm-project @ SRC_COMMIT + manifest.json producer manifest + ai/ + skills/ user-curated skills (consumed inside via ~/.claude/skills symlink) + settings.json user-curated settings (~/.claude/settings.json symlink) + memory/// per-project AI memory (~/.claude/projects/-patches/memory symlink) + ``` + +Everything else — sources, build dir, ccache when host-cache is off, +shell history, container HOME — lives in a per-cell named docker +volume `devshell-` or inside the container's writable +layer. The volume survives `bin/repro --devshell --devshell-rm`; +reclaim with `docker volume rm devshell-`. + +Inside the container the workspace is bind-mounted at the recipe's +runner workspace path (read from `manifest.build_env.ccache.base_dir`), +so ccache's recorded paths match the producer. + +### Trust model + +- No git identity is injected. The container has no `user.name`, + `user.email`, ssh keys, or gpg keys. `git clone/fetch` works over + public HTTPS; `git commit/push` will not (the AI must hand patches + to the host). +- Default user is `dev` with host UID/GID. Files written to + `/patches` come out owned by the host user, so `git am` works + cleanly. `--devshell-as-root` is an escape hatch. + +### Recommended setup (copy-paste) + +The fastest path to a persistent, AI-enabled devshell. One-time +host setup, then a per-session loop. Replace `` with your +matrix-row name or `recipe/version/os/arch` coord, and +`/path/to/project` with whichever working copy you're patching. +```bash +# --- one-time host setup ------------------------------------------------ +# Seed the host cache with your existing AI tooling. The container +# symlinks ~/.claude/{skills,settings.json,projects/-patches/memory} +# into this tree, so anything you put here is what the AI sees. +HOST_CACHE=~/.cache/ci-workflows/devshell-cache +mkdir -p "$HOST_CACHE/ai/skills" "$HOST_CACHE/ai/memory" +cp -r ~/.claude/skills/. "$HOST_CACHE/ai/skills/" 2>/dev/null || true +cp ~/.claude/settings.json "$HOST_CACHE/ai/settings.json" 2>/dev/null || true + +# --- per-session loop --------------------------------------------------- +cd /path/to/project # $PWD becomes /patches inside +bin/repro --devshell --devshell-host-cache +# ... inside the container, run your AI of choice, iterate, then: +# cd $DEVSHELL_SRC && git format-patch -o /patches +# ... exit when done. +git am /path/to/project/*.patch # apply with your host identity +git push # ...and ship as usual. + +# --- teardown (optional) ------------------------------------------------ +bin/repro --devshell --devshell-rm # container only; volume kept +# docker volume rm devshell- # reclaim the volume too ``` -~/.cache/ci-workflows/devshell// - _recipe_out/llvm-project/ install tree (LLVM_PREFIX) - .ccache/ producer's sibling ccache - _recipe_work/llvm-project/ shallow llvm-project @ SRC_COMMIT - manifest.json producer manifest -``` -The container (`devshell-`) bind-mounts this directory at the -recipe's runner workspace path (read from -`manifest.build_env.ccache.base_dir`), so ccache's recorded paths -match. Re-invoking `bin/repro --devshell` re-uses the -container; `--devshell-rm` deletes it but keeps the workdir. +What this gets you: + +- `cells//` in the host cache persists src/build/ccache + across sessions and across `--devshell-rm` cycles. Subsequent + `bin/repro --devshell` re-enters in seconds, not minutes. +- `ai/memory///` accumulates your AI's per-project + knowledge on the host. It survives image rebuilds, machine moves, + and `docker volume rm`. Treat it as part of your dotfiles. +- `ai/skills/` and `ai/settings.json` are the AI's personality. Curate + them on the host; the container picks them up via symlink and stays + hermetic. +- `/patches` is the only rw bind besides `/cache`. The AI literally + cannot touch anything else on the host. ### Knobs | flag | effect | |------|--------| -| `--devshell-rm` | remove the container; workdir is kept | -| `--devshell-refetch` | re-download install/ccache/manifest | +| `--devshell-rm` | remove the container; named volume + host cache are kept | +| `--devshell-refetch` | re-download install/ccache/manifest into the volume / cache | +| `--devshell-host-cache [DIR]` | bind a single host dir at `/cache`. Bare flag uses `~/.cache/ci-workflows/devshell-cache/`. Required for persistent AI state across sessions. | +| `--devshell-patches-out DIR` | override the `/patches` bind. Defaults to `$PWD`. | +| `--devshell-image IMAGE` | override the container image (prefer a digest pin). | +| `--devshell-as-root` | run the interactive shell as root. Files in `/patches` will be root-owned on the host. | | `--devshell-script PATH` | run host PATH inside the container, exit with its rc (batch mode) | ### What `scripts/repro-config` does on entry diff --git a/scripts/devshell-init b/scripts/devshell-init new file mode 100755 index 0000000..4e6c64a --- /dev/null +++ b/scripts/devshell-init @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Hermetic devshell init. bin/repro --devshell installs this at +# /usr/local/bin/devshell-init and scripts/repro-config sources it +# as the first step inside the container. Idempotent: re-running +# converges on the same state. +# +# Inputs (env, all set by bin/repro from the host): +# DEVSHELL_DEV_UID / DEVSHELL_DEV_GID host UID/GID to assume inside +# DEVSHELL_DEV_HOME /home/dev by convention +# DEVSHELL_PATCHES_MOUNT /patches (the $PWD bind) +# DEVSHELL_CACHE_MOUNT /cache (only set when bound) +# DEVSHELL_BUILD drives workspace chown target +# DEVSHELL_AI_REPO / *_ENCODED host project key for memory dir + +set -e + +dev_uid="${DEVSHELL_DEV_UID:-1000}" +dev_gid="${DEVSHELL_DEV_GID:-1000}" +dev_home="${DEVSHELL_DEV_HOME:-/home/dev}" +patches="${DEVSHELL_PATCHES_MOUNT:-/patches}" +cache="${DEVSHELL_CACHE_MOUNT:-/cache}" + +# `devshell-init --verify` runs assertions about the post-init state +# instead of re-running init. Catches regressions in this script +# from inside the running container: bin/repro invokes it after the +# main init and fails the session on non-zero rc. +if [[ "${1:-}" == "--verify" ]]; then + errs=0 + check() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then return 0; fi + echo "devshell-init --verify: FAIL: $desc" >&2 + errs=$((errs+1)) + } + check "dev user exists with UID $dev_uid" \ + bash -c "[[ \"\$(id -u dev 2>/dev/null)\" == $dev_uid ]]" + check "$patches owned by dev" \ + bash -c "[[ \"\$(stat -c %U $patches)\" == dev ]]" + if [[ -d "$cache" ]]; then + repo="${DEVSHELL_AI_REPO:-default}" + encoded="${DEVSHELL_AI_REPO_ENCODED:--patches}" + check "$dev_home/.claude/skills resolves" \ + test -e "$dev_home/.claude/skills" + if [[ -f "$cache/ai/settings.json" ]]; then + check "$dev_home/.claude/settings.json resolves" \ + test -e "$dev_home/.claude/settings.json" + fi + check "memory dir under $repo/$encoded" \ + test -d "$cache/ai/memory/$repo/$encoded" + # Three encoded cwd keys must all resolve; init lays them down + # in one loop so a regression would silently break some-but- + # not-all and leave the AI mute depending on launch dir. + ws_for_verify="${DEVSHELL_BUILD:+${DEVSHELL_BUILD%/_recipe_work*}}" + for cwd in "$patches" "$ws_for_verify" "$dev_home"; do + [[ -z "$cwd" ]] && continue + enc="${cwd//\//-}" + check "memory symlink at $enc resolves" \ + test -e "$dev_home/.claude/projects/$enc/memory" + done + fi + exit "$errs" +fi + +# catthehacker/ubuntu ships `runner` at UID/GID 1001; a host user at +# the same UID would collide with `useradd -u`. Rename the existing +# owner of the target UID/GID to `dev` instead of creating a new +# account, so downstream `docker exec -u dev` still resolves. +existing_group=$(getent group "$dev_gid" | cut -d: -f1 || true) +if [[ -n "$existing_group" ]]; then + if [[ "$existing_group" != "dev" ]]; then + groupmod -n dev "$existing_group" 2>/dev/null || true + fi +else + groupadd -g "$dev_gid" dev +fi +existing_user=$(getent passwd "$dev_uid" | cut -d: -f1 || true) +if [[ -n "$existing_user" ]]; then + if [[ "$existing_user" != "dev" ]]; then + usermod -l dev -d "$dev_home" -m "$existing_user" 2>/dev/null \ + || usermod -l dev "$existing_user" + fi +else + useradd -u "$dev_uid" -g dev -d "$dev_home" -m -s /bin/bash dev +fi + +# NOPASSWD sudo lets the AI apt-install build deps without any host +# credential entering the container. +if command -v sudo >/dev/null 2>&1 \ + && [[ ! -f /etc/sudoers.d/devshell-dev ]]; then + echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/devshell-dev + chmod 0440 /etc/sudoers.d/devshell-dev +fi +mkdir -p "$dev_home" +chown "$dev_uid:$dev_gid" "$dev_home" + +if [[ -d "${DEVSHELL_PATCHES_MOUNT:-/patches}" ]]; then + chown "$dev_uid:$dev_gid" "${DEVSHELL_PATCHES_MOUNT}" +fi + +# Top-level stat gates the recursive walk: after first-run the +# workspace tree (multi-GB once built) is already dev-owned, and +# re-entry skips this entirely. +ws="" +if [[ -n "${DEVSHELL_BUILD:-}" ]]; then + ws="${DEVSHELL_BUILD%/_recipe_work*}" + if [[ -d "$ws" ]]; then + cur_uid=$(stat -c %u "$ws" 2>/dev/null || echo "") + if [[ "$cur_uid" != "$dev_uid" ]]; then + chown -R "$dev_uid:$dev_gid" "$ws" + fi + fi +fi + +# Host-cache bind, if any. Symlink claude's lookup paths into /cache +# so the AI picks up host-curated state without any other host bind. +# Memory is symlinked at every plausible session cwd (claude derives +# its project key from `getcwd()` at startup): /patches, $ws, and +# $HOME -- otherwise the link is silently inert when the user launches +# from a different directory. +if [[ -d "${DEVSHELL_CACHE_MOUNT:-/cache}" ]]; then + cache="${DEVSHELL_CACHE_MOUNT:-/cache}" + install -d -o "$dev_uid" -g "$dev_gid" \ + "$cache/ai" "$cache/ai/skills" "$cache/ai/memory" + repo="${DEVSHELL_AI_REPO:-default}" + encoded="${DEVSHELL_AI_REPO_ENCODED:--patches}" + mem_dir="$cache/ai/memory/$repo/$encoded" + install -d -o "$dev_uid" -g "$dev_gid" "$mem_dir" + + claude_root="$dev_home/.claude" + install -d -o "$dev_uid" -g "$dev_gid" \ + "$claude_root" "$claude_root/projects" + ln -sfn "$cache/ai/skills" "$claude_root/skills" + if [[ -f "$cache/ai/settings.json" ]]; then + ln -sfn "$cache/ai/settings.json" "$claude_root/settings.json" + fi + for cwd in "${DEVSHELL_PATCHES_MOUNT:-/patches}" "$ws" "$dev_home"; do + [[ -z "$cwd" ]] && continue + enc="${cwd//\//-}" + install -d -o "$dev_uid" -g "$dev_gid" "$claude_root/projects/$enc" + ln -sfn "$mem_dir" "$claude_root/projects/$enc/memory" + done + chown -h "$dev_uid:$dev_gid" \ + "$claude_root/skills" "$claude_root/settings.json" 2>/dev/null || true +fi + +# Forkable AI-CLI installer. Default is a no-op stub; a downstream +# fork's repro-config can replace `devshell_install_ai_cli` with a +# real installer (pip, curl|sh, ...). +devshell_install_ai_cli() { + echo "devshell: no AI CLI installed by default. Bake one into the" >&2 + echo " image or override devshell_install_ai_cli in this" >&2 + echo " fork's scripts/repro-config." >&2 +} +if ! command -v claude >/dev/null 2>&1; then + devshell_install_ai_cli || true +fi diff --git a/scripts/repro-config b/scripts/repro-config index 6427c7f..240b97b 100755 --- a/scripts/repro-config +++ b/scripts/repro-config @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# bin/repro --devshell stages this inside the container and runs it. -# Paths come from the env (DEVSHELL_SRC, DEVSHELL_BUILD, LLVM_PREFIX); -# ccache knobs come from DEVSHELL_PRODUCER_COMPILER_CHECK + the -# CCACHE_* vars bin/repro reads from the recipe's manifest. Idempotent: -# tools install once, cmake configure no-ops when CMakeCache.txt exists. +# bin/repro --devshell installs this at /usr/local/bin/devshell-repro-config +# and runs it as root inside the container. Paths come from the env +# (DEVSHELL_SRC, DEVSHELL_BUILD, LLVM_PREFIX); ccache knobs come from +# DEVSHELL_PRODUCER_COMPILER_CHECK + the CCACHE_* vars bin/repro +# reads from the recipe's manifest. Idempotent: tools install once, +# cmake configure no-ops when CMakeCache.txt exists. set -e @@ -13,9 +14,11 @@ if ! command -v ccache >/dev/null 2>&1; then # exactly: same compiler (clang) and same set of build deps the # publish-recipe pipeline used. DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - cmake ninja-build zstd ccache rsync clang libedit-dev git + cmake ninja-build zstd ccache rsync clang libedit-dev git sudo fi +bash /usr/local/bin/devshell-init + # Detect & install whatever libstdc++-N-dev the producer used. Read # CMAKE_CXX_IMPLICIT_INCLUDE_DIRECTORIES out of the manifest's # cmake_state and check for the corresponding /usr/include/c++/N