diff --git a/.angreal/demos/features/features.py b/.angreal/demos/features/features.py index 31a7627e2..aa7d5f0ca 100644 --- a/.angreal/demos/features/features.py +++ b/.angreal/demos/features/features.py @@ -1,5 +1,6 @@ """demos features — run feature-focused Cloacina examples.""" +import json import shutil import subprocess @@ -40,6 +41,32 @@ def _cmd(): for name, path in get_rust_feature_directories() } +# Bespoke feature demos defined below (excluded from auto-registration because +# `cargo run` is the wrong verb for them). Keep this list next to their +# definitions — `demos matrix` includes it so CI runs them too. +_BESPOKE_FEATURES = ["python-workflow", "simple-packaged"] + + +@demos() +@angreal.command( + name="matrix", + about="emit every runnable feature demo as a JSON list (the CI examples matrix source)", + when_to_use=[ + "generating the CI rust-examples matrix (examples-docs.yml discover job)", + "checking which examples the harness will execute", + ], + when_not_to_use=["running the demos themselves (use demos features )"], +) +def matrix(): + """CI executes ALL runnable examples. This is the single source of truth: + the same discovery that registers `demos features ` commands feeds + the CI matrix, so a new example directory automatically joins CI — no + hand-maintained workflow list to drift.""" + names = sorted( + [name.replace("_", "-") for name in _rust_feature_commands] + _BESPOKE_FEATURES + ) + print(json.dumps(names)) + # --- bespoke Python-workflow feature demo ----------------------------------- @@ -97,3 +124,149 @@ def python_workflow(): finally: if venv_path.exists(): shutil.rmtree(venv_path) + + +# --- canonical packaged-workflow demo (the gold path) ------------------------ +# +# `simple-packaged` is the CANONICAL example (CLOACI-I-0138): it runs through +# the PRIMARY interface — pack → upload → (server compiles & reconciles) → +# workflow run → execution Completed — never an in-process runner. It's in the +# auto-registration exclude list because `cargo run` is the wrong verb for it; +# this bespoke command drives the real lifecycle instead, reusing the +# service-lifecycle helpers from the compiler e2e harness. + +@demos() +@features() +@angreal.command( + name="simple-packaged", + about="run the canonical packaged example through the primary interface (pack → upload → compile → run)", + long_about=( + "Drives examples/features/workflows/simple-packaged the way the README " + "says a user does: postgres (dev stack) + cloacina-server + " + "cloacina-compiler (with --dev-workspace so the example's crates.io " + "version deps resolve against this checkout), then cloacinactl " + "pack → upload → poll the build to success → workflow run " + "data_processing → poll the execution to Completed. First run " + "cold-compiles the package deps (~5-10 min); warm cache finishes in " + "under a minute." + ), + when_to_use=[ + "verifying the packaged/server gold path end to end", + "validating the canonical example after compiler/server changes", + ], + when_not_to_use=[ + "compiler-pipeline regression assertions (use `test e2e compiler`)", + "running without docker", + ], +) +def simple_packaged(): + import tempfile + from pathlib import Path + + from test.e2e.compiler import ( + _assert_ports_free, + _build_binaries, + _cloacinactl, + _kill, + _poll_build_status, + _poll_execution_status, + _poll_run_workflow, + _start_postgres, + _upload, + _wait_http, + ) + + print("=== simple-packaged: packaged-workflow gold path ===") + _build_binaries() + _start_postgres() + # Distinct ports from the other harnesses (compiler e2e 18083/19003, + # ui e2e 18085/19001) so lanes can't collide on a shared machine. + _assert_ports_free(18087, 19005) + + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" + bootstrap_key = "demo-simple-packaged-key" + server_bind = "127.0.0.1:18087" + compiler_bind = "127.0.0.1:19005" + + example_dir = PROJECT_ROOT / "examples" / "features" / "workflows" / "simple-packaged" + home = Path(tempfile.mkdtemp(prefix="simple-packaged-demo-")) + print(f"demo home (service logs): {home}") + + server_proc = None + compiler_proc = None + try: + server_log = open(home / "server.log", "w") + server_proc = subprocess.Popen( + [ + "target/debug/cloacina-server", + "--home", str(home), + "--database-url", db_url, + "--bind", server_bind, + "--bootstrap-key", bootstrap_key, + "--verbose", + ], + cwd=PROJECT_ROOT, + stdout=server_log, + stderr=subprocess.STDOUT, + ) + _wait_http(f"http://{server_bind}/health", "server", proc=server_proc) + print(" ok: server up") + + # Shared target cache with the compiler e2e so the cloacina deps only + # cold-compile once across all harness lanes. + shared_target = PROJECT_ROOT / "target" / "compiler-e2e-cache" + shared_target.mkdir(parents=True, exist_ok=True) + compiler_log = open(home / "compiler.log", "w") + compiler_proc = subprocess.Popen( + [ + "target/debug/cloacina-compiler", + "--home", str(home), + "--database-url", db_url, + "--bind", compiler_bind, + "--poll-interval-ms", "500", + "--cargo-target-dir", str(shared_target), + "--cargo-flag=build", + "--cargo-flag=--lib", + # DEV ESCAPE HATCH (CLOACI-T-0887): the example ships crates.io + # version deps (the form users ship); resolve them against THIS + # checkout's unpublished crates. + "--dev-workspace", str(PROJECT_ROOT), + "--verbose", + ], + cwd=PROJECT_ROOT, + stdout=compiler_log, + stderr=subprocess.STDOUT, + ) + _wait_http( + f"http://{compiler_bind}/health", "compiler", + timeout_s=60.0, proc=compiler_proc, + ) + print(" ok: compiler up") + + _cloacinactl( + home, + "config", "profile", "set", "local", f"http://{server_bind}", + "--api-key", bootstrap_key, + "--default", + ) + + print(" pack + upload (server compiles the source archive; " + "first run: ~5-10 min cold build)") + pkg_id = _upload(home, example_dir) + _poll_build_status(home, pkg_id, {"success"}, timeout_s=900.0) + print(" ok: build_status = success") + + exec_id = _poll_run_workflow(home, "data_processing", timeout_s=180.0) + print(f" ok: workflow run accepted (execution {exec_id})") + + _poll_execution_status(home, exec_id, {"Completed"}, timeout_s=300.0) + print(" ok: execution Completed") + + print( + "\nSUCCESS: gold path verified — " + "pack → upload → compile → reconcile → execute → Completed" + ) + return 0 + finally: + _kill(compiler_proc) + _kill(server_proc) diff --git a/.angreal/demos/tutorials/python.py b/.angreal/demos/tutorials/python.py index 3949d4673..cc572b047 100644 --- a/.angreal/demos/tutorials/python.py +++ b/.angreal/demos/tutorials/python.py @@ -1,5 +1,6 @@ """demos tutorials python — run individual Python tutorial examples.""" +import os import shutil import subprocess import sys @@ -68,6 +69,12 @@ def _run_python_tutorial(tutorial_num, tutorial_rel_path, backend="sqlite"): print(f"[diagnostic] smart_postgres_reset returned {reset_ok}", flush=True) print(f"Executing tutorial {tutorial_num}...", flush=True) + # The harness owns the DB wiring: the dev stack publishes postgres on + # host 15432 (not 5432 — that's the user's own DB). Tutorials honor + # DATABASE_URL and keep a user-facing 5432 fallback. + env = os.environ.copy() + if backend == "postgres": + env["DATABASE_URL"] = "postgres://cloacina:cloacina@localhost:15432/cloacina" # `python -u` forces unbuffered stdio in the child so CI sees # progress + tracebacks even if the tutorial crashes mid-stream. result = subprocess.run( @@ -76,6 +83,7 @@ def _run_python_tutorial(tutorial_num, tutorial_rel_path, backend="sqlite"): capture_output=True, text=True, timeout=300, + env=env, ) if result.returncode == 0: diff --git a/.angreal/docker-compose.yaml b/.angreal/docker-compose.yaml index 7fa791073..40cd11c78 100644 --- a/.angreal/docker-compose.yaml +++ b/.angreal/docker-compose.yaml @@ -1,4 +1,10 @@ --- +# Pinned project name so this backing-services stack is unambiguously Cloacina's +# and never collides with the default `angreal` project used by other angreal +# repos on the same machine. `angreal ui`'s full demo/e2e stack is a SEPARATE +# project (`cloacina-demo`, docker/docker-compose.demo.yml); `angreal purge` +# tears down both. +name: cloacina-dev services: postgres: image: postgres:16 @@ -8,7 +14,7 @@ services: POSTGRES_PASSWORD: cloacina POSTGRES_DB: cloacina ports: - - "5432:5432" + - "15432:5432" volumes: - postgres_data:/var/lib/postgresql/data command: postgres -c max_connections=500 diff --git a/.angreal/task_purge.py b/.angreal/task_purge.py index 0f826df61..3685459f9 100644 --- a/.angreal/task_purge.py +++ b/.angreal/task_purge.py @@ -109,18 +109,30 @@ def _cargo_cache_dirs() -> List[Path]: def _docker_compose_down(cwd: Path) -> int: - """Stop docker services and remove volumes. Returns docker's exit code, - or 0 if compose isn't on PATH.""" - compose_file = cwd / ".angreal" / "docker-compose.yaml" - if not compose_file.exists(): - return 0 + """Stop EVERY Cloacina docker stack and remove volumes. Returns the worst + exit code, or 0 if compose isn't on PATH. + + Purge must clean ALL stacks a command can bring up — otherwise a stale one + (notably the `angreal ui` demo stack) survives a purge and its DB pool + later exhausts. Historically this only tore down the backing-services stack + and left `cloacina-demo` running for days.""" if shutil.which("docker") is None: return 0 - return subprocess.run( - ["docker", "compose", "-f", str(compose_file), "down", "-v", "--remove-orphans"], - cwd=str(cwd), - check=False, - ).returncode + compose_files = [ + cwd / ".angreal" / "docker-compose.yaml", # backing services (angreal services / test integration) + cwd / "docker" / "docker-compose.demo.yml", # demo / e2e stack (angreal ui) + ] + rc = 0 + for compose_file in compose_files: + if not compose_file.exists(): + continue + code = subprocess.run( + ["docker", "compose", "-f", str(compose_file), "down", "-v", "--remove-orphans"], + cwd=str(cwd), + check=False, + ).returncode + rc = rc or code + return rc # --------------------------------------------------------------------------- diff --git a/.angreal/test/auth.py b/.angreal/test/auth.py index ce260f690..2c2accfb2 100644 --- a/.angreal/test/auth.py +++ b/.angreal/test/auth.py @@ -116,7 +116,7 @@ def auth_integration_test(): start_postgres() base_url = "http://127.0.0.1:18082" - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" test_home = Path("target/auth-integration-test") if test_home.exists(): import shutil diff --git a/.angreal/test/e2e/cli.py b/.angreal/test/e2e/cli.py index 2e6d99cf5..45d022aa3 100644 --- a/.angreal/test/e2e/cli.py +++ b/.angreal/test/e2e/cli.py @@ -109,7 +109,7 @@ def cli(): _build_binaries() _start_postgres() - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" bootstrap_key = "test-bootstrap-e2e" bind = "127.0.0.1:18082" base_url = f"http://{bind}" @@ -659,7 +659,7 @@ def default_executor(): _build_binaries() _start_postgres() - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" # --- negative: unknown executor key aborts startup ---------------------- with tempfile.TemporaryDirectory() as home_s: diff --git a/.angreal/test/e2e/compiler.py b/.angreal/test/e2e/compiler.py index 613ab11e6..c37359f71 100644 --- a/.angreal/test/e2e/compiler.py +++ b/.angreal/test/e2e/compiler.py @@ -23,6 +23,7 @@ import json import os import signal +import re import subprocess import tempfile import time @@ -62,6 +63,15 @@ def _start_postgres(): cwd=REPO_ROOT, check=False, ) + # The down above only frees 15432 if OUR dev stack held it. The dev stack + # publishes postgres on 15432 (NOT the postgres default 5432) precisely so + # it can't collide with other projects' databases; if something still holds + # it, fail with a remedy instead of docker's opaque port-bind error. + if not _port_free(15432): + raise RuntimeError( + "port 15432 is held by another process — check `lsof -iTCP:15432 " + "-sTCP:LISTEN` / `docker ps`, stop whatever owns it, and re-run." + ) subprocess.run( ["docker", "compose", "-f", ".angreal/docker-compose.yaml", "up", "-d", "postgres"], cwd=REPO_ROOT, @@ -173,6 +183,39 @@ def _cloacinactl(home: Path, *args, check=True, env=None): # --------------------------------------------------------------------------- +# Matches a cloacina workspace path-dep — `{ path = "__WORKSPACE__/crates/…" +# [, features = […]] }` — so the `--version-deps` staging mode can rewrite it to +# the crates.io VERSION-dep form a real distributed package ships. +_WS_PATH_DEP = re.compile( + r'\{\s*path\s*=\s*"__WORKSPACE__/crates/[^"]+"' + r'(?:\s*,\s*(?Pfeatures\s*=\s*\[[^\]]*\]))?\s*\}' +) + +# Workspace crate version the version-dep form pins to. Caret "0.10" matches the +# workspace's 0.10.x; the compiler's `--dev-workspace` patch supplies the actual +# (unpublished) crate, so this need not exist on crates.io. +_PROD_DEP_VERSION = "0.10" + + +def _to_version_deps(text: str) -> str: + """Rewrite `__WORKSPACE__` path-deps to crates.io version-deps in a Cargo.toml. + + `{ path = "__WORKSPACE__/crates/cloacina-workflow", features = ["packaged"] }` + → `{ version = "0.10", features = ["packaged"] }`, and a bare path-dep → + `"0.10"`. This is the exact dep form `cloacinactl package new` emits — the + shipping form — which only resolves under a compiler started with + `--dev-workspace` (it injects `[patch.crates-io]` → the local crates). + """ + + def repl(m: re.Match) -> str: + feats = m.group("feats") + if feats: + return f'{{ version = "{_PROD_DEP_VERSION}", {feats} }}' + return f'"{_PROD_DEP_VERSION}"' + + return _WS_PATH_DEP.sub(repl, text) + + def _stage_fixture( home: Path, src_name: str, @@ -180,6 +223,7 @@ def _stage_fixture( rename_to: str | None = None, version_override: str | None = None, stage_suffix: str | None = None, + version_deps: bool = False, ) -> Path: """Copy a fixture from examples/fixtures/ into the per-run home, rewriting `__WORKSPACE__` placeholders to absolute paths that @@ -202,7 +246,18 @@ def _stage_fixture( ws = str(REPO_ROOT) for rel in ("package.toml", "Cargo.toml", "build.rs", "src/lib.rs"): - text = (src / rel).read_text().replace("__WORKSPACE__", ws) + raw = (src / rel).read_text() + # version_deps: author the Cargo.toml with crates.io version deps (the + # manifest shape users get from `cloacinactl package new`) instead of + # rewriting `__WORKSPACE__` path-deps to absolute paths. The compiler must + # run with `--dev-workspace` so these resolve to the LOCAL crates. + if version_deps and rel == "Cargo.toml": + # Deps become version deps; then scrub residual placeholders (the + # fixture's own comments mention __WORKSPACE__, and cloacinactl's + # pack guard rejects the literal anywhere in the manifest). + text = _to_version_deps(raw).replace("__WORKSPACE__", ws) + else: + text = raw.replace("__WORKSPACE__", ws) if rename_to is not None: text = text.replace(src_name, rename_to) text = text.replace( @@ -371,13 +426,32 @@ def _poll_execution_status( ], when_not_to_use=["unit testing", "running without docker"], ) -def compiler(): +@angreal.argument( + name="version_deps", + long="version-deps", + help=( + "Author the happy-path fixture with version deps (`cloacina-workflow = " + '"0.10"` — the manifest shape `cloacinactl package new` gives users) ' + "instead of __WORKSPACE__ path deps. Still compiles THIS checkout: the " + "compiler gets --dev-workspace, which patches those deps to the local " + "crates/ (offline; crates.io is never contacted). CLOACI-T-0887." + ), + takes_value=False, + is_flag=True, +) +def compiler(version_deps=False): print_section_header("cloacina-compiler e2e") + if version_deps: + print( + " MODE: --version-deps — user-shaped manifest (version deps), " + "resolved to the LOCAL dev workspace via --dev-workspace (offline; " + "crates.io never contacted)" + ) _build_binaries() _start_postgres() _assert_ports_free(18083, 19003) - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" bootstrap_key = "test-bootstrap-compiler-e2e" server_bind = "127.0.0.1:18083" compiler_bind = "127.0.0.1:19003" @@ -418,18 +492,25 @@ def compiler(): # bincode in release) matches the debug-built server we're running # here. In prod both server and compiler are release builds and the # default --release flag on cargo is fine. + compiler_argv = [ + "target/debug/cloacina-compiler", + "--home", str(home), + "--database-url", db_url, + "--bind", compiler_bind, + "--poll-interval-ms", "500", + "--cargo-target-dir", str(shared_target), + "--cargo-flag=build", + "--cargo-flag=--lib", + "--verbose", + ] + if version_deps: + # DEV ESCAPE HATCH (CLOACI-T-0887): inject [patch.crates-io] → the + # local unpublished crates so the version-dep fixture resolves — + # dev code, user-shaped manifest. No-op for the path-dep fixtures + # (their deps aren't crates-io deps → patch unused). + compiler_argv += ["--dev-workspace", str(REPO_ROOT)] compiler_proc = subprocess.Popen( - [ - "target/debug/cloacina-compiler", - "--home", str(home), - "--database-url", db_url, - "--bind", compiler_bind, - "--poll-interval-ms", "500", - "--cargo-target-dir", str(shared_target), - "--cargo-flag=build", - "--cargo-flag=--lib", - "--verbose", - ], + compiler_argv, cwd=REPO_ROOT, stdout=compiler_log, stderr=subprocess.STDOUT, @@ -455,9 +536,12 @@ def compiler(): # --- happy path ----------------------------------------------------- # First run cold-compiles cloacina + ~100 transitive deps; subsequent # runs hit the shared target cache and finish in <30s. - happy_dir = _stage_fixture(home, "compiler-happy-rust") + happy_dir = _stage_fixture( + home, "compiler-happy-rust", version_deps=version_deps + ) print(" compiling happy fixture " - "(first run: ~5-10 min cold build; subsequent: <30s)") + f"({'version-dep manifest' if version_deps else 'path-dep escape hatch'}; " + "first run: ~5-10 min cold build; subsequent: <30s)") happy_id = _upload(home, happy_dir) body = _poll_build_status(home, happy_id, {"success"}, timeout_s=900.0) assert body.get("build_status") == "success", body diff --git a/.angreal/test/e2e/fleet.py b/.angreal/test/e2e/fleet.py index 98534b72c..5b28e257c 100644 --- a/.angreal/test/e2e/fleet.py +++ b/.angreal/test/e2e/fleet.py @@ -77,7 +77,7 @@ def fleet(): _start_postgres() _assert_ports_free(18083, 19003) - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" bootstrap_key = "test-bootstrap-fleet-e2e" server_bind = "127.0.0.1:18083" compiler_bind = "127.0.0.1:19003" diff --git a/.angreal/test/e2e/sdk_contract.py b/.angreal/test/e2e/sdk_contract.py index afd680d73..32c3800b9 100644 --- a/.angreal/test/e2e/sdk_contract.py +++ b/.angreal/test/e2e/sdk_contract.py @@ -75,7 +75,7 @@ def _sdk_server(): [ "target/debug/cloacina-server", "--home", home, - "--database-url", "postgres://cloacina:cloacina@localhost:5432/cloacina", + "--database-url", "postgres://cloacina:cloacina@localhost:15432/cloacina", "--bind", BIND, "--bootstrap-key", BOOTSTRAP_KEY, ], diff --git a/.angreal/test/e2e/ui_e2e.py b/.angreal/test/e2e/ui_e2e.py index 3df305777..8093b8e08 100644 --- a/.angreal/test/e2e/ui_e2e.py +++ b/.angreal/test/e2e/ui_e2e.py @@ -54,7 +54,7 @@ PREVIEW_PORT = 4173 PREVIEW_URL = f"http://localhost:{PREVIEW_PORT}" BOOTSTRAP_KEY = "ui-e2e-bootstrap-key" -DB_URL = "postgres://cloacina:cloacina@localhost:5432/cloacina" +DB_URL = "postgres://cloacina:cloacina@localhost:15432/cloacina" TARGET_DIR = str(PROJECT_ROOT / "target") FIXTURES_DIR = PROJECT_ROOT / "examples" / "fixtures" diff --git a/.angreal/test/e2e/ws.py b/.angreal/test/e2e/ws.py index 147257fe1..8caf082f0 100644 --- a/.angreal/test/e2e/ws.py +++ b/.angreal/test/e2e/ws.py @@ -142,7 +142,7 @@ def ws_integration_test(): base_url = "http://127.0.0.1:18081" ws_base = "http://127.0.0.1:18081" - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" test_home = Path("target/ws-integration-test") if test_home.exists(): import shutil diff --git a/.angreal/test/metrics_format.py b/.angreal/test/metrics_format.py index a6e5cdb1d..eea39f569 100644 --- a/.angreal/test/metrics_format.py +++ b/.angreal/test/metrics_format.py @@ -132,7 +132,7 @@ def scrape_and_validate(url: str, label: str) -> int: [ str(server_binary), "--home", str(home), - "--database-url", "postgres://cloacina:cloacina@localhost:5432/cloacina", + "--database-url", "postgres://cloacina:cloacina@localhost:15432/cloacina", "--bind", "127.0.0.1:18181", "--bootstrap-key", "clk_metrics_format_check_dummy_key_000000000", ], @@ -164,7 +164,7 @@ def scrape_and_validate(url: str, label: str) -> int: [ str(compiler_binary), "--home", str(compiler_home), - "--database-url", "postgres://cloacina:cloacina@localhost:5432/cloacina", + "--database-url", "postgres://cloacina:cloacina@localhost:15432/cloacina", "--bind", "127.0.0.1:18182", ], stdout=subprocess.DEVNULL, diff --git a/.angreal/test/soak/fleet.py b/.angreal/test/soak/fleet.py index e232254c5..2f42cc115 100644 --- a/.angreal/test/soak/fleet.py +++ b/.angreal/test/soak/fleet.py @@ -148,7 +148,7 @@ def fleet(): _start_postgres() _assert_ports_free(18088, 19008) - db_url = "postgres://cloacina:cloacina@localhost:5432/cloacina" + db_url = "postgres://cloacina:cloacina@localhost:15432/cloacina" bootstrap_key = "soak-fleet-key" server_bind = "127.0.0.1:18088" compiler_bind = "127.0.0.1:19008" diff --git a/.angreal/utils.py b/.angreal/utils.py index 5f3228ed9..d5ff21c86 100644 --- a/.angreal/utils.py +++ b/.angreal/utils.py @@ -122,13 +122,14 @@ def docker_clean(): return 1 return 0 -def run_cargo_command(cwd, command_args, check=True): +def run_cargo_command(cwd, command_args, check=True, env=None): """Run a cargo command in the specified directory. Args: cwd: Working directory to run the command in command_args: List of arguments to pass to cargo check: Whether to check the return code (default: True) + env: Optional environment dict (default: inherit the parent env) Returns: The return code from the command @@ -137,7 +138,8 @@ def run_cargo_command(cwd, command_args, check=True): result = subprocess.run( ["cargo"] + command_args, cwd=str(cwd), - check=check + check=check, + env=env, ) return result.returncode except subprocess.CalledProcessError as e: @@ -205,11 +207,20 @@ def run_example_or_tutorial(project_root, example_dir, name, is_test=False, bina # For most tutorials, SQLite is used - no Docker setup needed print(f"Running {name} (SQLite-based, no database setup required)") + # The harness owns the DB wiring: the dev stack publishes postgres on + # host port 15432 (NOT 5432 — that's left to other projects / the user's + # own DB), while the examples' fallback URLs stay at the user-facing + # default. Point them at OUR stack explicitly. + env = os.environ.copy() + if needs_postgres: + env["DATABASE_URL"] = "postgres://cloacina:cloacina@localhost:15432/cloacina" + # Run the example/tutorial if is_test: return run_cargo_command( project_root / example_dir, - ["test", name, "--", "--nocapture"] + ["test", name, "--", "--nocapture"], + env=env, ) else: cmd = ["run"] @@ -217,7 +228,8 @@ def run_example_or_tutorial(project_root, example_dir, name, is_test=False, bina cmd.extend(["--bin", binary]) return run_cargo_command( project_root / example_dir, - cmd + cmd, + env=env, ) def check_postgres_container_health() -> bool: diff --git a/.github/workflows/examples-docs.yml b/.github/workflows/examples-docs.yml index 038ae2ea4..924cc7794 100644 --- a/.github/workflows/examples-docs.yml +++ b/.github/workflows/examples-docs.yml @@ -115,17 +115,45 @@ jobs: command: angreal demos tutorials rust ${{ matrix.tutorial }} # ----------------------------------------------------------------------- - # Rust examples + # Feature examples — CI executes ALL runnable examples. The matrix comes + # from `angreal demos matrix` (the same discovery that registers the + # `demos features ` commands), so a new example directory joins CI + # automatically — there is no hand-maintained list to drift. # ----------------------------------------------------------------------- + discover-examples: + name: Discover Examples + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + examples: ${{ steps.list.outputs.examples }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install angreal + run: | + python -m pip install --upgrade pip + pip install angreal + + - name: List runnable examples + id: list + run: echo "examples=$(angreal demos matrix)" >> "$GITHUB_OUTPUT" + rust-examples: name: Rust Example Tests - needs: [build-cache] + needs: [build-cache, discover-examples] timeout-minutes: 30 strategy: fail-fast: false matrix: - example: ["cron-scheduling", "multi-tenant", "per-tenant-credentials", "registry-execution"] + example: ${{ fromJSON(needs.discover-examples.outputs.examples) }} runs-on: ubuntu-latest env: RUST_BACKTRACE: full diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 96473560e..0096d9164 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -58,7 +58,7 @@ jobs: POSTGRES_PASSWORD: cloacina POSTGRES_DB: cloacina ports: - - 5432:5432 + - 15432:5432 options: >- --health-cmd "pg_isready -U cloacina" --health-interval 10s @@ -104,7 +104,7 @@ jobs: --exclude-files "crates/cloacina/src/python/*" \ -- --test-threads=1 env: - DATABASE_URL: postgres://cloacina:cloacina@localhost:5432/cloacina + DATABASE_URL: postgres://cloacina:cloacina@localhost:15432/cloacina - name: Upload coverage report uses: actions/upload-artifact@v4 @@ -154,7 +154,7 @@ jobs: username: cloacina password: cloacina database: cloacina - port: 5432 + port: 15432 - name: Install libpq run: | diff --git a/.metis/backlog/bugs/CLOACI-T-0887.md b/.metis/backlog/bugs/CLOACI-T-0887.md new file mode 100644 index 000000000..8c5755e49 --- /dev/null +++ b/.metis/backlog/bugs/CLOACI-T-0887.md @@ -0,0 +1,251 @@ +--- +id: bug-server-compiler-build-fails +level: task +title: "BUG: server compiler build fails — cloacina-computation-graph missing from staged crate set (packaged builds broken)" +short_code: "CLOACI-T-0887" +created_at: 2026-07-10T09:11:26.062829+00:00 +updated_at: 2026-07-10T09:11:26.062829+00:00 +parent: +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/backlog" + - "#bug" + + +exit_criteria_met: false +initiative_id: NULL +--- + +# BUG: server compiler build fails — cloacina-computation-graph missing from staged crate set (packaged builds broken) + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[Parent Initiative]] + +## Objective **[REQUIRED]** + +Server-side compilation of a packaged workflow that depends on `cloacina-computation-graph` fails — surfaced by the first real gold-path E2E (I-0138/T-0884): pack `simple-packaged` → upload to the demo server → compiler build fails. + +**Server audit event:** +``` +compiler.build.finished outcome=failed exit_status=101 sandbox_level=landlock +package_name=simple-packaged-demo package_version=0.1.0 +failure_reason="cargo build failed: failed to load manifest for dependency + `cloacina-computation-graph` … failed to read + `/root/.cloacina/crates/cloacina-computation-graph/Cargo.toml`: No such file (os error 2)" +``` + +**Two findings (likely both need fixing):** +1. **Compiler crate provisioning gap.** The server compiler resolves the package's cloacina path-deps against `/root/.cloacina/crates/` (the runtime `default_home` = `$HOME/.cloacina`; note the Dockerfile.compiler bakes/sets `CLOACINA_HOME=/workspace/.cloacina` — a HOME mismatch worth checking) and that staged crate set is MISSING `cloacina-computation-graph`. Seeded demo rust fixtures never hit it because none depend on the CG crate; `simple-packaged` is the first that does. +2. **Stale example Cargo.toml.** `examples/features/workflows/simple-packaged/Cargo.toml` is the OLD verbose 6-dep form (cloacina, cloacina-macros, cloacina-computation-graph, cloacina-workflow, cloacina-workflow-plugin, cloacina-build) with `path = "../../../../crates/…"`, NOT the I-0125 minimal 4-dep shell. Part of T-0884 (canonicalizing it) is modernizing this to the minimal shell — which may or may not still need CG. + +**Check for T-0865 interaction:** I-0134 converted `cloacina-workflow-plugin`'s `cloacina-computation-graph` dep from explicit `path` to `{ workspace = true }`. If the compiler's crate staging enumerated explicit path-deps, the inheritance could drop CG from the staged set. Verify whether this is a regression vs. a long-standing provisioning gap. + +**Impact:** any packaged workflow needing `cloacina-computation-graph` cannot be built by the server → the packaged/server gold path (now the intended e2e/functional-test path, see [[project_packaged_first_examples]]) is broken for CG-using packages. Type=Bug, likely P1. + +**Fix location:** `crates/cloacina-compiler/src/build.rs` (path rewrite + crate staging) + `docker/Dockerfile.compiler` (CLOACINA_HOME/crate baking). Verify by re-running the T-0884 gold-path E2E to Completed. + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +### 2026-07-11 (update 11) — VERIFIED CLOSED: 18/18 fixtures green on fresh stack + gold path Completed in-container +Post-excision demo stack (sequential image build, `ui down --clean` fresh slate): **all 18 seed fixtures build success** — first time ever on a fresh stack (13 Rust + 5 Python). Then the canonical README recipe live against the stack: pack simple-packaged (version-dep manifest) → upload (pkg 1aa77216) → in-container two-phase build with `--dev-workspace` → `build_status=success` → `workflow run data_processing` (exec 6216daad) → **execution `Completed`**. The full primary-interface path is proven in-container. Post-excision harness regression lane (`angreal test e2e compiler --version-deps`) running as final belt-and-braces. Committed `56e54505` (excision) on PR #192. + +### 2026-07-11 (update 10) — MAINTAINER DECISION: build sandbox EXCISED +After the third sandbox defect in one day (registry-write denial → landlock V1 EXDEV → bwrap probe silently downgrading), maintainer called it: "lets excise the sandbox for now - its a nice to have but given we went with a tenant based isolation we have some defenses; we can figure out more later if we need layers." Tenant-level isolation (per-tenant compilers via --tenant-schema, per-tenant schemas/agents) is the security boundary; per-build process sandboxing is a possible FUTURE layer. + +**Excised:** `sandbox.rs` deleted; `sandbox_level` off CompilerConfig; boot probe out of main.rs; build spawns cargo directly. compose: `CLOACINA_COMPILER_SANDBOX` + `seccomp=unconfined` removed. Dockerfiles: bubblewrap removed (demo + root). Helm: `compiler.sandbox.*` values + seccompProfile templating + env removed. Docs: `service/compiler-sandbox.md` deleted; `running-the-compiler.md` reframed around tenant isolation + rlimits; env-vars rows removed. e2e escape test (T-0855) removed with the feature. + +**KEPT (orthogonal):** rlimits (T-0575 resource ceilings), `--vendor-dir` curation (T-0574), wall-clock timeout (T-0573), **two-phase fetch→offline-build** (still valuable: deterministic single resolution, fetch errors separated from compile errors), `--dev-workspace` patch hatch, audit `sandbox_level` field (schema stability; now reports "none" — the truth). I-0105's archived docs stay as history. + +Verified post-excision: cargo check clean, compose config valid, helm template renders, harness py_compile clean. Demo rebuild + full fixture verification next. + +### 2026-07-11 (update 9) — second onion layer: landlock ABI::V1 denies cross-hierarchy renames with EXDEV +Two-phase fixed the downloads; the fresh-stack fixtures then FAILED differently: builds ran (fetch OK, compile started) and died with `failed to write /workspace/target/debug/deps/libX.rmeta: Invalid cross-device link (os error 18)` + a `rustix` build-script EACCES. Compiler logs revealed the demo builds run at **sandbox="landlock"** (the bwrap probe fails in this container — separate follow-up), and the sandbox pins **landlock ABI::V1 — which denies EVERY rename/link between different rule hierarchies with EXDEV**. rustc's rmeta temp→final rename crosses hierarchies → instant failure. Not a device issue at all; nothing ever reached a landlock-sandboxed compile before (downloads always failed first), so it was latent since I-0105. + +**Fixes (compiler):** +- `sandbox.rs apply_landlock`: **ABI::V1 → ABI::V2** — V2's `Refer` right permits renames between hierarchies granted it (our RW rules use `from_all`); best-effort downgrade on pre-5.19 kernels (landlock 0.4.4). +- `sandbox.rs build_env`: pin **TMPDIR to the target dir** (same mount + same landlock hierarchy as the outputs) — belt-and-braces for the V1-downgrade case and for bwrap's tmpfs /tmp (a different device from the bound target). +- landlock RO grant on env `CARGO_HOME` (phase-2 offline reads of the fetched registry when it's outside /usr). + +macOS `cargo check` can't compile the linux-only landlock body — the docker builder stage type-checks it. Rebuild + fresh-slate fixture verification in progress. Also queued follow-up: why the bwrap probe fails in the demo container (seccomp=unconfined is set; it downgrades to landlock silently). + +### 2026-07-10 (update 8) — REOPENED for the demo stack: EVERY Rust package build there was failing (empty baked registry) +In-container verification on a FRESH demo stack (`ui down --clean` → rebuilt images) surfaced the last layer of the onion: **all 10 Rust fixture builds + simple-packaged fail**; only the Python packages succeed. Root cause: `Dockerfile.demo`'s builder uses a BuildKit **cache mount** for `/usr/local/cargo/registry`, which never persists into the image → the compiler runtime's registry is EMPTY → every sandboxed cargo build tries to download crates → `Permission denied` writing the read-only cargo home (e.g. `autocfg-1.5.1.crate`, `sha1_smol-1.0.1.crate`). Old stacks looked healthy only because their volumes predated I-0105's sandbox. IMPORTANT positive finding: the `--dev-workspace` patch injection works in-container (the patch-unused warnings list only the genuinely unused cloacina crates — the workflow crates resolved from /workspace). + +**Fix (on PR #192):** +- `Dockerfile.demo` workspace stage: `RUN cargo fetch` after the workspace COPY — bakes the full workspace-lock registry INTO the image. +- `docker-compose.demo.yml`: `--cargo-flag=--offline` on all three compilers (public, acme, x86) — resolution stays allowed (no `--frozen`; staged packages carry no lock) but cargo resolves against the baked cache instead of dying mid-download. Sandbox stays read-only. + +Rebuild + fresh-slate verification in progress: expect ALL Rust fixtures to build AND the simple-packaged gold-path recipe to reach a Completed execution in-container. + +### 2026-07-10 (update 7) — angreal lanes green, committed to PR #192 +Default (path-dep) lane also full-green (exit 0, execution Completed) — no regression. Committed as two commits on `chore/compose-stack-cleanup` (PR #192, title/body expanded): `560450c8` chore(dev-stack) port-15432 move; `bb389959` fix(T-0887) compiler `--dev-workspace` + `--version-deps` e2e + example re-author + demo compose wiring. Pre-commit (fmt, cargo check both backends, version lockstep) green on both. Done pending merge. + +### 2026-07-10 (update 6) — VERIFIED GREEN: version-dep gold path passes end-to-end +`angreal test e2e compiler --version-deps` (renamed from `--prod-deps` — maintainer read "prod" as "released crates"; it always compiles the LOCAL dev workspace, offline, crates.io never contacted) → **full pass, exit 0**: happy path (version-dep manifest) build_status=success; workflow_name/tasks/task_graph populated; **reconciler e2e → execution Completed**; failed-build/content-hash/stale-heartbeat/upgrade/rollback/concurrent lanes all green on path-dep fixtures under the same `--dev-workspace` compiler (hatch is a no-op for path-dep packages, as designed). Default (no-flag) lane rerun in progress as the regression check. + +Two infra fixes landed en route: +- **Dev stack moved off port 5432 → host 15432** (maintainer decision; `kairos-postgres` — another project, untouchable — holds 5432). `.angreal/docker-compose.yaml` publishes `15432:5432`; every harness db_url, Rust test fallback (server lib.rs, cloacina tests, cloacina-python), `tests/python/conftest.py`, Dockerfile.test, and nightly CI (service mapping + DATABASE_URL + macos setup-postgres) now use `localhost:15432`. In-cluster URLs (`postgres:5432`, helm) and user-facing docs stay 5432. Preflight in `_start_postgres` checks 15432 with a remedy message. +- **Pack-guard fix**: in `--version-deps` mode, residual `__WORKSPACE__` literals in fixture COMMENTS tripped cloacinactl's pack guard; the staging now scrubs them after the dep rewrite. + +Remaining to close: default-lane green (running) + commit; the demo-compose `--dev-workspace /workspace` wiring is committed but its in-container run is verified only transitively (same compiler code + dep form proven here). + +### 2026-07-10 (update 5) — e2e coverage for the SHIPPING dep form (angreal, not a scratchpad) +Maintainer flagged that the gold-path check shouldn't be a `/tmp` script — the compiler-path e2e IS the right test path; add the non-escape-hatch (production version-dep) form "as a flag." Done, in the existing `angreal test e2e compiler` harness (it already owns the server+compiler+postgres subprocess lifecycle): + +- New **`--prod-deps`** flag on `angreal test e2e compiler` (`.angreal/test/e2e/compiler.py`). When set: (1) the happy fixture's Cargo.toml is rewritten from `__WORKSPACE__` path-deps to crates.io **version-deps** on the fly (`_to_version_deps` regex — same workflow code, shipping dep form); (2) the compiler subprocess is started with `--dev-workspace `. Same assertions (build_status=success → workflow run → execution completes) now prove a REAL distributed-package dep form builds + runs. Default run is unchanged (escape hatch); CI runs both. +- Key insight this closes: the escape-hatch (`__WORKSPACE__`) form was tested, the SHIPPING (version-dep) form had ZERO e2e coverage — same "should've seen loud failures" blind spot as I-0137. Now both are covered. +- Verified: transform produces the exact `cloacinactl package new` form; `py_compile` clean; `--prod-deps` flag registers in `angreal test e2e compiler --help`. + +**Proof command (rebuilds the compiler from source, no docker):** `angreal test e2e compiler --prod-deps` → expect build_status=success + execution completes. Supersedes the scratchpad demo-stack driver. + +### 2026-07-10 (update 4) — IMPLEMENTED: `--dev-workspace` patch-injection hatch (host-validated, image rebuilding) +Chose a lighter mechanism than update-3's vendored-registry: a `[patch.crates-io]` injection, which needs no `cargo vendor`/local-registry generation. + +**Compiler change (new `--dev-workspace ` flag / `CLOACINA_COMPILER_DEV_WORKSPACE`, `dev_workspace: Option` on CompilerConfig):** +- `build.rs::inject_dev_patch` — before each build, read `/crates/*/Cargo.toml`, and append a `[patch.crates-io]` table to the unpacked package's Cargo.toml mapping every local crate name → its path. So a package that ships **production crates.io version deps** (`cloacina-workflow = "0.10"`) resolves against the UNPUBLISHED local crates. No-op unless `--dev-workspace` set → production compilers resolve from crates.io untouched. +- `sandbox.rs` — new `BuildMounts.patch_crates_dir` bound **read-only**; bound to the workspace **ROOT** (not `crates/`) because the patched path-crates use `{ workspace = true }` inheritance and cargo walks up to the root `Cargo.toml`. bwrap binds it before the RW target sub-bind (order preserved); landlock unions the RO-root + RW-target rules so `/workspace/target` stays writable. Wired through `wrap_command` (bwrap `--ro-bind`), `apply_landlock` (extra RO PathBeneath), and `config_hash`. +- `main.rs` — clap arg + config wiring. + +**Example (T-0884):** re-authored `examples/features/workflows/simple-packaged/Cargo.toml` to the `cloacinactl package new` version-dep form — `cloacina-workflow = { version = "0.10", features=["packaged"] }`, `cloacina-workflow-plugin = "0.10"`, + dev/build cloacina deps as version deps. No more `../../../../crates/`. + +**Demo stack:** `docker/docker-compose.demo.yml` — added `--dev-workspace /workspace` to both `compiler` and `compiler-acme` (the repo is already mounted at `/workspace`). + +**Host validation (all green):** (1) `cargo check -p cloacina-compiler` → Finished, no errors. (2) Replicated `inject_dev_patch` on a copy of simple-packaged living OUTSIDE the workspace: `cargo generate-lockfile --offline` locked 310 pkgs (workspace-inheritance path-crates resolved); `cargo check --lib --offline` → Finished (cloacina-workflow w/ `packaged`, macros, workflow-plugin, computation-graph all compiled). Proves the full mechanism end-to-end offline. + +**Remaining:** rebuild the demo compiler image (in progress) + re-run the gold-path E2E to a **Completed** execution to close this + T-0884. + +### 2026-07-10 (update 3) — MAINTAINER STEER: don't prop up the vendor/workspace path; crates.io is the model +Maintainer clarified the architecture: the `__WORKSPACE__`/code-vendor path is a **dev-cycle ESCAPE HATCH**, not production. Production = a package ships deps via **crates.io version deps** (`cloacina-workflow = "0.10"`), which is exactly what `cloacinactl package new` emits. So binding `/workspace/crates` into the sandbox (update-2 idea) is WRONG — it dresses up the hatch as the real model. + +**Correct fix (two parts):** +1. **Examples use version deps.** Re-author the canonical example (simple-packaged, T-0884) to the `cloacinactl package new` form (`cloacina-workflow = { version = "…", features=["packaged","macros"] }`, `cloacina-workflow-plugin = "…"`, minimal shell) — NOT path/`__WORKSPACE__` deps. +2. **Dev/demo compiler resolves them via a proper vendor hatch.** Because cloacina isn't published to crates.io yet, the demo/e2e compiler needs a vendored registry of the (unpublished) cloacina crates so version-dep packages resolve OFFLINE under the sandbox — via `--vendor-dir` (already supported: sandbox.rs binds vendor_dir RO). This is the legit dev-cycle use of the hatch: package stays production-shaped (version deps), infra supplies the not-yet-published crates. Requires generating a vendor/local-registry that source-replaces crates.io with the workspace crates (cargo vendor alone won't do unpublished path members — needs a local registry or `[source]`/`[patch]` replacement), mounting it, and pointing `--vendor-dir` at it in docker-compose.demo.yml. + +Fix location: `docker/docker-compose.demo.yml` + `Dockerfile.compiler` (build the vendor + `--vendor-dir`), example Cargo.toml (T-0884). Verify: gold-path E2E to a Completed execution. This is real infra (+ compiler image rebuild + re-test), not a one-liner. + +### 2026-07-10 (update 2) — REAL root cause: landlock sandbox denies /workspace/crates +Took the E2E further: re-authored simple-packaged to `__WORKSPACE__/crates/`, re-uploaded. It STILL failed — did NOT reach a Completed execution (12 min "compiling…", `data_processing` never registered; `workflow run` → "Workflow not found in registry"). The compiler log on the demo's OWN `packaged-graph-example` fixture shows the true error: +`failed to read /workspace/crates/cloacina-computation-graph/Cargo.toml: Permission denied (os error 13)`. + +**REAL ROOT CAUSE:** the compiler builds under `sandbox_level=landlock`, whose curated RO mounts are source_dir + target_dir + vendor_dir + toolchain — but NOT `/workspace/crates/` (the baked cloacina source that `__WORKSPACE__` path-deps resolve to). So ANY package with path-deps into the workspace fails EACCES under the sandbox; and there's no vendor dir for the version-dep form either. This hits the demo's OWN CG-dependent fixtures — pre-existing, NOT a T-0865 regression, NOT the dep form (that was necessary but not sufficient). + +**Fix options (real infra work):** (a) bind `/workspace/crates` (or the whole baked workspace) RO into the compiler sandbox for the demo/e2e stack; (b) provide a vendored registry (`cargo vendor` → `--vendor-dir`) so packaged workflows use version deps (the real distributed form) and never need the workspace; (c) run the demo compiler at `sandbox_level=off` (weakest). Likely (b) is the "real distributed package" answer aligned with `cloacinactl package new`. Fix location: `crates/cloacina-compiler/src/sandbox.rs` (mount set) + `docker/docker-compose.demo.yml` / `Dockerfile.compiler` (vendor provisioning). Re-run the T-0884 gold-path E2E to a Completed execution to verify. + +### 2026-07-10 — RUN DOWN: not a T-0865 regression; it's a stale-example dep form +Inspected the running compiler container: `/root/.cloacina/crates/` does not exist (only `build-tmp` + `logs`); the crates ARE baked at `/workspace/crates/` (incl. cloacina-computation-graph). So NOTHING is "missing from a staged set" — my original finding #1 was wrong. + +**Real cause:** three dep conventions — +- `cloacinactl package new` (canonical, distributed): version deps `cloacina-workflow = { version = "0.10", features=[...] }` (new.rs:323; a unit test asserts NO `path=`/`__WORKSPACE__`). +- Working demo fixtures (demo-fanout-rust): `path = "__WORKSPACE__/crates/…"` — the server REWRITES `__WORKSPACE__` → the abs workspace root at `registry/reconciler/loading.rs:3204`, so these build on the demo server. +- `simple-packaged`: `path = "../../../../crates/…"` — a THIRD, broken form. When the server stages the pkg under `/root/.cloacina/build-tmp/…`, `../../../../crates/` resolves to the nonexistent `/root/.cloacina/crates/`. Only ever worked for the in-repo `cargo run --example end_to_end_demo`. + +**T-0865 verdict: NOT a regression.** cloacina-workflow-plugin's `{ workspace = true }` is irrelevant; the failure is entirely simple-packaged's own relative-path deps. + +**Fix (folds into [[CLOACI-T-0884]]):** re-author `simple-packaged` (and the canonical packaged example) off `../../../../crates/`. For a demo-runnable example, use `__WORKSPACE__/crates/` like the fixtures; for the "real distributed package" story, version deps (needs a vendored registry on the demo compiler, which currently has none — separate consideration). This closes T-0887 as "diagnosis done, fix is example re-authoring in T-0884," not a compiler/infra bug. Re-run the T-0884 gold-path E2E to Completed after re-authoring to confirm. diff --git a/.metis/backlog/bugs/CLOACI-T-0888.md b/.metis/backlog/bugs/CLOACI-T-0888.md new file mode 100644 index 000000000..be0ecf72f --- /dev/null +++ b/.metis/backlog/bugs/CLOACI-T-0888.md @@ -0,0 +1,141 @@ +--- +id: bug-databaseadmin-build-connection +level: task +title: "BUG: DatabaseAdmin build_connection_string hardcodes localhost:5432 — tenant credentials point at the wrong host on any non-default deployment" +short_code: "CLOACI-T-0888" +created_at: 2026-07-11T21:01:45.516203+00:00 +updated_at: 2026-07-11T21:01:45.516203+00:00 +parent: +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/backlog" + - "#bug" + + +exit_criteria_met: false +initiative_id: NULL +--- + +# BUG: DatabaseAdmin build_connection_string hardcodes localhost:5432 — tenant credentials point at the wrong host on any non-default deployment + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[Parent Initiative]] + +## Objective **[REQUIRED]** + +`crates/cloacina/src/database/admin.rs` `build_connection_string()` (~line 324) returns a hardcoded template — `postgresql://{user}:{pass}@localhost:5432/cloacina` — regardless of the admin connection it was created from (the code even says "For now, return a template"). The `TenantCredentials.connection_string` handed back by `create_tenant` therefore points at the wrong host/port/database for ANY deployment that isn't exactly localhost:5432/cloacina (the dev stack on 15432, any remote postgres, any non-`cloacina` db name). + +**Found** during the 2026-07-11 dev-stack port move (T-0887 verification): python tutorial 06 *prints* the credential connection string (wrong on 15432) but connects via `with_schema(admin_url, schema)`, so nothing in-repo currently breaks — it's latent until a consumer actually dials `credentials.connection_string` (which is the documented purpose of the field). + +**Fix:** derive host/port/dbname from the admin connection URL (parse the admin URL, swap in the tenant username/password, keep host/port/db/params). Unit test: a non-default admin URL round-trips into the tenant credential string. Verify the python binding surface (`cloaca.TenantCredentials.connection_string`) reflects the fix (tutorial 06 prints it). + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/initiative.md b/.metis/initiatives/CLOACI-I-0138/initiative.md index bf8e3997b..ef83d2d21 100644 --- a/.metis/initiatives/CLOACI-I-0138/initiative.md +++ b/.metis/initiatives/CLOACI-I-0138/initiative.md @@ -4,14 +4,14 @@ level: initiative title: "Packaged-first examples — server/daemon gold path as the standard for all examples" short_code: "CLOACI-I-0138" created_at: 2026-07-10T00:22:40.417461+00:00 -updated_at: 2026-07-10T00:22:40.417461+00:00 -parent: +updated_at: 2026-07-10T01:16:19.910293+00:00 +parent: blocked_by: [] archived: false tags: - "#initiative" - - "#phase/discovery" + - "#phase/active" exit_criteria_met: false @@ -46,6 +46,29 @@ initiative_id: packaged-first-examples-server ## Decisions (2026-07-09 check-in) - **D-1 Scope:** NEW-first + incremental. Lock packaged-first as the standard, build the canonical packaged-example template, apply to new examples immediately; convert the existing embedded examples incrementally (own tasks). - **D-2 Docs:** Examples tree FIRST; re-cut the Diátaxis tutorials packaged-first as a LATER phase, once the pattern is proven. +- **D-3 (maintainer, 2026-07-10): ALL examples take the primary interface; the built-in scheduler is not the demo/testing vehicle.** The point is to *demonstrate every feature through the primary interface* — server/daemon via pack → upload → compile → reconcile → execute (or monitor). Examples must not show the in-process `DefaultRunner` as the way to run/test. This sharpens D-1: incremental is still the pace, but the end-state is unambiguous — no embedded-runner demos left, no "alternative embedded path" sections in READMEs. Corollary: migrating each feature example through the server path will surface any server-path feature gaps (cron via schedules, event triggers via the trigger API, multi-tenancy via the tenants API, …) — surfacing those loudly is part of the value, per the I-0137 lesson. + +## Feature-coverage audit (2026-07-11) — do examples exercise all Rust + Python features? NO +Grounded against the actual surfaces: `cloacina-macros/src/lib.rs` (all proc macros + options), `cloacina-python/src/lib.rs::register_authoring` (the cloaca contract), and the server routes / cloacinactl nouns. + +**Covered (mostly via the EMBEDDED runner, which D-3 says stop demoing):** tasks/dependencies/retries (tutorials + most examples); conditional retries (`conditional-retries`, rust tutorial 02); trigger rules (tutorials 04); deferred tasks; cron via runner APIs (`cron-scheduling`, python 05); event/poll triggers (`event-triggers`, python 07-08); multi-tenancy via DatabaseAdmin (`multi-tenant`, `per-tenant-credentials`, tutorials 06); CGs/reactors/accumulators basics (rust CG tutorials 07-10, python 09-11, `packaged-graph`, `filtered-reactor`). **Via the PRIMARY interface: `simple-packaged` only — plain tasks.** + +**Zero user-facing coverage (fixtures-only or nothing):** +1. **Parameterized workflow instances (I-0116)** — `register_workflow_instance`, `#[workflow] params(...)`, `@workflow_params` — an entire shipped initiative +2. **Workflow secrets** — `secrets(...)` / `@workflow_secrets` + `cloacinactl secret` noun +3. **`@boundary_schema`** (python CG typed surfaces) — fixtures only +4. **Task→CG invocation** — `invokes = computation_graph(...)`, `post_invocation` — fixtures only +5. **Accumulator kinds `stream` (kafka) / `batch` / `polling`** — fixtures only (tutorials cover passthrough/state) +6. **Constructors/providers** — `#[constructor]`, `constructor_provider!`, `constructor!` nodes + `grants`, `cloacinactl constructor package` — `examples/constructor-contract/*` exists but is OUTSIDE the demos harness and CI +7. **Operational surface** — manual `reactor fire`, `accumulator inject`, trigger pause/resume/fire, secrets noun, fleet provision/limits, `execution events --follow` — nothing teaches any of it + +**Conclusion:** the migration (embedded→primary interface) and the gap-fill are the SAME work: the end-state is **one gold-path example per feature**. Decomposed below as T-0889..T-0893 alongside the existing T-0885/T-0886. + +## Migration inventory (grounded 2026-07-10) — ≈26 units still demoing via `DefaultRunner` +- **Rust feature examples (10):** conditional-retries, cron-scheduling, deferred-tasks, event-triggers, multi-tenant, per-tenant-credentials, registry-execution, python-workflow, computation-graphs/filtered-reactor, constructor-contract/fs-grant-demo +- **Performance (3):** simple, parallel, pipeline (may stay embedded by design — they benchmark the engine, not the interface; decide at design time) +- **Rust tutorial library (6):** 01-basic-workflow … 06-multi-tenancy +- **Python tutorials (8):** 01_first_workflow … 08_packaged_triggers ## Grounding (2026-07-09) — the pattern already exists Reference packaged examples: `examples/features/workflows/{simple-packaged, packaged-workflows, packaged-triggers, registry-execution}` (registry-execution's README shows the compile→load-into-server→run recipe) + `computation-graphs/{packaged-graph, python-packaged-graph}`. Many `examples/fixtures/*` are packaged too but are TEST FIXTURES, not user-facing examples. Still-embedded feature examples (the incremental-conversion backlog): complex-dag, conditional-retries, cron-scheduling, deferred-tasks, event-triggers, multi-tenant, per-tenant-credentials, python-workflow. @@ -104,7 +127,27 @@ Reference packaged examples: `examples/features/workflows/{simple-packaged, pack ## Detailed Design **[REQUIRED]** -{Technical approach and implementation details} +### The gold-path shape (grounded 2026-07-09) +`simple-packaged` is the AUTHORING half (package.toml + workflow src → `.cloacina`). `registry-execution` runs a package but via an EMBEDDED `DefaultRunner` (SQLite in-mem + FilesystemRegistryStorage) — still embedded, NOT the server. Packaged-FIRST adds the missing half: **register the `.cloacina` with a running SERVER/daemon and run it there** (align with the existing `docs/content/service/tutorials/03-packaged-workflows.md`). cloacinactl exposes the nouns: `package`, `workflow`, `server`, `daemon`, `tenant`, `trigger`, `execution`. + +### Canonical packaged example (THE template) +Each example dir: +- `package.toml` — manifest (name, version, `interface = cloacina-workflow-plugin`, `[metadata] workflow_name/language/description`). +- Workflow source — Rust (`Cargo.toml` minimal shell + `src/lib.rs` with `#[workflow]`/`#[task]`, per I-0125) OR Python (`workflow.py` + `package.toml`). +- `README.md` — the **gold-path run recipe**: (1) build the `.cloacina`, (2) bring up the server via the docker compose demo stack ([[feedback_use_container_stack]]), (3) register the package with the server (cloacinactl), (4) trigger it, (5) observe via API/UI. NO in-process `DefaultRunner`. + +### Standard artifact +A short "Writing a Packaged Example" convention (files above + the README recipe skeleton, both languages) that NEW examples follow — the thing that makes packaged-first the default. + +### Decomposition (proposed — for sign-off) +- **T-a** Canonical RUST packaged example (reference template) — author + package.toml + gold-path README against the demo stack. +- **T-b** Canonical PYTHON packaged example (reference template). +- **T-c** "Packaged example standard" doc/convention new examples follow (+ CONTRIBUTING pointer). +- **T-d+ (backlog, incremental per D-1)** convert each still-embedded feature example (cron-scheduling, event-triggers, multi-tenant, per-tenant-credentials, complex-dag, conditional-retries, deferred-tasks, python-workflow) — one task each, later. + +### Open for sign-off +1. Run recipe targets the **docker compose demo stack** as the canonical "server" for examples (vs a bare `cloacinactl daemon`/`server start`)? +2. T-a/T-b build NEW reference examples, or promote `simple-packaged`/`packaged-workflows` in place as the canonical ones? ## UI/UX Design **[CONDITIONAL: Frontend Initiative]** @@ -150,4 +193,4 @@ Reference packaged examples: `examples/features/workflows/{simple-packaged, pack ## Implementation Plan **[REQUIRED]** -{Phases and timeline for execution} \ No newline at end of file +{Phases and timeline for execution} diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0884.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0884.md new file mode 100644 index 000000000..7beec5e73 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0884.md @@ -0,0 +1,175 @@ +--- +id: canonical-rust-packaged-example +level: task +title: "Canonical Rust packaged example — promote simple-packaged + gold-path demo-stack README" +short_code: "CLOACI-T-0884" +created_at: 2026-07-10T01:16:01.284448+00:00 +updated_at: 2026-07-11T19:21:59.063071+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/completed" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Canonical Rust packaged example — promote simple-packaged + gold-path demo-stack README + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +Make `examples/features/workflows/simple-packaged` THE canonical Rust packaged example by adding the gold-path (server/daemon) run recipe it's currently missing, and verify it end-to-end against the docker compose demo stack. + +**Key finding (2026-07-09):** the server GOLD-PATH recipe does not exist yet anywhere. Even the "service" tutorial (`docs/content/service/tutorials/03-packaged-workflows.md`) "Build and run" is `cargo run --example end_to_end_demo` — an IN-PROCESS demo that packages+loads+executes embedded. `registry-execution` is likewise embedded (`DefaultRunner`). So this task authors the genuinely-new flow, not a README polish. + +**The recipe (grounded — cloacinactl mechanism):** +1. `cloacinactl package pack . --out simple-packaged.cloacina` (build the `.cloacina`; `package validate` to check the archive). +2. Bring up the **docker compose demo stack** (server + UI + services) — the canonical "server" for examples ([[feedback_use_container_stack]]). +3. `cloacinactl config profile set demo --api-key --default` — point the CLI at the running server. +4. `cloacinactl package upload simple-packaged.cloacina` (or `publish`) — register with the running server. +5. Trigger the workflow via the server (cloacinactl trigger/execution noun or API) and observe via the API / web UI — NOT an in-process `DefaultRunner`. + +**Deliverable:** the README recipe above (verified against a live stack). Nail the exact `package upload` args + trigger command + demo-stack bring-up + bootstrap-key retrieval during implementation. + +**SCOPE STEER (maintainer, 2026-07-10):** this task is the standard-bearer for **ALL examples taking this path** — examples must demonstrate features **through the primary interface** (server/daemon: pack → upload → compile → reconcile → execute/monitor), and must **NOT show the built-in in-process scheduler (`DefaultRunner`) as the testing/demo vehicle**. So: do NOT keep the embedded `end_to_end_demo` as a labeled "alternative path" — the canonical example shows one path, the primary one. The pattern this task establishes is what the rest of the examples tree (≈26 units currently demoing via `DefaultRunner`: 10 Rust feature examples, 3 performance, 6 Rust tutorial steps, 8 Python tutorials) migrates to under [[CLOACI-I-0138]]. + +**REMAINING (needs a live stack):** end-to-end verification requires docker + the demo stack up. Not yet run. + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria + +## Acceptance Criteria + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +### 2026-07-11 (update 3) — DONE: recipe verified live to Completed, in-container and in-harness +- README recipe run live against a FRESH demo stack: pack → upload (pkg 1aa77216) → in-container build success → `workflow run data_processing` → **execution `Completed`** (exec 6216daad). All 18 seed fixtures also green on the same fresh stack (post sandbox excision + two-phase builds + `--dev-workspace`). +- `angreal demos features simple-packaged` (the CI examples-lane runner) → pass; wired into the `rust-examples` matrix. +- `angreal test e2e compiler --version-deps` (shipping manifest form) → full pass post-excision. +- Everything on PR #192. Task complete pending merge. + +### 2026-07-10 (update 2) — README rewritten + harness runner registered in the CI examples path +- **README**: full rewrite of `simple-packaged/README.md` as the pure gold-path recipe (pack → upload → compile → reconcile → execute → observe) against the demo stack via cloacinactl. Removed all stale content (broken `cargo run --example` commands, in-process registry/scheduler snippet, wrong dep versions, dead links). No embedded-runner path anywhere (initiative D-3). Authoring section matches the real macro syntax in src/lib.rs (task id = fn name; `dependencies=[...]`, `retry_attempts=N`). +- **Harness (maintainer steer: "the test harness should orchestrate all of this irl (angreal)... the harness has a path for examples and tutorials — use that path, it's in CI")**: added a bespoke `angreal demos features simple-packaged` command in `.angreal/demos/features/features.py` (same pattern as the bespoke `python-workflow`; the example stays in the auto-registration exclude list since `cargo run` is the wrong verb for it). It reuses the compiler-e2e lifecycle helpers: fresh dev-stack postgres (15432) + host server (18087) + host compiler (19005, `--dev-workspace `) → cloacinactl pack → upload → poll build to success → `workflow run data_processing` → poll execution to **Completed**. Added `simple-packaged` to the `rust-examples` matrix in `.github/workflows/examples-docs.yml` so CI runs it on example changes. (A first-draft `test/e2e/gold_path.py` was deleted — wrong home; the demos surface is the harness path for examples/tutorials.) +- Live run of `angreal demos features simple-packaged` in progress; result to be logged here. + +### 2026-07-10 — recipe VERIFIED through upload; trigger/observe blocked by a stale demo stack +Ran the gold path live against `angreal ui up`: +1. `cloacinactl package pack examples/features/workflows/simple-packaged --out X.cloacina` → 20KB source archive ✅ +2. `cloacinactl config profile set demo http://localhost:8080 --api-key clk_demo_public_key_0003 --tenant public --default` ✅ +3. `cloacinactl package upload X.cloacina` → **server accepted, returned package id 9dc1e30b-… ✅** (server-side registration + compiler build — the genuinely-new part works). +Trigger/observe commands CONFIRMED but not completed: `cloacinactl workflow run data_processing` (POST `/v1/tenants/{t}/workflows/{name}/execute`) and `cloacinactl execution list`. + +**Blocker = environment, not recipe.** The running demo stack was a STALE ~3-day-old instance (the earlier `angreal purge` docker-down used a different project name); its DB pool repeatedly exhausts (`Connection pool error: Timeout waiting for a slot`) under 5 agents + other postgres projects on the shared docker host — `/health` returns 200 but API calls flap with network errors. One `docker restart cloacina-demo-server-1` cleared it long enough for the upload; it re-saturates. + +**To finish:** on a CLEAN stack (`angreal ui down` → `angreal ui up`, isolated), complete `workflow run` + `execution list`, then write the verified recipe into the simple-packaged README as the canonical gold-path example. Recipe is otherwise ready. diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0885.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0885.md new file mode 100644 index 000000000..8ed65a1f4 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0885.md @@ -0,0 +1,136 @@ +--- +id: canonical-python-packaged-example +level: task +title: "Canonical Python packaged example — promote + gold-path demo-stack README" +short_code: "CLOACI-T-0885" +created_at: 2026-07-10T01:16:02.734335+00:00 +updated_at: 2026-07-10T01:16:02.734335+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Canonical Python packaged example — promote + gold-path demo-stack README + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +{Clear statement of what this task accomplishes} + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0886.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0886.md new file mode 100644 index 000000000..cce72937b --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0886.md @@ -0,0 +1,136 @@ +--- +id: packaged-example-standard-the +level: task +title: "Packaged example standard — the convention new examples follow (CONTRIBUTING)" +short_code: "CLOACI-T-0886" +created_at: 2026-07-10T01:16:03.990113+00:00 +updated_at: 2026-07-10T01:16:03.990113+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Packaged example standard — the convention new examples follow (CONTRIBUTING) + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +{Clear statement of what this task accomplishes} + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0889.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0889.md new file mode 100644 index 000000000..f52b75b39 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0889.md @@ -0,0 +1,148 @@ +--- +id: gold-path-example-parameterized +level: task +title: "Gold-path example: parameterized workflow instances (I-0116) — params, named instances, schedules" +short_code: "CLOACI-T-0889" +created_at: 2026-07-11T22:03:08.051490+00:00 +updated_at: 2026-07-11T22:03:08.051490+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Gold-path example: parameterized workflow instances (I-0116) — params, named instances, schedules + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +I-0116 (parameterized workflow instances — named, scheduled, param-bound) shipped with ZERO user-facing example coverage; only test fixtures (`demo-py-cron`, `demo-py-workflow`) use `workflow_params`. Build the gold-path example that demonstrates it end-to-end through the primary interface. + +**Surface to exercise (grounded):** +- Rust authoring: `#[workflow] params( name: Type [= default], … )` (workflow_attr.rs:281) +- Python authoring: `@cloaca.workflow_params(**kwargs)` (lib.rs:135, workflow.rs:451) +- Instance registration: named instances with bound params + optional schedule (I-0116; runner `register_workflow_instance` runner.rs:1015 is the embedded API — find and use the SERVER-side equivalent: how do packaged workflows declare/bind instances via upload + API? The compiler parses `declared_params` from package source at build (build.rs run_build) — trace how the server exposes param binding + scheduled instances, and demo THAT) +- Run with per-execution param values via `cloacinactl workflow run` (context/params input) + +**Shape (per the T-0886 standard):** `examples/features/workflows/parameterized-instances/` — package.toml + version-dep Cargo.toml + `#[workflow]` with `params(...)` + gold-path README (pack → upload → build → bind instance(s) → run with values → observe) + a bespoke or auto `demos features` runner (auto-joins CI via `demos matrix`). + +**Acceptance:** example builds on the demo stack; two named instances with different param bindings both reach Completed; README verified command-by-command; CI runs it. + +**Loud-failure clause (I-0137 lesson):** if the server path can't express something I-0116 promised (e.g. schedule binding for packaged workflows), that's a FINDING to surface, not to paper over. + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0890.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0890.md new file mode 100644 index 000000000..bb41f9069 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0890.md @@ -0,0 +1,142 @@ +--- +id: gold-path-example-workflow-secrets +level: task +title: "Gold-path example: workflow secrets — authoring secrets() plus the cloacinactl secret lifecycle" +short_code: "CLOACI-T-0890" +created_at: 2026-07-11T22:03:17.286920+00:00 +updated_at: 2026-07-11T22:03:17.286920+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Gold-path example: workflow secrets — authoring secrets() plus the cloacinactl secret lifecycle + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +Tenant secrets have full plumbing — authoring (`#[workflow] secrets( n1, n2, … )` workflow_attr.rs:250; `@cloaca.workflow_secrets(*args)` lib.rs:138/workflow.rs:483), server routes (create/list/get/rotate/delete, write-only values — routes/secrets.rs), and a `cloacinactl secret` noun (Create/Rotate/List/Get/Delete) — but ZERO example or tutorial teaches any of it. + +**Build:** `examples/features/workflows/secret-consumer/` (or fold into an existing gold-path example if that reads better) — a packaged workflow declaring `secrets(api_token)`, a task that reads the secret at execution, and a README walking the lifecycle: `cloacinactl secret create api_token …` → pack/upload/build → run → Completed → `secret rotate` → run again (new value visible) → show `secret get` returns metadata only (write-only values). + +**Shape:** T-0886 standard (package.toml, version deps, gold-path README, demos-features runner, auto-joins CI). + +**Acceptance:** execution Completed consuming a real tenant secret on the demo stack; rotation demonstrated; README verified command-by-command; CI runs it. Trace first HOW a task actually receives the secret value at runtime (context injection? env? grants?) and document that accurately — if the delivery mechanism turns out to be missing/broken for packaged workflows, that's a loud finding (I-0137 clause). + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0891.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0891.md new file mode 100644 index 000000000..7b3fbaacf --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0891.md @@ -0,0 +1,146 @@ +--- +id: gold-path-example-computation +level: task +title: "Gold-path example: computation-graph feature tour — stream/batch/polling accumulators, task-to-CG invocation, boundary_schema" +short_code: "CLOACI-T-0891" +created_at: 2026-07-11T22:03:26.284573+00:00 +updated_at: 2026-07-11T22:03:26.284573+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Gold-path example: computation-graph feature tour — stream/batch/polling accumulators, task-to-CG invocation, boundary_schema + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +The reactive layer's advanced surface is fixtures-only. Tutorials + `packaged-graph` cover CG basics (passthrough/state accumulators, reactor criteria, routing); NOT covered anywhere user-facing: +- **`#[stream_accumulator]`** (kafka: `type/topic/group/state` — accumulator_macros.rs:46-58) — only `demo-kafka-stream-rust` fixture +- **`#[batch_accumulator]`** (`flush_interval/max_buffer_size` — :342-346) and **`#[polling_accumulator]`** (`interval` — :240) — fixtures only +- **Task→CG invocation**: `#[task(invokes = computation_graph("name"), post_invocation = …)]` (tasks.rs:150/184) — the workflow↔CG bridge, fixtures only +- **`@cloaca.boundary_schema(**kwargs)`** (python typed CG surfaces — workflow.rs:516) — fixtures only + +**Build:** one richer gold-path CG example (extend `packaged-graph` or new `examples/features/computation-graphs/cg-feature-tour/`): a kafka stream accumulator feeding a reactor (demo stack has kafka), a polling accumulator, a batch accumulator, a workflow task that `invokes` the CG with `post_invocation`, and — python side — `boundary_schema` on the python-packaged-graph example (or the T-0885 python canonical). Split into two examples if one becomes unreadable; teaching clarity beats completionism. + +**Shape:** T-0886 standard; runs on the demo stack (kafka available); demos-features runner; auto-joins CI. + +**Acceptance:** each of the four uncovered surfaces appears in a user-facing example with a verified README step showing it working on the demo stack (accumulator ingest visible via `cloacinactl graph accumulators` / UI; task→CG invocation reaches Completed). + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0892.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0892.md new file mode 100644 index 000000000..dd133fe92 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0892.md @@ -0,0 +1,145 @@ +--- +id: constructors-providers-into-the +level: task +title: "Constructors/providers into the demos harness + CI — fs-grant-demo and friends stop being dark matter" +short_code: "CLOACI-T-0892" +created_at: 2026-07-11T22:03:34.817943+00:00 +updated_at: 2026-07-11T22:03:34.817943+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Constructors/providers into the demos harness + CI — fs-grant-demo and friends stop being dark matter + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +The entire constructor/provider surface — `#[constructor]` (kind = task|trigger|accumulator|reactor, `#[config]`/`#[param]` fields), `constructor_provider!`, in-workflow `constructor!` nodes with `grants` (http|tcp|fs|env|secrets), `#[reactor(from/constructor/config/grants)]`, `cloacinactl constructor package` (build+sign+pack a provider archive) — has examples under `examples/constructor-contract/` (fs-grant-demo, provider-fs/extract/quorum/sensor, constructor-contract) **but they are dark matter**: outside the demos discovery scan (`get_rust_feature_directories` only walks `examples/features/`), outside the CI matrix, never executed by any harness. This is the same class of blind spot as the sandbox (worked-on-paper, never run). + +**Do:** +1. Decide the canonical constructor example (likely `fs-grant-demo` — it shows grants + a provider consumed by a workflow) and bring it to the T-0886 standard (README with the provider lifecycle: author → `cloacinactl constructor package` → install/upload → workflow consumes the constructor node → run to Completed on the demo stack; note the demo compiler builds providers to wasm32-wasip2 at seed time already — T-0836). +2. Register it in the demos surface (bespoke `demos features` command if `cargo run` is the wrong verb) so it auto-joins the CI matrix via `demos matrix`. +3. Fold or clearly mark the remaining `constructor-contract/*` dirs as library/fixture crates (exclude-with-reason), so nothing user-facing is silently unexecuted. + +**Caveat from memory:** [[project_fidius_wasm_authoring_shift]] — fidius wasm trait impls may reshape the authoring/packaging story; keep this example thin and lifecycle-focused rather than deep on the current authoring shapes. + +**Acceptance:** a constructor/provider example runs green in CI through the demos harness; provider archive built + consumed + execution Completed; README verified. + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0893.md b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0893.md new file mode 100644 index 000000000..bcccc54b5 --- /dev/null +++ b/.metis/initiatives/CLOACI-I-0138/tasks/CLOACI-T-0893.md @@ -0,0 +1,146 @@ +--- +id: operational-surface-coverage +level: task +title: "Operational-surface coverage — reactor fire, accumulator inject, trigger pause/fire, execution events woven into owning examples" +short_code: "CLOACI-T-0893" +created_at: 2026-07-11T22:03:42.162382+00:00 +updated_at: 2026-07-11T22:03:42.162382+00:00 +parent: CLOACI-I-0138 +blocked_by: [] +archived: false + +tags: + - "#task" + - "#phase/todo" + + +exit_criteria_met: false +initiative_id: CLOACI-I-0138 +--- + +# Operational-surface coverage — reactor fire, accumulator inject, trigger pause/fire, execution events woven into owning examples + +*This template includes sections for various types of tasks. Delete sections that don't apply to your specific use case.* + +## Parent Initiative **[CONDITIONAL: Assigned Task]** + +[[CLOACI-I-0138]] + +## Objective **[REQUIRED]** + +The operational half of the primary interface — the verbs an operator uses on a RUNNING system — is taught nowhere. Surfaces (all shipped, all dark): `cloacinactl reactor force-fire` / `reactor fire ` (typed), `accumulator inject `, `trigger list/inspect` + server trigger pause/resume/fire routes, `graph list/status/accumulators`, `execution events --follow/--since`, `tenant`/`key` lifecycle, fleet provision/deprovision + tenant limits. + +**Approach — weave, don't invent:** each verb belongs in the README of the example that OWNS the feature, as an "Operate it" section after "Run it": +- `packaged-graph` / the T-0891 CG tour → `accumulator inject`, `reactor fire`/`force-fire`, `graph status/accumulators`, the accumulator/reactor WebSocket/UI view +- `event-triggers` (migrated) → `trigger list/inspect`, pause/resume, manual fire +- `simple-packaged` (canonical) → `execution events --follow` (already has list/status) +- `multi-tenant` (migrated) → server-side `tenant create/list` + `key create` + fleet provision/limits — the PRIMARY-interface tenant story (vs the embedded DatabaseAdmin it uses today) + +Each "Operate it" section is verified live like the Run-it recipes, and — where cheap — asserted in the example's demos-harness runner so CI exercises the verb (e.g. the CG runner injects an event and polls the reactor fire). + +**Acceptance:** every listed verb appears in exactly one owning example's verified "Operate it" section; at least `accumulator inject`, `reactor fire`, and `execution events` are asserted by harness runners in CI. Depends on / sequences with the owning examples' migrations (T-0891, event-triggers + multi-tenant migrations). + +## Backlog Item Details **[CONDITIONAL: Backlog Item]** + +{Delete this section when task is assigned to an initiative} + +### Type +- [ ] Bug - Production issue that needs fixing +- [ ] Feature - New functionality or enhancement +- [ ] Tech Debt - Code improvement or refactoring +- [ ] Chore - Maintenance or setup work + +### Priority +- [ ] P0 - Critical (blocks users/revenue) +- [ ] P1 - High (important for user experience) +- [ ] P2 - Medium (nice to have) +- [ ] P3 - Low (when time permits) + +### Impact Assessment **[CONDITIONAL: Bug]** +- **Affected Users**: {Number/percentage of users affected} +- **Reproduction Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected vs Actual**: {What should happen vs what happens} + +### Business Justification **[CONDITIONAL: Feature]** +- **User Value**: {Why users need this} +- **Business Value**: {Impact on metrics/revenue} +- **Effort Estimate**: {Rough size - S/M/L/XL} + +### Technical Debt Impact **[CONDITIONAL: Tech Debt]** +- **Current Problems**: {What's difficult/slow/buggy now} +- **Benefits of Fixing**: {What improves after refactoring} +- **Risk Assessment**: {Risks of not addressing this} + +## Acceptance Criteria **[REQUIRED]** + +- [ ] {Specific, testable requirement 1} +- [ ] {Specific, testable requirement 2} +- [ ] {Specific, testable requirement 3} + +## Test Cases **[CONDITIONAL: Testing Task]** + +{Delete unless this is a testing task} + +### Test Case 1: {Test Case Name} +- **Test ID**: TC-001 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +### Test Case 2: {Test Case Name} +- **Test ID**: TC-002 +- **Preconditions**: {What must be true before testing} +- **Steps**: + 1. {Step 1} + 2. {Step 2} +- **Expected Results**: {What should happen} +- **Actual Results**: {To be filled during execution} +- **Status**: {Pass/Fail/Blocked} + +## Documentation Sections **[CONDITIONAL: Documentation Task]** + +{Delete unless this is a documentation task} + +### User Guide Content +- **Feature Description**: {What this feature does and why it's useful} +- **Prerequisites**: {What users need before using this feature} +- **Step-by-Step Instructions**: + 1. {Step 1 with screenshots/examples} + 2. {Step 2 with screenshots/examples} + 3. {Step 3 with screenshots/examples} + +### Troubleshooting Guide +- **Common Issue 1**: {Problem description and solution} +- **Common Issue 2**: {Problem description and solution} +- **Error Messages**: {List of error messages and what they mean} + +### API Documentation **[CONDITIONAL: API Documentation]** +- **Endpoint**: {API endpoint description} +- **Parameters**: {Required and optional parameters} +- **Example Request**: {Code example} +- **Example Response**: {Expected response format} + +## Implementation Notes **[CONDITIONAL: Technical Task]** + +{Keep for technical tasks, delete for non-technical. Technical details, approach, or important considerations} + +### Technical Approach +{How this will be implemented} + +### Dependencies +{Other tasks or systems this depends on} + +### Risk Considerations +{Technical risks and mitigation strategies} + +## Status Updates **[REQUIRED]** + +*To be added during implementation* diff --git a/Dockerfile b/Dockerfile index ab5898fb5..aa872d748 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,6 @@ RUN apt-get update \ ca-certificates \ python3 \ python3-dev \ - bubblewrap \ && rm -rf /var/lib/apt/lists/* WORKDIR /build diff --git a/charts/cloacina-server/templates/compiler-deployment.yaml b/charts/cloacina-server/templates/compiler-deployment.yaml index 17e934662..71aebd5ec 100644 --- a/charts/cloacina-server/templates/compiler-deployment.yaml +++ b/charts/cloacina-server/templates/compiler-deployment.yaml @@ -1,11 +1,8 @@ {{- if .Values.compiler.enabled }} {{- include "cloacina-server.validateDatabase" . -}} -# CLOACI-I-0105: the cloacina-compiler builds uploaded packages, running -# attacker-controlled build.rs / proc-macros. It runs each `cargo build` inside -# a bwrap sandbox (see docs/service/compiler-sandbox.md) — which needs -# unprivileged user namespaces, blocked by the default seccomp profile. The -# securityContext below sets seccompProfile: Unconfined so bwrap works -# in-cluster; CLOACINA_COMPILER_SANDBOX gates the fail-closed posture. +# The cloacina-compiler builds uploaded packages. Tenant-level isolation is +# the security boundary (one compiler per tenant via --tenant-schema); the +# per-build process sandbox was excised 2026-07-11 (possible future layer). apiVersion: apps/v1 kind: Deployment metadata: @@ -72,11 +69,6 @@ spec: - name: DATABASE_URL value: {{ .Values.database.url | quote }} {{- end }} - # CLOACI-I-0105: fail-closed sandbox posture. `required` refuses to - # boot if bwrap is unusable (multi-tenant default here); `preferred` - # downgrades to landlock; `off` disables the process sandbox. - - name: CLOACINA_COMPILER_SANDBOX - value: {{ .Values.compiler.sandbox.mode | quote }} {{- with .Values.compiler.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} @@ -84,13 +76,6 @@ spec: {{- toYaml .Values.compiler.resources | nindent 12 }} securityContext: {{- toYaml .Values.compiler.securityContext | nindent 12 }} - {{- if ne .Values.compiler.sandbox.mode "off" }} - # bwrap's unprivileged user namespaces need the default seccomp - # profile relaxed. A NET security gain: bwrap's per-build isolation - # is stronger than default-seccomp with NO build sandbox. - seccompProfile: - type: {{ .Values.compiler.sandbox.seccompProfile }} - {{- end }} volumeMounts: # readOnlyRootFilesystem requires emptyDirs for cargo's writable # paths — the build target cache, tmp, and the cargo/rustup homes. diff --git a/charts/cloacina-server/values.yaml b/charts/cloacina-server/values.yaml index 9b56b5cb8..eb107c114 100644 --- a/charts/cloacina-server/values.yaml +++ b/charts/cloacina-server/values.yaml @@ -279,12 +279,13 @@ podAnnotations: {} podLabels: {} # --------------------------------------------------------------------------- -# Compiler (CLOACI-I-0105 / I-0111) +# Compiler (CLOACI-I-0111) # -# The cloacina-compiler builds uploaded Rust packages, running attacker- -# controlled build.rs / proc-macros. It sandboxes each `cargo build` with -# bubblewrap (see docs/service/compiler-sandbox.md). Enable it to compile +# The cloacina-compiler builds uploaded Rust packages. Enable it to compile # packages in-cluster; without it, uploaded Rust packages stay `pending`. +# Tenant-level isolation is the security boundary (run one compiler per +# tenant via --tenant-schema); the per-build process sandbox was excised +# 2026-07-11 and may return as an optional layer later. # --------------------------------------------------------------------------- compiler: enabled: false @@ -301,14 +302,6 @@ compiler: - "--cargo-flag=build" - "--cargo-flag=--lib" extraEnv: [] - sandbox: - # required | preferred | off. `required` (the multi-tenant default) refuses - # to boot when bwrap is unusable, rather than build untrusted code - # unsandboxed. `preferred` downgrades to landlock. `off` disables it. - mode: required - # bwrap needs unprivileged user namespaces, which the default seccomp - # profile blocks; Unconfined grants them. Applied only when mode != off. - seccompProfile: Unconfined # emptyDir size limit for the shared cargo target cache. buildCacheSize: 8Gi resources: @@ -318,8 +311,7 @@ compiler: limits: cpu: "2" memory: 4Gi - # Mirrors the server container hardening; seccompProfile is layered in by the - # template when the sandbox is active. + # Mirrors the server container hardening. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/crates/cloacina-compiler/src/build.rs b/crates/cloacina-compiler/src/build.rs index e619f6ba9..1c36a60d4 100644 --- a/crates/cloacina-compiler/src/build.rs +++ b/crates/cloacina-compiler/src/build.rs @@ -415,7 +415,9 @@ async fn run_build( None, wall_clock_ms, Some(&reason), - config.sandbox_level.as_str(), + // Build-process sandbox excised (2026-07-11); the field stays + // for audit-schema stability and states the truth. + "none", ); return Err(err); } @@ -432,7 +434,9 @@ async fn run_build( None, wall_clock_ms, None, - config.sandbox_level.as_str(), + // Build-process sandbox excised (2026-07-11); the field stays + // for audit-schema stability and states the truth. + "none", ); info!( %package_id, @@ -465,7 +469,9 @@ async fn run_build( exit_signal.as_deref(), wall_clock_ms, Some(&reason), - config.sandbox_level.as_str(), + // Build-process sandbox excised (2026-07-11); the field stays + // for audit-schema stability and states the truth. + "none", ); Err(BuildError::Failed { reason, @@ -489,7 +495,9 @@ async fn run_build( "cargo build exceeded build_timeout after {}s", elapsed.as_secs() )), - config.sandbox_level.as_str(), + // Build-process sandbox excised (2026-07-11); the field stays + // for audit-schema stability and states the truth. + "none", ); Err(BuildError::TimedOut { elapsed }) } @@ -649,6 +657,117 @@ fn ensure_build_wiring(source_dir: &Path) { } } +/// DEV ESCAPE HATCH (CLOACI-T-0887): when `--dev-workspace ` is set, inject +/// a `[patch.crates-io]` section mapping every local cloacina workspace crate +/// (`/crates/*`) to its path. This lets a package that ships +/// PRODUCTION-shaped crates.io version deps (`cloacina-workflow = "0.x"`) +/// resolve against the UNPUBLISHED local crates while cloacina isn't on +/// crates.io yet. NOT for production — real packages resolve from crates.io; +/// this is only for dev/e2e stacks (the demo compiler passes `--dev-workspace`). +fn inject_dev_patch(source_dir: &Path, workspace: &Path) { + let crates_dir = workspace.join("crates"); + let Ok(entries) = std::fs::read_dir(&crates_dir) else { + return; + }; + let mut patches = toml::value::Table::new(); + for entry in entries.flatten() { + let dir = entry.path(); + let Ok(raw) = std::fs::read_to_string(dir.join("Cargo.toml")) else { + continue; + }; + let Ok(doc) = raw.parse::() else { + continue; + }; + if let Some(name) = doc + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + let mut spec = toml::value::Table::new(); + spec.insert( + "path".into(), + toml::Value::String(dir.to_string_lossy().to_string()), + ); + patches.insert(name.to_string(), toml::Value::Table(spec)); + } + } + if patches.is_empty() { + return; + } + let manifest_path = source_dir.join("Cargo.toml"); + let Ok(raw) = std::fs::read_to_string(&manifest_path) else { + return; + }; + let Ok(mut doc) = raw.parse::() else { + return; + }; + let Some(root) = doc.as_table_mut() else { + return; + }; + let patch = root + .entry("patch") + .or_insert_with(|| toml::Value::Table(Default::default())); + if let Some(patch) = patch.as_table_mut() { + patch.insert("crates-io".into(), toml::Value::Table(patches)); + } + if let Ok(out) = toml::to_string(&doc) { + let _ = std::fs::write(&manifest_path, out); + tracing::info!( + dir = %source_dir.display(), + workspace = %workspace.display(), + "DEV: injected [patch.crates-io] -> local workspace crates (CLOACI-T-0887 --dev-workspace)" + ); + } +} + +/// Phase 1 of the two-phase build (CLOACI-T-0887): `cargo fetch`, OUTSIDE the +/// sandbox. Fetch only resolves the dependency graph and downloads crates — +/// no build scripts or proc-macros execute — so it may safely use the network +/// and a writable `CARGO_HOME`. Phase 2 (`cargo build --offline`) then runs +/// the untrusted code fully sandboxed with the network cut. Fetch also writes +/// the `Cargo.lock` phase 2 builds against, so the two phases see one graph. +async fn run_cargo_fetch( + package_id: uuid::Uuid, + source_dir: &Path, + config: &CompilerConfig, +) -> Result<(), BuildError> { + const MAX_ERR: usize = 16 * 1024; + tracing::info!( + package_id = %package_id, + "two-phase build: cargo fetch (trusted, unsandboxed — no package code runs)" + ); + let mut cmd = tokio::process::Command::new("cargo"); + cmd.arg("fetch") + .current_dir(source_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(vendor) = &config.vendor_dir { + cmd.env("CARGO_HOME", vendor); + } + let out = tokio::time::timeout(config.build_timeout, cmd.output()) + .await + .map_err(|_| { + BuildError::internal(format!( + "cargo fetch timed out after {:?}", + config.build_timeout + )) + })? + .map_err(|e| BuildError::internal(format!("failed to spawn cargo fetch: {e}")))?; + if !out.status.success() { + let mut err = String::from_utf8_lossy(&out.stderr).to_string(); + if err.len() > MAX_ERR { + err.truncate(MAX_ERR); + } + return Err(BuildError::Failed { + reason: format!("cargo fetch failed:\n{err}"), + exit_status: out.status.code(), + exit_signal: None, + }); + } + Ok(()) +} + async fn cargo_build( package_id: uuid::Uuid, source_dir: &Path, @@ -658,42 +777,40 @@ async fn cargo_build( ensure_build_wiring(source_dir); - // CLOACI-I-0105: run the build at the boot-probed sandbox level. The - // level was decided once at startup (fail-closed under `required`); - // here we only compose the command for it. - let mounts = crate::sandbox::BuildMounts { - source_dir, - target_dir: config.cargo_target_dir.as_deref(), - vendor_dir: config.vendor_dir.as_deref(), - }; - let level = config.sandbox_level; - let (program, pre_args) = crate::sandbox::wrap_command(level, &mounts); - let sandbox_hash = crate::sandbox::config_hash(level, &mounts); - tracing::info!( - sandbox = level.as_str(), - config_hash = %sandbox_hash, - package_id = %package_id, - "spawning build" - ); + // DEV hatch (CLOACI-T-0887): resolve version-dep packages against the local + // unpublished workspace crates. No-op unless `--dev-workspace` is set. + if let Some(ws) = &config.dev_workspace { + inject_dev_patch(source_dir, ws); + } + // Two-phase build (CLOACI-T-0887): `cargo fetch` resolves + downloads the + // full graph and pins the Cargo.lock, then the compile runs with + // `--offline` appended — deterministic (one resolution feeds both phases) + // and fetch failures surface separately from compile failures. Operators + // running the curated vendor posture (`--frozen`/`--offline` in + // --cargo-flag) keep single-phase behavior: their registry is + // authoritative and a fetch would defeat the curation. + let operator_offline = config + .cargo_flags + .iter() + .any(|f| f == "--frozen" || f == "--offline"); + if !operator_offline { + run_cargo_fetch(package_id, source_dir, config).await?; + } - let mut cmd = tokio::process::Command::new(&program); - cmd.args(&pre_args); - if level == crate::sandbox::SandboxLevel::Landlock { - // Level 2: env is scrubbed by the parent (no clearenv available) and - // the FS ruleset is applied in pre_exec. - cmd.env_clear(); - for (k, v) in crate::sandbox::build_env(&mounts) { - cmd.env(k, v); - } - crate::sandbox::apply_landlock( - &mut cmd, - source_dir.to_path_buf(), - config.cargo_target_dir.clone(), - config.vendor_dir.clone(), - ); + // The build-process sandbox (I-0105: bwrap/landlock around this spawn) + // was EXCISED by maintainer decision (2026-07-11): tenant-level isolation + // is the security boundary; process sandboxing is a possible future layer + // if needed. The build runs cargo directly. + tracing::info!(package_id = %package_id, "spawning build"); + + let mut cmd = tokio::process::Command::new("cargo"); + cmd.args(&config.cargo_flags); + if !operator_offline { + // Phase 2 of the two-phase build: the fetch above resolved + pinned + // the graph, so the compile resolves nothing new. + cmd.arg("--offline"); } - cmd.args(&config.cargo_flags) - .current_dir(source_dir) + cmd.current_dir(source_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) // Belt-and-suspenders: if the awaited future is dropped (e.g. parent @@ -999,74 +1116,8 @@ mod tests { pkg } - /// A build.rs that TRIES to read a host file + open a network socket and - /// records what it managed. Under bwrap both must fail (no host FS beyond - /// the mounts, no network). - fn synthetic_escape_package(work: &Path) -> PathBuf { - let pkg = work.join("t0855-escape"); - std::fs::create_dir_all(pkg.join("src")).expect("mkdir src"); - std::fs::write( - pkg.join("Cargo.toml"), - "[package]\nname = \"t0855-escape\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n", - ) - .expect("Cargo.toml"); - std::fs::write(pkg.join("src/lib.rs"), "").expect("lib.rs"); - // Escape attempts fail the BUILD (panic) so a leak is a loud red test, - // not a silently-ignored success. - std::fs::write( - pkg.join("build.rs"), - r#"fn main() { - // Host filesystem: /etc/hostname exists on every Linux host but must NOT - // be reachable from inside the sandbox (only curated RO mounts are). - if std::fs::read_to_string("/etc/machine-id").is_ok() { - panic!("SANDBOX ESCAPE: build.rs read /etc/machine-id"); - } - // Network: a connect() must fail with the network namespace unshared. - if std::net::TcpStream::connect_timeout( - &"1.1.1.1:53".parse().unwrap(), - std::time::Duration::from_millis(500), - ).is_ok() { - panic!("SANDBOX ESCAPE: build.rs opened a network socket"); - } -} -"#, - ) - .expect("build.rs"); - pkg - } - - /// CLOACI-T-0855: adversarial proof that a bwrap build cannot read host - /// paths or open the network. SKIPS where bwrap is unusable (macOS dev, - /// CI without userns) — the assertion only means something at level 1. - #[tokio::test] - async fn bwrap_build_cannot_escape_to_host_or_network() { - if crate::sandbox::probe(crate::sandbox::SandboxMode::Preferred) - .map(|p| p.level) - .unwrap_or(crate::sandbox::SandboxLevel::None) - != crate::sandbox::SandboxLevel::Bwrap - { - eprintln!("skipping: bwrap not usable here (dev/CI without userns)"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let package = synthetic_escape_package(tmp.path()); - let mut config = test_config(tmp.path(), Duration::from_secs(120)); - config.sandbox_level = crate::sandbox::SandboxLevel::Bwrap; - - let result = cargo_build(uuid::Uuid::new_v4(), &package, &config).await; - // The build must SUCCEED (both escape attempts failed, so build.rs - // didn't panic). A sandbox escape would have panicked build.rs → - // BuildError, failing this test loudly. - assert!( - result.is_ok(), - "escape build.rs failed — a sandbox escape (panic) or a bwrap \ - misconfiguration: {result:?}" - ); - } - fn test_config(home: &Path, build_timeout: Duration) -> CompilerConfig { CompilerConfig { - sandbox_level: crate::sandbox::SandboxLevel::None, home: home.to_path_buf(), bind: "127.0.0.1:0".parse::().expect("parse bind"), database_url: "unused-in-this-test".to_string(), @@ -1080,6 +1131,7 @@ mod tests { cargo_target_dir: Some(home.join("target")), build_timeout, vendor_dir: None, + dev_workspace: None, // Generous rlimits for the regular tests so a normal cargo // build never trips them; rlimit-specific tests override the // relevant field explicitly. diff --git a/crates/cloacina-compiler/src/config.rs b/crates/cloacina-compiler/src/config.rs index 398225032..132176e39 100644 --- a/crates/cloacina-compiler/src/config.rs +++ b/crates/cloacina-compiler/src/config.rs @@ -101,9 +101,14 @@ pub struct CompilerConfig { /// point `--vendor-dir` at the resulting cargo home. Closes the /// network-side of CLOACI-T-0574 / SEC-06. pub vendor_dir: Option, - /// CLOACI-I-0105: boot-probed sandbox level every build runs at - /// (fail-closed under CLOACINA_COMPILER_SANDBOX=required). - pub sandbox_level: crate::sandbox::SandboxLevel, + /// DEV ESCAPE HATCH (CLOACI-T-0887): a local cloacina workspace root. When + /// set, each build injects `[patch.crates-io]` mapping every crate under + /// `/crates/` to its path, and that dir is bound READ-ONLY in the + /// sandbox — so packages that ship production crates.io version deps + /// resolve against the UNPUBLISHED local crates during dev cycles. NOT for + /// production (real packages resolve from crates.io); only dev/e2e stacks + /// pass `--dev-workspace`. + pub dev_workspace: Option, /// Kernel-enforced resource ceilings applied via `setrlimit` in a /// `pre_exec` hook on Linux. Stored on all platforms but only applied diff --git a/crates/cloacina-compiler/src/lib.rs b/crates/cloacina-compiler/src/lib.rs index 0cff0936a..1c195afd2 100644 --- a/crates/cloacina-compiler/src/lib.rs +++ b/crates/cloacina-compiler/src/lib.rs @@ -23,7 +23,6 @@ mod doc_parse; mod health; mod loopp; mod param_parse; -pub mod sandbox; pub use config::{BuildRlimits, CompilerConfig}; diff --git a/crates/cloacina-compiler/src/main.rs b/crates/cloacina-compiler/src/main.rs index c72063359..28284ff49 100644 --- a/crates/cloacina-compiler/src/main.rs +++ b/crates/cloacina-compiler/src/main.rs @@ -120,6 +120,14 @@ struct Cli { #[arg(long, env = "CLOACINA_COMPILER_VENDOR_DIR")] vendor_dir: Option, + /// DEV ESCAPE HATCH (CLOACI-T-0887): local cloacina workspace root. When set, + /// each build injects `[patch.crates-io]` mapping `/crates/*` to their + /// paths, so packages that ship crates.io version + /// deps resolve against the UNPUBLISHED local crates during dev cycles. NOT + /// for production; only dev/e2e stacks (e.g. the demo compiler) set this. + #[arg(long, env = "CLOACINA_COMPILER_DEV_WORKSPACE")] + dev_workspace: Option, + /// `RLIMIT_CPU` (CPU-seconds) for the cargo subprocess. Linux-only. /// Default matches `--build-timeout-s` as a generous upper bound; the /// wall-clock timeout (T-0573) is the real bound. CLOACI-T-0575. @@ -214,15 +222,7 @@ async fn main() -> Result<()> { anyhow::bail!("--build-timeout-s must be greater than zero"); } - // CLOACI-I-0105: probe the sandbox ladder ONCE at boot. `required` - // without bwrap is a hard startup failure — never a silent downgrade. - let sandbox_mode = - cloacina_compiler::sandbox::SandboxMode::from_env().map_err(|e| anyhow::anyhow!(e))?; - let sandbox_plan = - cloacina_compiler::sandbox::probe(sandbox_mode).map_err(|e| anyhow::anyhow!(e))?; - let config = CompilerConfig { - sandbox_level: sandbox_plan.level, home: cli.home, bind: cli.bind, database_url: cli.database_url, @@ -239,6 +239,7 @@ async fn main() -> Result<()> { cargo_target_dir: cli.cargo_target_dir, build_timeout: Duration::from_secs(cli.build_timeout_s), vendor_dir: cli.vendor_dir, + dev_workspace: cli.dev_workspace, build_rlimits: BuildRlimits { // RLIMIT_CPU default tracks the wall-clock timeout: T-0573 is // the real bound; this is a generous upper ceiling. diff --git a/crates/cloacina-compiler/src/sandbox.rs b/crates/cloacina-compiler/src/sandbox.rs deleted file mode 100644 index 62d53dea4..000000000 --- a/crates/cloacina-compiler/src/sandbox.rs +++ /dev/null @@ -1,543 +0,0 @@ -/* - * Copyright 2026 Colliery Software - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -//! Build-process sandbox (CLOACI-I-0105, SEC-06/OPS-07). -//! -//! `cargo build` runs attacker-controlled code (`build.rs`, proc-macros). -//! Phase 1 (I-0104) capped COST (rlimits, `--frozen --offline`, curated -//! vendor registry); this phase isolates the PROCESS via a fail-closed -//! ladder selected by `CLOACINA_COMPILER_SANDBOX`: -//! -//! - **`required`** — builds run under bwrap (level 1) or the compiler -//! REFUSES AT BOOT. The multi-tenant posture. -//! - **`preferred`** — best available level, downgrades logged loudly. -//! - **`off`** — no sandbox (dev laptops); logged loudly at boot. -//! -//! **Level 1 — bwrap**: PID/net/user/... namespaces (`--unshare-all`, so NO -//! network), `--clearenv` (a build.rs cannot read `DATABASE_URL`), RO binds -//! for the toolchain + curated registry, writable binds ONLY for the build -//! dir + shared target cache, tmpfs `/tmp`, `--die-with-parent`. -//! -//! **Level 2 — landlock** (containers without userns, kernel >=5.13): -//! kernel FS ACLs applied in `pre_exec` — RO everything, RW only the build -//! dir + target cache. No namespace isolation; env is still scrubbed by the -//! caller. Phase 1 rlimits apply at every level. -//! -//! Every build's audit row records the ACHIEVED level + a hash of the -//! sandbox configuration, so forensics can prove what contained a given -//! build. - -use std::path::{Path, PathBuf}; - -/// Operator-selected sandbox mode (`CLOACINA_COMPILER_SANDBOX`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SandboxMode { - Required, - Preferred, - Off, -} - -impl SandboxMode { - pub fn from_env() -> Result { - match std::env::var("CLOACINA_COMPILER_SANDBOX") - .unwrap_or_else(|_| "preferred".to_string()) - .to_ascii_lowercase() - .as_str() - { - "required" => Ok(Self::Required), - "preferred" => Ok(Self::Preferred), - "off" => Ok(Self::Off), - other => Err(format!( - "CLOACINA_COMPILER_SANDBOX must be required|preferred|off, got '{other}'" - )), - } - } -} - -/// The isolation level a build actually ran under (audited per build). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SandboxLevel { - /// bwrap namespaces + clearenv + RO mounts + no network. - Bwrap, - /// landlock FS ACLs (+ Phase 1 rlimits); no namespace isolation. - Landlock, - /// No process sandbox (Phase 1 rlimits/offline only). - None, -} - -impl SandboxLevel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Bwrap => "bwrap", - Self::Landlock => "landlock", - Self::None => "none", - } - } -} - -/// Boot-time probe result: the level every build will use. -#[derive(Debug, Clone)] -pub struct SandboxPlan { - pub mode: SandboxMode, - pub level: SandboxLevel, -} - -/// Probe the host for the best available level and reconcile with the mode. -/// `Required` without bwrap is a LOUD boot failure — never a silent -/// downgrade (the REQ-008 pattern). -pub fn probe(mode: SandboxMode) -> Result { - if mode == SandboxMode::Off { - tracing::warn!( - "CLOACINA_COMPILER_SANDBOX=off — builds run UNSANDBOXED \ - (dev-only posture; do not use with untrusted packages)" - ); - return Ok(SandboxPlan { - mode, - level: SandboxLevel::None, - }); - } - - let bwrap = bwrap_usable(); - if bwrap { - tracing::info!("compiler sandbox: bwrap available — builds run at level 1 (namespaced)"); - return Ok(SandboxPlan { - mode, - level: SandboxLevel::Bwrap, - }); - } - - if mode == SandboxMode::Required { - return Err( - "CLOACINA_COMPILER_SANDBOX=required but bwrap is unusable here \ - (missing binary, or user namespaces blocked — in Docker, set the \ - documented security_opt so unprivileged userns work). Refusing to \ - start rather than build untrusted packages unsandboxed." - .to_string(), - ); - } - - if landlock_usable() { - tracing::warn!( - "compiler sandbox: bwrap unusable — DOWNGRADED to level 2 (landlock \ - FS ACLs, no namespace isolation). Set the container security_opt \ - for full isolation." - ); - return Ok(SandboxPlan { - mode, - level: SandboxLevel::Landlock, - }); - } - - tracing::warn!( - "compiler sandbox: neither bwrap nor landlock usable — builds run \ - UNSANDBOXED (Phase 1 rlimits/offline only). Use \ - CLOACINA_COMPILER_SANDBOX=required to make this a hard failure." - ); - Ok(SandboxPlan { - mode, - level: SandboxLevel::None, - }) -} - -/// bwrap is usable iff the binary exists AND it can actually create its -/// namespaces here (Docker's default seccomp blocks unprivileged userns — -/// the probe catches that, not just missing binaries). -fn bwrap_usable() -> bool { - // REPRESENTATIVE probe (CLOACI-T-0855): exercise the SAME namespace + - // mount shape a real build uses — a bare `--ro-bind / /` passes in - // containers where `--proc` mounting or `--unshare-net` actually fails, - // giving a false-positive that would then break every build. This runs - // the exact critical bits: unshare net/user/ipc/uts/cgroup, RO-bind - // /proc (a fresh --proc mount needs a new PID ns + caps a default - // container lacks), tmpfs, --clearenv. - std::process::Command::new("bwrap") - .args([ - "--unshare-user", - "--unshare-net", - "--unshare-ipc", - "--unshare-uts", - "--unshare-cgroup", - "--clearenv", - "--ro-bind", - "/proc", - "/proc", - "--dev", - "/dev", - "--tmpfs", - "/tmp", - "--ro-bind", - "/usr", - "/usr", - "true", - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -#[cfg(target_os = "linux")] -fn landlock_usable() -> bool { - use landlock::{Access, AccessFs, Ruleset, RulesetAttr, ABI}; - Ruleset::default() - .handle_access(AccessFs::from_all(ABI::V1)) - .and_then(|r| r.create()) - .is_ok() -} - -#[cfg(not(target_os = "linux"))] -fn landlock_usable() -> bool { - false -} - -/// The filesystem surface one sandboxed build sees. -pub struct BuildMounts<'a> { - /// The staged package source — the ONLY writable project dir. - pub source_dir: &'a Path, - /// Shared cargo target cache (writable — cost optimization from Phase 1). - pub target_dir: Option<&'a Path>, - /// Curated vendor registry / CARGO_HOME (read-only at level 1). - pub vendor_dir: Option<&'a Path>, -} - -/// Environment the sandboxed cargo receives. At level 1 the environment is -/// CLEARED and rebuilt from exactly this list — `DATABASE_URL` and friends -/// never cross into attacker code. -pub fn build_env(mounts: &BuildMounts<'_>) -> Vec<(String, String)> { - let mut env = vec![ - ( - "PATH".to_string(), - std::env::var("PATH") - .unwrap_or_else(|_| "/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin".into()), - ), - // build.rs writes to HOME land in the (contained) build dir. - ( - "HOME".to_string(), - mounts.source_dir.to_string_lossy().to_string(), - ), - ]; - for var in ["RUSTUP_HOME", "CARGO_HOME"] { - if let Ok(v) = std::env::var(var) { - env.push((var.to_string(), v)); - } - } - // The shared target cache + debuginfo default must survive the env scrub - // (they were previously plain `cmd.env` calls in cargo_build). - if let Some(target) = mounts.target_dir { - env.push(( - "CARGO_TARGET_DIR".to_string(), - target.to_string_lossy().to_string(), - )); - } - env.push(( - "CARGO_PROFILE_DEV_DEBUG".to_string(), - std::env::var("CARGO_PROFILE_DEV_DEBUG").unwrap_or_else(|_| "line-tables-only".into()), - )); - // Vendored CARGO_HOME (Phase 1) overrides the toolchain default. - if let Some(vendor) = mounts.vendor_dir { - env.retain(|(k, _)| k != "CARGO_HOME"); - env.push(( - "CARGO_HOME".to_string(), - vendor.to_string_lossy().to_string(), - )); - } - env -} - -/// Compose the command for a sandboxed `cargo` invocation at `level`. -/// Returns the program + leading args; the caller appends cargo's own args -/// and the (already-scrubbed) environment from [`build_env`]. -pub fn wrap_command(level: SandboxLevel, mounts: &BuildMounts<'_>) -> (String, Vec) { - match level { - SandboxLevel::Bwrap => { - // Per-namespace unshares (NOT --unshare-all): a fresh --proc mount - // needs a new PID ns + caps a default container lacks (CLOACI-T-0855 - // real-container proof). --unshare-net is the security-critical - // one; the container's /proc is already PID-isolated by Docker, so - // RO-bind it rather than mounting a new procfs. - let mut args: Vec = vec![ - "--unshare-user".into(), - "--unshare-net".into(), - "--unshare-ipc".into(), - "--unshare-uts".into(), - "--unshare-cgroup".into(), - "--die-with-parent".into(), - "--clearenv".into(), - "--ro-bind".into(), - "/proc".into(), - "/proc".into(), - "--dev".into(), - "/dev".into(), - "--tmpfs".into(), - "/tmp".into(), - ]; - // Toolchain + system libraries, read-only. Bind only what exists. - for ro in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc"] { - if Path::new(ro).exists() { - args.extend(["--ro-bind".into(), ro.into(), ro.into()]); - } - } - // Rust toolchain homes (the rust images put them under /usr/local, - // already covered by /usr; bind explicitly when elsewhere). - for var in ["RUSTUP_HOME", "CARGO_HOME"] { - if let Ok(v) = std::env::var(var) { - if !v.starts_with("/usr") && Path::new(&v).exists() { - args.extend(["--ro-bind".into(), v.clone(), v]); - } - } - } - // Curated vendor registry: read-only. - if let Some(vendor) = mounts.vendor_dir { - let v = vendor.to_string_lossy().to_string(); - args.extend(["--ro-bind".into(), v.clone(), v]); - } - // Writable surfaces: the staged source + the shared target cache. - let src = mounts.source_dir.to_string_lossy().to_string(); - args.extend(["--bind".into(), src.clone(), src.clone()]); - if let Some(target) = mounts.target_dir { - let t = target.to_string_lossy().to_string(); - args.extend(["--bind".into(), t.clone(), t]); - } - args.extend(["--chdir".into(), src]); - // Environment: cleared above; rebuilt explicitly. - for (k, v) in build_env(mounts) { - args.extend(["--setenv".into(), k, v]); - } - args.push("cargo".into()); - ("bwrap".to_string(), args) - } - SandboxLevel::Landlock | SandboxLevel::None => ("cargo".to_string(), Vec::new()), - } -} - -/// Apply the level-2 landlock ruleset to a command (Linux): RO+execute on -/// the system paths, RW only on the build dir + target cache. A best-effort -/// no-op on kernels without landlock (the probe already told the operator). -#[cfg(target_os = "linux")] -pub fn apply_landlock( - cmd: &mut tokio::process::Command, - source_dir: PathBuf, - target_dir: Option, - vendor_dir: Option, -) { - use landlock::{ - Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, ABI, - }; - unsafe { - cmd.pre_exec(move || { - let abi = ABI::V1; - let mut ruleset = Ruleset::default() - .handle_access(AccessFs::from_all(abi)) - .and_then(|r| r.create()) - .map_err(|e| std::io::Error::other(format!("landlock create: {e}")))?; - let ro = AccessFs::from_read(abi); - let rw = AccessFs::from_all(abi); - for p in [ - "/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/proc", "/dev", "/tmp", - ] { - if let Ok(fd) = PathFd::new(p) { - ruleset = ruleset - .add_rule(PathBeneath::new(fd, ro)) - .map_err(|e| std::io::Error::other(format!("landlock rule: {e}")))?; - } - } - if let Some(v) = &vendor_dir { - if let Ok(fd) = PathFd::new(v) { - ruleset = ruleset - .add_rule(PathBeneath::new(fd, ro)) - .map_err(|e| std::io::Error::other(format!("landlock rule: {e}")))?; - } - } - let mut rw_paths = vec![source_dir.clone()]; - if let Some(t) = &target_dir { - rw_paths.push(t.clone()); - } - // /tmp must stay writable for rustc temp files. - rw_paths.push(PathBuf::from("/tmp")); - for p in rw_paths { - if let Ok(fd) = PathFd::new(&p) { - ruleset = ruleset - .add_rule(PathBeneath::new(fd, rw)) - .map_err(|e| std::io::Error::other(format!("landlock rule: {e}")))?; - } - } - ruleset - .restrict_self() - .map_err(|e| std::io::Error::other(format!("landlock restrict: {e}")))?; - Ok(()) - }); - } -} - -#[cfg(not(target_os = "linux"))] -pub fn apply_landlock( - _cmd: &mut tokio::process::Command, - _source_dir: PathBuf, - _target_dir: Option, - _vendor_dir: Option, -) { -} - -/// Stable hash of the sandbox configuration for the audit trail. -pub fn config_hash(level: SandboxLevel, mounts: &BuildMounts<'_>) -> String { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - level.as_str().hash(&mut h); - mounts.source_dir.hash(&mut h); - mounts.target_dir.hash(&mut h); - mounts.vendor_dir.hash(&mut h); - format!("{:016x}", h.finish()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn mounts<'a>(src: &'a Path, vendor: &'a Path) -> BuildMounts<'a> { - BuildMounts { - source_dir: src, - target_dir: None, - vendor_dir: Some(vendor), - } - } - - /// The bwrap composition is the isolation contract (CLOACI-T-0853): full - /// namespaces, cleared env, and the curated mount set — proven without - /// needing bwrap installed. - #[test] - fn bwrap_command_enforces_isolation_contract() { - let src = PathBuf::from("/staged/pkg"); - let vendor = PathBuf::from("/curated/registry"); - let (prog, args) = wrap_command(SandboxLevel::Bwrap, &mounts(&src, &vendor)); - assert_eq!(prog, "bwrap"); - - // No network (the security-critical unshare) + per-namespace - // isolation + die-with-parent. (CLOACI-T-0855: --unshare-all + a - // fresh --proc mount fails unprivileged in containers; the real - // config unshares per-namespace and RO-binds /proc.) - assert!(args.iter().any(|a| a == "--unshare-net")); - assert!(args.iter().any(|a| a == "--unshare-user")); - assert!(args.iter().any(|a| a == "--die-with-parent")); - // /proc is RO-bound, not a fresh mount. - assert!(args.join(" ").contains("--ro-bind /proc /proc")); - // Env is CLEARED before the explicit rebuild — a build.rs cannot read - // DATABASE_URL et al. - assert!(args.iter().any(|a| a == "--clearenv")); - - let joined = args.join(" "); - // The staged source is bound WRITABLE; the curated registry RO. - assert!(joined.contains("--bind /staged/pkg /staged/pkg")); - assert!(joined.contains("--ro-bind /curated/registry /curated/registry")); - // The command ends by launching cargo INSIDE the sandbox. - assert_eq!(args.last().map(String::as_str), Some("cargo")); - - // ADVERSARIAL: no host path outside the staged source is writable — - // scan every `--bind` (RW) target and assert it's the source (or the - // target cache, absent here). - for w in args.windows(2) { - if w[0] == "--bind" { - assert_eq!( - w[1], "/staged/pkg", - "unexpected writable host bind: {}", - w[1] - ); - } - } - } - - /// The sandboxed env is an ALLOWLIST (CLOACI-T-0853): even with a secret - /// in the parent process, only the curated keys cross the boundary. - #[test] - fn build_env_is_an_allowlist_not_a_denylist() { - unsafe { - std::env::set_var("CLOACINA_TEST_SECRET_DATABASE_URL", "postgres://leak"); - } - let src = PathBuf::from("/staged/pkg"); - let vendor = PathBuf::from("/curated/registry"); - let env = build_env(&mounts(&src, &vendor)); - let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); - assert!(!keys.contains(&"CLOACINA_TEST_SECRET_DATABASE_URL")); - // HOME is redirected into the contained build dir so build.rs writes - // to $HOME stay inside the sandbox. - let home = env - .iter() - .find(|(k, _)| k == "HOME") - .map(|(_, v)| v.as_str()); - assert_eq!(home, Some("/staged/pkg")); - // The vendored registry wins CARGO_HOME (Phase 1 curation). - let cargo_home = env - .iter() - .find(|(k, _)| k == "CARGO_HOME") - .map(|(_, v)| v.as_str()); - assert_eq!(cargo_home, Some("/curated/registry")); - unsafe { - std::env::remove_var("CLOACINA_TEST_SECRET_DATABASE_URL"); - } - } - - /// non-bwrap levels do not wrap the command. - #[test] - fn non_bwrap_levels_run_cargo_directly() { - let src = PathBuf::from("/s"); - let vendor = PathBuf::from("/v"); - for level in [SandboxLevel::Landlock, SandboxLevel::None] { - let (prog, args) = wrap_command(level, &mounts(&src, &vendor)); - assert_eq!(prog, "cargo"); - assert!(args.is_empty()); - } - } - - /// END-TO-END ADVERSARIAL (CLOACI-T-0855): where bwrap actually works - /// (Linux CI with userns), a build attempting to WRITE outside its staged - /// dir is denied — the RO host mount holds. Skips (not fails) where bwrap - /// is unusable (macOS dev, userns-blocked containers) so the suite stays - /// green everywhere; CI runs it for real. - #[test] - fn bwrap_denies_host_write() { - let plan = probe(SandboxMode::Preferred).expect("probe"); - if plan.level != SandboxLevel::Bwrap { - eprintln!( - "skipping: bwrap not usable here (level={})", - plan.level.as_str() - ); - return; - } - let tmp = std::env::temp_dir().join("cloacina-sbx-adv"); - let _ = std::fs::create_dir_all(&tmp); - let (prog, mut args) = wrap_command(SandboxLevel::Bwrap, &mounts(&tmp, &tmp)); - // Replace the trailing `cargo` with a shell that tries to write /etc. - assert_eq!(args.pop().as_deref(), Some("cargo")); - args.extend([ - "/bin/sh".into(), - "-c".into(), - "echo x > /etc/cloacina_probe".into(), - ]); - let status = std::process::Command::new(prog) - .args(&args) - .status() - .expect("run bwrap"); - assert!( - !status.success(), - "write to /etc must be denied inside the sandbox" - ); - assert!( - !std::path::Path::new("/etc/cloacina_probe").exists(), - "the probe file must not exist on the host" - ); - } -} diff --git a/crates/cloacina-python/src/bindings/runner.rs b/crates/cloacina-python/src/bindings/runner.rs index cd51a76b0..e643cdede 100644 --- a/crates/cloacina-python/src/bindings/runner.rs +++ b/crates/cloacina-python/src/bindings/runner.rs @@ -1890,7 +1890,7 @@ mod tests { fn test_with_schema_rejects_empty_schema() { pyo3::prepare_freethreaded_python(); let result = PyDefaultRunner::with_schema( - "postgres://cloacina:cloacina@localhost:5432/cloacina", + "postgres://cloacina:cloacina@localhost:15432/cloacina", "", ); assert!(result.is_err()); @@ -1901,7 +1901,7 @@ mod tests { fn test_with_schema_rejects_invalid_chars() { pyo3::prepare_freethreaded_python(); let result = PyDefaultRunner::with_schema( - "postgres://cloacina:cloacina@localhost:5432/cloacina", + "postgres://cloacina:cloacina@localhost:15432/cloacina", "tenant;DROP TABLE", ); assert!(result.is_err()); diff --git a/crates/cloacina-python/tests/python_package.rs b/crates/cloacina-python/tests/python_package.rs index bc5eafe36..86ad1d777 100644 --- a/crates/cloacina-python/tests/python_package.rs +++ b/crates/cloacina-python/tests/python_package.rs @@ -367,7 +367,7 @@ mod postgres_bindings { use pyo3::prelude::*; use serial_test::serial; - const TEST_PG_URL: &str = "postgres://cloacina:cloacina@localhost:5432/cloacina"; + const TEST_PG_URL: &str = "postgres://cloacina:cloacina@localhost:15432/cloacina"; #[test] #[serial] diff --git a/crates/cloacina-server/src/lib.rs b/crates/cloacina-server/src/lib.rs index 83494510d..d2d139ea5 100644 --- a/crates/cloacina-server/src/lib.rs +++ b/crates/cloacina-server/src/lib.rs @@ -2198,7 +2198,7 @@ mod tests { use std::io::Write; use tower::ServiceExt; - const TEST_DB_URL: &str = "postgres://cloacina:cloacina@localhost:5432/cloacina"; + const TEST_DB_URL: &str = "postgres://cloacina:cloacina@localhost:15432/cloacina"; /// One Prometheus recorder per test process, shared by every `test_state()`. /// diff --git a/crates/cloacina/tests/fixtures.rs b/crates/cloacina/tests/fixtures.rs index 424c18960..5b4a462ec 100644 --- a/crates/cloacina/tests/fixtures.rs +++ b/crates/cloacina/tests/fixtures.rs @@ -46,7 +46,7 @@ static SQLITE_FIXTURE: OnceCell>> = OnceCell::new(); /// Default PostgreSQL connection URL #[cfg(feature = "postgres")] -const DEFAULT_POSTGRES_URL: &str = "postgres://cloacina:cloacina@localhost:5432/cloacina"; +const DEFAULT_POSTGRES_URL: &str = "postgres://cloacina:cloacina@localhost:15432/cloacina"; /// Get the test schema name from environment variable or generate a unique one /// This allows CI jobs to isolate their tests using different schemas @@ -87,7 +87,7 @@ pub async fn get_or_init_postgres_fixture() -> Arc> { // Use Database::new_with_schema for schema isolation // Pool size 10 + longer timeout to avoid exhaustion during long serial test runs let db = Database::new_with_schema( - "postgres://cloacina:cloacina@localhost:5432", + "postgres://cloacina:cloacina@localhost:15432", "cloacina", 10, Some(&schema), @@ -519,7 +519,7 @@ pub mod fixtures { #[serial] async fn test_migration_function_postgres() { let mut conn = - PgConnection::establish("postgres://cloacina:cloacina@localhost:5432/cloacina") + PgConnection::establish("postgres://cloacina:cloacina@localhost:15432/cloacina") .expect("Failed to connect to database"); // Test that our migration function works diff --git a/crates/cloacina/tests/integration/database/connection.rs b/crates/cloacina/tests/integration/database/connection.rs index a14e29851..d83e5877b 100644 --- a/crates/cloacina/tests/integration/database/connection.rs +++ b/crates/cloacina/tests/integration/database/connection.rs @@ -19,7 +19,7 @@ use url::Url; #[test] fn test_url_parsing_basic() { // Test that we can parse PostgreSQL URLs (using the url crate directly) - let db_url = "postgresql://user:pass@localhost:5432/test_db"; + let db_url = "postgresql://user:pass@localhost:15432/test_db"; let result = Url::parse(db_url); assert!(result.is_ok()); @@ -33,7 +33,7 @@ fn test_url_parsing_basic() { #[test] fn test_url_parsing_without_password() { - let db_url = "postgresql://user@localhost:5432/test_db"; + let db_url = "postgresql://user@localhost:15432/test_db"; let result = Url::parse(db_url); assert!(result.is_ok()); @@ -73,7 +73,7 @@ fn test_invalid_database_urls() { #[test] fn test_database_connection_construction() { // Test that we can construct database URLs for the Database constructor - let base_url = "postgresql://user:pass@localhost:5432"; + let base_url = "postgresql://user:pass@localhost:15432"; let database_name = "test_db"; let mut url = Url::parse(base_url).unwrap(); @@ -87,7 +87,7 @@ fn test_database_connection_construction() { #[test] fn test_database_url_modification() { // Test URL modification as done in Database::new - let base_url = "postgresql://user:pass@localhost:5432"; + let base_url = "postgresql://user:pass@localhost:15432"; let database_name = "my_database"; let mut url = Url::parse(base_url).expect("Invalid base URL"); diff --git a/crates/cloacina/tests/integration/executor/multi_tenant.rs b/crates/cloacina/tests/integration/executor/multi_tenant.rs index 811edd847..5d2faaa6d 100644 --- a/crates/cloacina/tests/integration/executor/multi_tenant.rs +++ b/crates/cloacina/tests/integration/executor/multi_tenant.rs @@ -70,7 +70,7 @@ mod postgres_multi_tenant_tests { #[tokio::test] async fn test_schema_isolation() -> Result<(), Box> { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgresql://cloacina:cloacina@localhost:5432/cloacina".to_string() + "postgresql://cloacina:cloacina@localhost:15432/cloacina".to_string() }); // Setup workflows BEFORE creating runners (runtime snapshot must capture them) @@ -173,7 +173,7 @@ mod postgres_multi_tenant_tests { #[tokio::test] async fn test_independent_execution() -> Result<(), Box> { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgresql://cloacina:cloacina@localhost:5432/cloacina".to_string() + "postgresql://cloacina:cloacina@localhost:15432/cloacina".to_string() }); // Setup workflows BEFORE creating runners @@ -259,7 +259,7 @@ mod postgres_multi_tenant_tests { /// Test that invalid schema names are rejected #[tokio::test] async fn test_invalid_schema_names() { - let database_url = "postgresql://cloacina:cloacina@localhost:5432/cloacina"; + let database_url = "postgresql://cloacina:cloacina@localhost:15432/cloacina"; // Test schema name with hyphens (should fail) let result = DefaultRunner::with_schema(database_url, "tenant-123").await; @@ -300,7 +300,7 @@ mod postgres_multi_tenant_tests { #[tokio::test] async fn test_builder_pattern() -> Result<(), Box> { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgresql://cloacina:cloacina@localhost:5432/cloacina".to_string() + "postgresql://cloacina:cloacina@localhost:15432/cloacina".to_string() }); let executor = DefaultRunner::builder() diff --git a/docker/Dockerfile.demo b/docker/Dockerfile.demo index 4f121d2ae..0114f5516 100644 --- a/docker/Dockerfile.demo +++ b/docker/Dockerfile.demo @@ -93,7 +93,7 @@ FROM rust:${RUST_VERSION}-slim-bookworm AS workspace RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential cmake libpq-dev libsasl2-dev libssl-dev pkg-config \ - git ca-certificates bzip2 python3 python3-dev bubblewrap \ + git ca-certificates bzip2 python3 python3-dev \ && rm -rf /var/lib/apt/lists/* # CLOACI-T-0836: constructor providers build to WASM components at package build # time (`pack_providers` → `cargo build --target wasm32-wasip2`), so the compiler diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index 1bf86d932..0df05f273 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -22,7 +22,7 @@ WORKDIR /workspace # Default environment for tests ENV RUST_BACKTRACE=full -ENV DATABASE_URL=postgres://cloacina:cloacina@host.docker.internal:5432/cloacina +ENV DATABASE_URL=postgres://cloacina:cloacina@host.docker.internal:15432/cloacina # Entry point for running tests CMD ["bash"] diff --git a/docker/docker-compose.demo.yml b/docker/docker-compose.demo.yml index 393c34ad9..ff1fb96c5 100644 --- a/docker/docker-compose.demo.yml +++ b/docker/docker-compose.demo.yml @@ -36,6 +36,11 @@ x-acme-key: &acme_key "clk_demo_acme_key_0002" services: postgres: image: postgres:16 + # The demo runs a full fleet (server + N agents + compilers) all pooling + # into this one Postgres; the default 100 connections exhaust under that + # load (auth/heartbeat/scheduler all time out waiting for a slot). Match the + # backing-services stack's headroom. + command: postgres -c max_connections=500 environment: POSTGRES_USER: cloacina POSTGRES_PASSWORD: cloacina @@ -173,24 +178,30 @@ services: - "/workspace/target" - "--cargo-flag=build" - "--cargo-flag=--lib" + # NOTE: no --frozen/--offline here. That opts into the compiler's + # TWO-PHASE build (CLOACI-T-0887): `cargo fetch` runs OUTSIDE the sandbox + # (network on, only trusted cargo code — no package build scripts), then + # the compile runs INSIDE the sandbox with `--offline` appended (package + # code executes, network cut). Hardened operators pass a curated + # --vendor-dir + --cargo-flag=--frozen/--offline to stay single-phase. + # DEV ESCAPE HATCH (CLOACI-T-0887): packages ship crates.io VERSION deps + # (production form), but the cloacina crates aren't published yet. Point + # the compiler at the baked workspace so each build injects + # `[patch.crates-io]` redirecting cloacina-* to the local unpublished + # crates. Production compilers OMIT this and resolve from crates.io. + - "--dev-workspace" + - "/workspace" environment: # Override the compiler's default (line-tables-only) down to NO debuginfo # for the memory-tightest build on a constrained Docker VM — full # debuginfo OOM-killed complex-dag-example (it pulls in the heavy # `cloacina` lib). The demo never debugs the compiled cdylibs. CARGO_PROFILE_DEV_DEBUG: "0" - # CLOACI-I-0105: build-process sandbox. `preferred` uses bwrap when the - # container permits unprivileged user namespaces (the security_opt - # below), else downgrades to landlock. Multi-tenant operators should set - # `required` to make an un-sandboxable container a hard boot failure. - CLOACINA_COMPILER_SANDBOX: "preferred" - # CLOACI-I-0105: bwrap needs unprivileged user namespaces, which Docker's - # default seccomp profile blocks. Unconfined seccomp lets bwrap create its - # namespaces — defensible because bwrap's per-build isolation is stronger - # than default-seccomp-with-no-sandbox. K8s: the Helm chart sets the - # equivalent securityContext.seccompProfile=Unconfined on the compiler pod. - security_opt: - - seccomp=unconfined + volumes: + # Persist the cargo registry the two-phase fetch populates, so container + # recreations don't re-download the crate universe (CLOACI-T-0887). + # Shared across compilers — cargo file-locks registry writes. + - cargo-registry:/usr/local/cargo/registry depends_on: postgres: condition: service_healthy @@ -228,8 +239,15 @@ services: - "/workspace/target" - "--cargo-flag=build" - "--cargo-flag=--lib" + # Two-phase build + dev-workspace hatch (CLOACI-T-0887) — see the + # public `compiler` service. + - "--dev-workspace" + - "/workspace" environment: CARGO_PROFILE_DEV_DEBUG: "0" + volumes: + # Shared fetch cache — see the public `compiler` service (CLOACI-T-0887). + - cargo-registry:/usr/local/cargo/registry depends_on: postgres: condition: service_healthy @@ -275,6 +293,10 @@ services: - "/workspace/target-x86" - "--cargo-flag=build" - "--cargo-flag=--lib" + # Two-phase build + dev-workspace hatch (CLOACI-T-0887) — see the + # public `compiler` service. + - "--dev-workspace" + - "/workspace" environment: CARGO_PROFILE_DEV_DEBUG: "0" depends_on: @@ -511,3 +533,5 @@ volumes: pgdata: fixtures-pkgs: kafkadata: + # Cargo registry populated by the compilers' two-phase fetch (CLOACI-T-0887). + cargo-registry: diff --git a/docs/content/reference/environment-variables.md b/docs/content/reference/environment-variables.md index 0e33e396b..d1866bd4f 100644 --- a/docs/content/reference/environment-variables.md +++ b/docs/content/reference/environment-variables.md @@ -382,7 +382,6 @@ Exposed on `localhost:9092`. | Variable | Purpose | Default | Component | Notes | |----------|---------|---------|-----------|-------| -| `CLOACINA_COMPILER_SANDBOX` | Process-isolation posture for the build sandbox. `required` = builds run under the bwrap namespace sandbox (level 1) or the compiler **refuses to start**; `preferred` = use the best available level, logging any downgrade loudly; `off` = no process sandbox (dev laptops only). Validated **fail-closed at boot** — an invalid value or a `required` selection with no bwrap available is a fatal boot error, never a silent downgrade. See [Compiler Build Sandbox]({{< ref "/service/compiler-sandbox" >}}). | `preferred` | Compiler | The achieved level (bwrap / landlock / none) is recorded on every build's audit row. | | `CLOACINA_COMPILER_BUILD_TIMEOUT_S` | Wall-clock cap on a single cargo build (seconds). | `600` | Compiler | Past timeout, the build is killed and the row is left for the stale-build sweeper to reclaim. | | `CLOACINA_COMPILER_VENDOR_DIR` | `CARGO_HOME` for the cargo subprocess — point it at a curated pre-vendored source tree. | (cargo's usual `~/.cargo` when unset) | Compiler | Combined with `--frozen --offline` so package builds resolve only what the operator has allowed. | | `CLOACINA_COMPILER_BUILD_RLIMIT_*` | Per-build resource caps via `setrlimit` — CPU, memory, FDs, processes. | (binary default) | Compiler | Linux only. The specific variable names mirror `RLIMIT_*` constants. | @@ -441,7 +440,6 @@ Quick reference of all Cloacina-specific environment variables: | `CLOACINA_SERVER` | Agent | Server base URL the agent registers with | | `CLOACINA_API_KEY` | Agent | Agent API key (tenant scope) | | `CLOACINA_AGENT_CACHE_DIR` | Agent | Fetched-cdylib cache directory | -| `CLOACINA_COMPILER_SANDBOX` | Compiler | Build sandbox posture `required` / `preferred` / `off` (default `preferred`; fail-closed at boot) | | `CLOACINA_COMPILER_BUILD_TIMEOUT_S` | Compiler | Per-build wall-clock cap (default `600`) | | `CLOACINA_COMPILER_VENDOR_DIR` | Compiler | `CARGO_HOME` pointed at a curated vendored source tree | | `CLOACINA_COMPILER_BUILD_RLIMIT_*` | Compiler | Per-build setrlimit caps | diff --git a/docs/content/service/compiler-sandbox.md b/docs/content/service/compiler-sandbox.md deleted file mode 100644 index 2e7c5057e..000000000 --- a/docs/content/service/compiler-sandbox.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: "Compiler Build Sandbox" -description: "How cloacina-compiler isolates the attacker-controlled cargo build (build.rs, proc-macros)." -weight: 50 ---- - -# Compiler Build Sandbox - -`cloacina-compiler` compiles uploaded packages, which means it runs -**attacker-controlled code** at build time — `build.rs` and proc-macros -execute on the build host. Phase 1 capped *cost* (rlimits, -`--frozen --offline`, a curated vendor registry). Phase 2 isolates the -*process*. - -## Selecting the mode - -`CLOACINA_COMPILER_SANDBOX` — probed once at boot into the level every build -runs under: - -| Value | Behavior | -|-------|----------| -| `required` | Builds run under **bwrap** or the compiler **refuses to start**. The multi-tenant posture. | -| `preferred` (default) | Best available level; downgrades are logged loudly. | -| `off` | No process sandbox (dev laptops / macOS); logged loudly at boot. | - -The bwrap probe actually *runs* bwrap with the real namespace + mount shape, -so a container that can't create the sandbox correctly **downgrades (or, under -`required`, fails)** at boot rather than passing the probe and then breaking -every build. - -## The isolation ladder - -**Level 1 — bwrap** (namespaced): `--unshare-net` (no network), -`--unshare-user/ipc/uts/cgroup`, `--clearenv` (a `build.rs` cannot read -`DATABASE_URL`), read-only binds for the toolchain + curated registry, -writable binds for **only** the staged source + shared target cache, tmpfs -`/tmp`, RO-bound `/proc`, `--die-with-parent`. - -**Level 2 — landlock** (containers without user namespaces, kernel ≥5.13): -kernel filesystem ACLs — read-only everything, read-write only the build dir + -target cache. No namespace isolation; the environment is still scrubbed. - -Phase-1 rlimits (CPU / address-space / FD / proc ceilings) apply at every -level. Every build's audit row records the **achieved** isolation level, so -forensics can prove what contained a given build. - -## Running the compiler in a container - -bwrap needs unprivileged **user namespaces**, which Docker's default seccomp -profile blocks. To get level 1 in a container, relax seccomp for the compiler: - -```yaml -# docker-compose -services: - compiler: - security_opt: - - seccomp=unconfined - environment: - CLOACINA_COMPILER_SANDBOX: preferred # or `required` for multi-tenant -``` - -On Kubernetes, the `cloacina-server` Helm chart templates a compiler -Deployment (`compiler.enabled=true`) that sets -`securityContext.seccompProfile.type: Unconfined` for you whenever the sandbox -is active, and defaults `CLOACINA_COMPILER_SANDBOX` to `required`: - -```yaml -# values.yaml -compiler: - enabled: true - sandbox: - mode: required # fail-closed; or preferred / off - seccompProfile: Unconfined -``` - -Relaxing seccomp is defensible: bwrap's **per-build** namespace isolation is -stronger than default-seccomp with no build sandbox at all. A container that -still can't run bwrap (no userns even with seccomp relaxed) correctly -downgrades to landlock under `preferred`, or fails to boot under `required`. - -The compiler image must include `bubblewrap` (the demo image does). - -## Verifying - -`cargo test -p cloacina-compiler --lib sandbox` proves the contract at two -levels: - -- **Deterministic (runs everywhere):** `bwrap_command_enforces_isolation_contract` - asserts the bwrap command composition carries the per-namespace unshares - (`--unshare-net` — the security-critical one, plus `--unshare-user`), - `--die-with-parent`, and RO-binds `/proc` rather than mounting a fresh procfs - (`--unshare-all` with a fresh `--proc` mount fails unprivileged in a - container, so the sandbox deliberately does not emit it); that it `--clearenv` - and rebuilds the environment; binds the curated registry read-only; binds - **only** the staged source writable (no other writable host path); and - launches cargo inside the sandbox. `build_env_is_an_allowlist_not_a_denylist` - proves `build_env` is an *allowlist* — a secret set in the parent process - never crosses into the build. `non_bwrap_levels_run_cargo_directly` confirms - the landlock/none levels invoke cargo unwrapped. -- **End-to-end (runs where bwrap works):** `bwrap_denies_host_write` executes a - real sandboxed command that attempts to write outside its staged directory - and asserts the write is denied and leaves no host trace. It **skips** (not - fails) where bwrap is unusable — so the suite is green on macOS/dev while CI - proves the enforcement on Linux. - -The compiler also logs its probed level at boot and stamps `sandbox_level` on -every build audit event. diff --git a/docs/content/service/how-to/running-the-compiler.md b/docs/content/service/how-to/running-the-compiler.md index f8c75f2f8..e74b49f1a 100644 --- a/docs/content/service/how-to/running-the-compiler.md +++ b/docs/content/service/how-to/running-the-compiler.md @@ -28,47 +28,14 @@ can do — read files the compiler can read, contact endpoints reachable from the compiler, exhaust resources the kernel doesn't bound — happens on your infrastructure. -The compiler ships a **kernel-enforced process sandbox** that confines the -cargo subprocess: no host filesystem access beyond the staged source and -target cache, no outbound network, and a scrubbed environment. It is probed -once at boot and applies to every build — see -[Selecting the sandbox mode](#selecting-the-sandbox-mode) below and the full -[Compiler Build Sandbox]({{< ref "/service/compiler-sandbox" >}}) posture. -The sandbox bounds *where* attacker code can reach; the operator -responsibilities below bound *what* and *how much*. Run both. - -## Selecting the sandbox mode - -`CLOACINA_COMPILER_SANDBOX` is probed **once at boot** into the isolation -level every build then runs under: - -| Value | Behavior | -|---|---| -| `required` | Builds run under **bwrap** (level 1) or the compiler **refuses to start** (hard boot failure). The multi-tenant posture. | -| `preferred` (default) | Best available level; downgrades are logged loudly. | -| `off` | No process sandbox (dev laptops / macOS); logged loudly at boot. | - -The isolation ladder: - -- **Level 1 — bwrap** (namespaced): per-namespace unshares - (`--unshare-net` → no network, plus user/ipc/uts/cgroup), `--clearenv` - (a `build.rs` cannot read `DATABASE_URL` from the process environment), - read-only binds for the toolchain + curated registry, writable binds for - **only** the staged source and the shared target cache, tmpfs `/tmp`, and - `--die-with-parent`. -- **Level 2 — landlock** (containers without user namespaces, kernel - ≥5.13): kernel filesystem ACLs — read-only everything, read-write only - the build dir + target cache. No namespace isolation, but the environment - is still scrubbed. - -The bwrap probe actually *runs* bwrap with the real namespace + mount shape, -so a host that can't build the sandbox correctly downgrades (or, under -`required`, fails) at boot rather than passing the probe and then breaking -every build. Every build's audit event records the **achieved** isolation -level. Running in a container needs a relaxed seccomp profile for -unprivileged user namespaces — see -[Compiler Build Sandbox]({{< ref "/service/compiler-sandbox" >}}) for the -full posture (container/seccomp, Helm `compiler.sandbox.mode`, verification). +Cloacina's isolation boundary is the **tenant**: run one compiler per +tenant (`--tenant-schema`) so a tenant's source, build logs, and artifacts +never mix with another's, and size the deployment so a hostile build only +burns that tenant's compiler. Within a compiler, kernel `setrlimit` ceilings +bound resource cost per build (see below), and the wall-clock timeout kills +runaway builds. There is no per-build process sandbox; the operator +responsibilities below bound *what* a build can resolve and *how much* it +can consume. ## Operator responsibilities @@ -94,10 +61,10 @@ compiler needs: ### 2. No outbound network beyond the vendor dir `--frozen --offline` is the default. The cargo subprocess fails fast -on any dep that isn't in the vendor dir. The sandbox's bwrap level -also unshares the network (`--unshare-net`), so the build has no -outbound path at all. As belt-and-suspenders — and for the landlock -fallback level, which does not namespace the network — pair that with +on any dep that isn't in the vendor dir. (Without `--frozen`/`--offline` +the compiler runs a two-phase build instead: `cargo fetch` resolves and +downloads the graph, then the compile runs with `--offline` appended.) +As belt-and-suspenders, pair the curated posture with a host firewall that drops outbound connections from the `cloacina-compiler` UID. @@ -196,7 +163,6 @@ All compiler flags accept env equivalents (`CLOACINA_COMPILER_*`). | Flag | Env | Default | |---|---|---| -| _(env only)_ | `CLOACINA_COMPILER_SANDBOX` | `preferred` (`required` / `off`) | | `--build-timeout-s` | `CLOACINA_COMPILER_BUILD_TIMEOUT_S` | 600 | | `--vendor-dir` | `CLOACINA_COMPILER_VENDOR_DIR` | unset (cargo `~/.cargo`) | | `--cargo-flag` (repeatable) | — | `build --release --lib --frozen --offline` | @@ -211,12 +177,8 @@ All compiler flags accept env equivalents (`CLOACINA_COMPILER_*`). | `--home` | — | `$HOME/.cloacina` | | `--database-url` | `DATABASE_URL` | required | -**Sandbox / tenant / target flags:** +**Tenant / target flags:** -- `CLOACINA_COMPILER_SANDBOX` selects the process-sandbox mode - (`required` / `preferred` / `off`); see - [Selecting the sandbox mode](#selecting-the-sandbox-mode). Env-only — no - matching CLI flag. - `--tenant-schema` scopes the compiler to a single tenant's Postgres schema for build isolation: it claims and builds **only** that tenant's pending packages, with separate source, logs, and target dir (no cross-tenant @@ -299,18 +261,6 @@ grep '"event_type":"compiler.build.finished"' /var/log/cloacina/compiler.log \ | grep '"package_version":"1.2.3"' ``` -## Process sandbox - -The resource ceilings and vendor curation above bound *what* and *how much* -attacker code can do. The **process sandbox** bounds *where* it can reach: -bubblewrap namespace isolation (invisible host filesystem, no outbound -network, scrubbed environment) with a landlock fallback. It is selected by -`CLOACINA_COMPILER_SANDBOX` and probed once at boot — see -[Selecting the sandbox mode](#selecting-the-sandbox-mode) for the summary and -[Compiler Build Sandbox]({{< ref "/service/compiler-sandbox" >}}) for the full -posture (container/seccomp requirements, Kubernetes/Helm wiring, and how to -verify enforcement). - ## Observability `cloacina-compiler` exposes a `/metrics` Prometheus endpoint on the same port as `/health` and `/v1/status` (default `127.0.0.1:9000`). The relevant counters/histograms/gauges are documented in the [Metrics Catalog]({{< ref "/reference/metrics-catalog" >}}#compiler-metrics) — `cloacina_compiler_builds_total{status}`, `cloacina_compiler_queue_depth{state}`, `cloacina_compiler_sweep_resets_total`, `cloacina_compiler_build_duration_seconds`. @@ -323,5 +273,4 @@ Daily-rotated structured logs land in `~/.cloacina/logs/cloacina-compiler.log`. - [Production Deployment]({{< ref "/service/how-to/production-deployment" >}}) — TLS termination for the `cloacinactl server start` server. Separate concern from the compiler. - [Use cloacina-compiler Locally]({{< ref "/service/how-to/use-cloacina-compiler-locally" >}}) — local laptop / CI path, no service. - [Metrics Catalog]({{< ref "/reference/metrics-catalog" >}}) — the full `cloacina_*` and `cloacina_compiler_*` metric surface. -- [Compiler Build Sandbox]({{< ref "/service/compiler-sandbox" >}}) — the full process-sandbox posture: mode selection, the bwrap/landlock ladder, container/seccomp requirements, Helm wiring, and verification. - **ADR-0005** — Deployment-mode trust model (why the compiler is Linux-only, single-tenant build). diff --git a/examples/features/workflows/simple-packaged/Cargo.toml b/examples/features/workflows/simple-packaged/Cargo.toml index f3150237b..e9a0bdb15 100644 --- a/examples/features/workflows/simple-packaged/Cargo.toml +++ b/examples/features/workflows/simple-packaged/Cargo.toml @@ -12,11 +12,15 @@ packaged = [] [lib] crate-type = ["cdylib", "rlib"] +# Production-shaped: a packaged workflow ships its cloacina deps as crates.io +# VERSION deps (what `cloacinactl package new` emits). On dev/e2e stacks the +# compiler's `--dev-workspace` injects `[patch.crates-io]` so these resolve +# against the local unpublished crates until they're published (CLOACI-T-0887). [dependencies] -cloacina-macros = { path = "../../../../crates/cloacina-macros" } -cloacina-computation-graph = { path = "../../../../crates/cloacina-computation-graph" } -cloacina-workflow = { path = "../../../../crates/cloacina-workflow", features = ["packaged"] } -cloacina-workflow-plugin = { path = "../../../../crates/cloacina-workflow-plugin" } +cloacina-macros = "0.10" +cloacina-computation-graph = "0.10" +cloacina-workflow = { version = "0.10", features = ["packaged"] } +cloacina-workflow-plugin = "0.10" serde_json = "1.0" futures = "0.3" # Lightweight async executor for macro-generated code # User workflow dependencies (optional - only needed because this example uses them): @@ -27,8 +31,8 @@ uuid = { version = "1.0", features = ["v4"] } tracing = "0.1" [dev-dependencies] -cloacina = { path = "../../../../crates/cloacina", default-features = false, features = ["macros", "sqlite"] } +cloacina = { version = "0.10", default-features = false, features = ["macros", "sqlite"] } tempfile = "3.0" [build-dependencies] -cloacina-build = { path = "../../../../crates/cloacina-build" } +cloacina-build = "0.10" diff --git a/examples/features/workflows/simple-packaged/README.md b/examples/features/workflows/simple-packaged/README.md index b32197d19..0432d2a14 100644 --- a/examples/features/workflows/simple-packaged/README.md +++ b/examples/features/workflows/simple-packaged/README.md @@ -1,183 +1,125 @@ -# Simple Workflow Package Demo +# Simple Packaged Workflow -This example provides a **complete end-to-end demonstration** of workflow packages in Cloacina, showing the entire lifecycle from development to execution. +**The canonical Cloacina example.** A packaged workflow is the unit of deployment +in Cloacina: you author tasks in Rust, pack the source into a `.cloacina` +archive, and hand it to a running server — which compiles it, registers it, and +executes it. Everything happens through the primary interface: -## 🎯 What This Demonstrates +``` +pack → upload → compile → reconcile → execute → observe +``` + +This example implements a three-task data processing pipeline: + +``` +collect_data → process_data → generate_report +``` -### Complete Workflow Package Lifecycle: -1. **📝 Define** - Create workflow with `#[workflow]` macro -2. **🏗️ Compile** - Build to shared library (`.so`/`.dylib`/`.dll`) -3. **📦 Package** - Create distributable `.cloacina` archive -4. **🔄 Load** - Dynamically load via workflow registry -5. **⚡ Execute** - Run tasks through scheduler with dependency resolution -6. **📊 Monitor** - Track execution progress and results +## Layout -### Key Features Showcased: -- **Namespace Isolation** - Tasks isolated under `tenant::package::workflow::task` -- **Dependency Resolution** - Automatic task ordering based on dependencies -- **Context Data Flow** - Data passing between tasks via execution context -- **Error Handling** - Retry policies and graceful error recovery -- **FFI Exports** - Standard C-compatible interface for dynamic loading +| File | Role | +|---|---| +| `package.toml` | Package manifest — name, version, and the workflow it exposes (`data_processing`) | +| `Cargo.toml` | Ordinary crate manifest; cloacina deps are crates.io version deps | +| `src/lib.rs` | The workflow: `#[workflow]` module with three `#[task]` functions | +| `build.rs` | `cloacina-build` boilerplate (emits the plugin interface) | -## 🚀 Quick Start +This is exactly the shape `cloacinactl package new ` scaffolds — start +new packages from there rather than copying this directory. -```bash -# 1. Build the workflow package -cargo build --release +## Run it -# 2. See the compilation process -cargo run --example package_workflow +Everything below is also automated as `angreal demos features simple-packaged` +(the CI examples lane runs exactly that) — the steps here are the same flow, +by hand, against the demo stack. -# 3. Run the complete end-to-end demo -cargo run --example end_to_end_demo +### 1. Bring up the stack -# 4. Run the tests -cargo test +```bash +angreal ui up ``` -## 📋 Example Workflow +Server + web UI come up at with a seeded `public` +tenant. (Any running Cloacina server works; the demo stack is just the +batteries-included one.) -The demo implements a simple **Data Processing Pipeline**: +### 2. Point the CLI at it -``` -collect_data → process_data → generate_report +```bash +cloacinactl config profile set demo http://localhost:8080 \ + --api-key clk_demo_public_key_0003 --tenant public --default ``` -### Tasks: -- **`collect_data`** - Simulates gathering data from external sources -- **`process_data`** - Validates and transforms the collected data -- **`generate_report`** - Creates summary report from processed data +### 3. Pack the source -### Data Flow: -``` -raw_data → processed_data → final_report +```bash +cloacinactl package pack . --out simple-packaged-demo.cloacina ``` -## 🔧 Real-World Usage - -### Dependencies +The archive contains **source**, not binaries — the server's compiler builds it +for the fleet's architectures. -Workflow packages only need `cloacina-workflow`: +### 4. Upload -```toml -[dependencies] -cloacina-workflow = "0.2" # Includes macros by default -serde_json = "1.0" -tokio = { version = "1.0", features = ["full"] } +```bash +cloacinactl package upload simple-packaged-demo.cloacina ``` -### Development: -```rust -use cloacina_workflow::{workflow, task, Context, TaskError}; - -#[workflow( - name = "data_processing", - package = "simple_demo", - description = "Data processing workflow", - author = "Your Team" -)] -pub mod data_processing { - #[task] - pub async fn collect_data(context: &mut Context) -> Result<(), TaskError> { - // Implementation - } -} -``` +The server registers the package and queues a build. Watch it go +`pending → building → success`: -### Compilation: ```bash -# Build as shared library -cargo build --release --target x86_64-unknown-linux-gnu - -# Create distributable package -cloacina-ctl package build . -# → Generates: simple_demo.cloacina +cloacinactl package list ``` -### Deployment: -```rust -// Load package into registry -let package_data = std::fs::read("simple_demo.cloacina")?; -let package_id = registry.register_workflow(package_data).await?; +Once the build succeeds, the reconciler loads the workflow and +`data_processing` becomes executable. -// Schedule workflow execution -scheduler.schedule_workflow("data_processing", context).await?; +### 5. Execute + +```bash +cloacinactl workflow run data_processing ``` -## 🏗️ Architecture +### 6. Observe -### Workflow Package Structure: -``` -simple_demo.cloacina -├── metadata.json # Package information -├── lib/ -│ └── libsimple_demo.so # Compiled workflow -└── manifest.toml # Task definitions +```bash +cloacinactl execution list --workflow data_processing +cloacinactl execution status ``` -### Runtime Architecture: -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ .cloacina │───▶│ Workflow │───▶│ Task │ -│ Package │ │ Registry │ │ Scheduler │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ Dynamic │ │ Thread Task │ - │ Loader │ │ Executor │ - └─────────────────┘ └─────────────────┘ +Or watch the run in the web UI at — executions, task +states, and the workflow DAG are all there. + +## How the workflow is authored + +`src/lib.rs` declares the pipeline with the workflow macro; dependencies +between tasks are declared per-task and the engine derives the execution +order: + +```rust +#[workflow(name = "data_processing", description = "...", author = "...")] +pub mod data_processing { + #[task(retry_attempts = 2)] + pub async fn collect_data(ctx: &mut Context) -> Result<(), TaskError> { ... } + + #[task(dependencies = ["collect_data"], retry_attempts = 3)] + pub async fn process_data(ctx: &mut Context) -> Result<(), TaskError> { ... } + + #[task(dependencies = ["process_data"])] + pub async fn generate_report(ctx: &mut Context) -> Result<(), TaskError> { ... } +} ``` -## 🎯 Key Benefits - -### For Developers: -- **Independent Development** - Teams can develop workflows separately -- **Language Agnostic** - Standard C ABI enables any language -- **Version Control** - Code fingerprinting for integrity verification -- **Testing** - Unit test individual tasks and full workflows - -### For Operations: -- **Horizontal Scaling** - Deploy packages to multiple executors -- **Zero Downtime** - Hot-swap workflows without stopping executors -- **Multi-Tenancy** - Isolate workflows by tenant namespace -- **Observability** - Built-in monitoring and logging - -### For Organizations: -- **Workflow Reuse** - Share packages across teams and projects -- **Dependency Management** - Clear task dependency definitions -- **Compliance** - Audit trail for all workflow executions -- **Resource Efficiency** - Shared infrastructure for all workflows - -## 📊 Production Considerations - -### Performance: -- **Lazy Loading** - Workflows loaded on-demand -- **Connection Pooling** - Efficient database resource usage -- **Parallel Execution** - Independent tasks run concurrently -- **Memory Management** - Automatic cleanup of completed workflows - -### Security: -- **Namespace Isolation** - Tenants cannot access each other's data -- **Code Signing** - Verify package integrity before loading -- **Permission Control** - Fine-grained access controls -- **Audit Logging** - Complete execution history - -### Monitoring: -- **Execution Metrics** - Task duration, success rates, error counts -- **Resource Usage** - Memory, CPU, database connections -- **Dependency Tracking** - Understand workflow bottlenecks -- **Alerting** - Automated notifications for failures - -## 🔗 Related Examples - -- `examples/tutorial-*` - Basic workflow development -- `examples/multi_tenant` - Multi-tenancy without packaging -- `examples/cron-scheduling` - Time-based workflow execution -- `examples/registry-execution-demo` - Advanced registry usage - -## 📚 Further Reading - -- [Workflow Package Architecture](../../docs/architecture/packaged-workflows.md) -- [Deployment Guide](../../docs/deployment/packaged-workflows.md) -- [Best Practices](../../docs/best-practices/workflow-design.md) +The task id is the function name. Data flows between tasks through the +execution `Context` (`ctx.insert(...)` / `ctx.get(...)`); retry policy is +declared on the `#[task]` attributes. + +## A note on dependencies + +`Cargo.toml` declares the cloacina crates as **crates.io version deps** +(`cloacina-workflow = "0.10"`) — the form real distributed packages ship. +Development stacks (the demo compose stack, the `angreal test e2e compiler +--version-deps` harness) resolve these against the local workspace via the +compiler's `--dev-workspace` flag; production compilers resolve them from +crates.io. Nothing about the package changes between the two. diff --git a/examples/tutorials/python/workflows/06_multi_tenancy.py b/examples/tutorials/python/workflows/06_multi_tenancy.py index 8ecca2a30..ddf4bc4eb 100644 --- a/examples/tutorials/python/workflows/06_multi_tenancy.py +++ b/examples/tutorials/python/workflows/06_multi_tenancy.py @@ -23,6 +23,7 @@ PostgreSQL database running (see docker-compose.yaml in project root) """ +import os import sys import cloaca import random @@ -329,8 +330,12 @@ def simulate_multi_tenant_operations(): """Simulate multi-tenant SaaS operations.""" print("=== Multi-Tenant SaaS Simulation ===") - # PostgreSQL admin connection URL (modify as needed for your setup) - admin_postgres_url = "postgresql://cloacina:cloacina@localhost:5432/cloacina" + # PostgreSQL admin connection URL — honors DATABASE_URL when set (the + # angreal harness points it at the dev stack); modify the fallback as + # needed for your setup. + admin_postgres_url = os.environ.get( + "DATABASE_URL", "postgresql://cloacina:cloacina@localhost:5432/cloacina" + ) # Create tenant manager with admin credentials tenant_manager = TenantManager(admin_postgres_url) diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 88dfe92ca..6da2e7980 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -32,7 +32,7 @@ def get_test_db_url(): backend = os.environ.get("CLOACA_BACKEND", "sqlite").lower() if backend == "postgres": - return "postgresql://cloacina:cloacina@localhost:5432/cloacina" + return "postgresql://cloacina:cloacina@localhost:15432/cloacina" elif backend == "sqlite": # Create a temporary database file for SQLite with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: