From 39bed00334dc1dbc9a8dd1177014c1d843f9005d Mon Sep 17 00:00:00 2001 From: Jacob Lee <66867022+jacobseunglee@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:47:38 -0700 Subject: [PATCH] feat: add local Docker scenario deployment --- README.md | 10 + docs/architecture/v2_code_architecture.md | 2 +- docs/docker_deployment.md | 46 +++ goe/deploy/__init__.py | 1 + goe/deploy/docker.py | 330 ++++++++++++++++++++++ goe/flow/__main__.py | 129 +++++++++ goe/packaging/packager.py | 64 +++-- tests/test_docker_deploy.py | 141 +++++++++ tests/test_packaging.py | 16 ++ tests/test_phase4.py | 6 +- 10 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 docs/docker_deployment.md create mode 100644 goe/deploy/__init__.py create mode 100644 goe/deploy/docker.py create mode 100644 tests/test_docker_deploy.py diff --git a/README.md b/README.md index d8a937b..78b638f 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,18 @@ uv run python -m goe.build --spec tests/fixtures/entities/sqli_express.yaml # Re-test a generated output directory without model calls. uv run goe test output// +# Deploy a validated package persistently to the local Docker daemon. +uv run goe deploy docker output// + +# Inspect or tear down the local deployment. +uv run goe status output// +uv run goe destroy output// + # Run the fast test suite. uv run pytest -m "not docker and not llm" ``` See [the v2 specification](docs/architecture/v2_spec.md), [entity graph model](docs/architecture/entity_graph_model.md), and [code architecture](docs/architecture/v2_code_architecture.md) for the architecture and workflow details. + +See [local Docker deployment](docs/docker_deployment.md) for provisioning behavior, +port exposure, status inspection, and cleanup. diff --git a/docs/architecture/v2_code_architecture.md b/docs/architecture/v2_code_architecture.md index ad7e01f..b063396 100644 --- a/docs/architecture/v2_code_architecture.md +++ b/docs/architecture/v2_code_architecture.md @@ -597,7 +597,7 @@ flowchart TD `RunState` is checkpointed as `output/.checkpoints//state.json` after planning and after each terminal entity outcome, then after L3. It persists the original request, full graph, completed snapshots (deploy script, serialized procedure, outgoing values, attempts), failures, and chain-test result. Resume restores completed entities into the scheduler before new work starts, which re-establishes concrete value propagation. -The package contains `deploy.sh` for a single-system case, or per-system scripts and `docker-compose.yml` for multi-system cases. It always includes `playbook.yaml`; it includes `chain_playbook.yaml` when L3 produced a chain procedure. The replay CLI deploys these outputs into fresh test containers and executes the saved procedure(s), making it the post-package test path without new model calls. +The package contains `deploy.sh` for a single-system case or per-system scripts for a multi-system case, plus `docker-compose.yml` in both cases. It always includes `playbook.yaml`; it includes `chain_playbook.yaml` when L3 produced a chain procedure. The replay CLI deploys these outputs into fresh test containers and executes the saved procedure(s), making it the post-package test path without new model calls. The local Docker deploy lifecycle starts the Compose project persistently, waits for script-backed readiness checks, and records state under `.docker/` for status and teardown. ## 10. Completion conditions and model-call inventory diff --git a/docs/docker_deployment.md b/docs/docker_deployment.md new file mode 100644 index 0000000..4a0a501 --- /dev/null +++ b/docs/docker_deployment.md @@ -0,0 +1,46 @@ +# Local Docker deployment + +GoE packages can be deployed as persistent local environments with Docker Compose v2. +This is separate from `goe test`, which creates a temporary validation environment and +tears it down after the replay finishes. + +## Requirements + +- Docker Engine or Docker Desktop with the `docker compose` command +- Local capacity to run one Ubuntu container per scenario system +- The `goe-attacker:latest` image when the package includes an attacker service and + `solve.sh` (the normal GoE build/test workflow creates this image) + +## Deploy + +```bash +uv run goe deploy docker output// +``` + +The command uses the package's `docker-compose.yml`, waits up to ten minutes for every +system provisioning script to finish, and then leaves the containers running. Override +the timeout or Compose project name when needed: + +```bash +uv run goe deploy docker output// --timeout 1200 +uv run goe deploy docker output// --project-name goe-training-lab +``` + +Each system's declared exposed ports are published with the same host port on +`127.0.0.1` only. Deployment will fail if one of those host ports is already occupied. +Keep these intentionally vulnerable services loopback-only unless you have isolated the +host network and deliberately edit the generated Compose file. + +The deployment record is stored at `output//.docker/manifest.json`. A failed +provisioning attempt is preserved for inspection rather than silently removed. + +## Inspect and remove + +```bash +uv run goe status output// --provider docker +uv run goe destroy output// --provider docker +``` + +`destroy` removes the Compose project's containers, network, and named volumes. It does +not delete the generated output package or Docker images. If an output has only one +deployment provider, `--provider` may be omitted. diff --git a/goe/deploy/__init__.py b/goe/deploy/__init__.py new file mode 100644 index 0000000..5100098 --- /dev/null +++ b/goe/deploy/__init__.py @@ -0,0 +1 @@ +"""Deployment support for packaged GoE scenarios.""" diff --git a/goe/deploy/docker.py b/goe/deploy/docker.py new file mode 100644 index 0000000..1139c7d --- /dev/null +++ b/goe/deploy/docker.py @@ -0,0 +1,330 @@ +"""Persistent local deployment of packaged scenarios with Docker Compose.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +from contextlib import contextmanager +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Callable, Iterator, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +DOCKER_DIRNAME = ".docker" +MANIFEST_FILENAME = "manifest.json" +COMPOSE_FILENAME = "docker-compose.yml" + + +class DockerDeploymentError(RuntimeError): + """A local Docker deployment operation could not complete.""" + + +class DockerDeploymentState(str, Enum): + STARTING = "starting" + READY = "ready" + FAILED = "failed" + DESTROYING = "destroying" + DESTROYED = "destroyed" + + +class DockerDeploymentManifest(BaseModel): + model_config = ConfigDict(strict=True) + + schema_version: Literal[1] = 1 + provider: Literal["docker"] = "docker" + run_id: str + output_dir: str + project_name: str + compose_file: str = COMPOSE_FILENAME + state: DockerDeploymentState + created_at: str + updated_at: str + services: dict[str, dict] = Field(default_factory=dict) + error: str | None = None + + +Progress = Callable[[str], None] + + +def _noop(_: str) -> None: + pass + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def manifest_path(out_dir: Path) -> Path: + return Path(out_dir) / DOCKER_DIRNAME / MANIFEST_FILENAME + + +def _write_manifest(out_dir: Path, manifest: DockerDeploymentManifest) -> None: + manifest.updated_at = _utc_now() + path = manifest_path(out_dir) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(manifest.model_dump(mode="json"), indent=2) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def load_manifest(out_dir: Path) -> DockerDeploymentManifest: + path = manifest_path(out_dir) + if not path.is_file(): + raise FileNotFoundError(f"Docker deployment manifest not found: {path}") + return DockerDeploymentManifest.model_validate_json( + path.read_text(encoding="utf-8"), strict=False + ) + + +def default_project_name(run_id: str) -> str: + """Return a deterministic, Compose-safe project name for a package.""" + slug = re.sub(r"[^a-z0-9_-]+", "-", run_id.lower()).strip("-_") or "scenario" + digest = hashlib.sha256(run_id.encode("utf-8")).hexdigest()[:8] + return f"goe-{slug[:42]}-{digest}" + + +def _validate_project_name(value: str) -> str: + if len(value) > 63 or not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", value): + raise DockerDeploymentError( + "Docker project name must start with a lowercase letter or digit, contain only " + "lowercase letters, digits, hyphens, or underscores, and be at most 63 characters" + ) + return value + + +def _parse_compose_json(value: str) -> list[dict]: + """Accept both JSON-array and line-delimited Compose ``ps`` output.""" + value = value.strip() + if not value: + return [] + try: + parsed = json.loads(value) + except json.JSONDecodeError: + rows = [] + for line in value.splitlines(): + try: + row = json.loads(line) + except json.JSONDecodeError as exc: + raise DockerDeploymentError( + "Docker Compose returned unrecognized service status output" + ) from exc + if isinstance(row, dict): + rows.append(row) + return rows + if isinstance(parsed, list): + return [row for row in parsed if isinstance(row, dict)] + if isinstance(parsed, dict): + return [parsed] + raise DockerDeploymentError("Docker Compose returned unrecognized service status output") + + +class DockerComposeRunner: + """Small subprocess wrapper around the Docker Compose v2 CLI.""" + + def __init__(self, compose_file: Path, project_name: str): + self.compose_file = Path(compose_file).resolve() + self.project_name = project_name + self.cwd = self.compose_file.parent + + @property + def _base_command(self) -> list[str]: + return [ + "docker", + "compose", + "--project-name", + self.project_name, + "--file", + str(self.compose_file), + ] + + def _run(self, args: list[str], *, timeout: int | None = None) -> subprocess.CompletedProcess: + try: + result = subprocess.run( + self._base_command + args, + cwd=self.cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise DockerDeploymentError( + "Docker CLI was not found; install Docker Desktop or Docker Engine with Compose v2" + ) from exc + except subprocess.TimeoutExpired as exc: + raise DockerDeploymentError("Docker Compose operation timed out") from exc + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise DockerDeploymentError(detail or "Docker Compose command failed") + return result + + def check_available(self) -> None: + self._run(["version"], timeout=30) + + def up(self, timeout: int) -> None: + self._run( + ["up", "--detach", "--wait", "--wait-timeout", str(timeout)], + timeout=timeout + 30, + ) + + def status(self) -> list[dict]: + result = self._run(["ps", "--all", "--format", "json"], timeout=30) + return _parse_compose_json(result.stdout) + + def down(self) -> None: + self._run(["down", "--remove-orphans", "--volumes"], timeout=120) + + +RunnerFactory = Callable[[Path, str], DockerComposeRunner] + + +@contextmanager +def _operation_lock(out_dir: Path) -> Iterator[None]: + lock = Path(out_dir) / DOCKER_DIRNAME / "operation.lock" + lock.parent.mkdir(parents=True, exist_ok=True) + try: + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError as exc: + raise DockerDeploymentError( + f"another Docker deployment operation may be running ({lock}); remove the lock " + "only after confirming no goe process is active" + ) from exc + try: + os.write(descriptor, f"pid={os.getpid()}\n".encode()) + os.close(descriptor) + yield + finally: + try: + lock.unlink() + except FileNotFoundError: + pass + + +def _service_map(rows: list[dict]) -> dict[str, dict]: + services: dict[str, dict] = {} + for row in rows: + service = str(row.get("Service") or row.get("Name") or "unknown") + services[service] = row + return services + + +def deploy( + out_dir: Path, + *, + project_name: str | None = None, + timeout: int = 600, + progress: Progress = _noop, + runner_factory: RunnerFactory = DockerComposeRunner, +) -> DockerDeploymentManifest: + """Start a packaged scenario locally and wait for provisioning to finish.""" + out_dir = Path(out_dir).resolve() + if timeout < 1: + raise DockerDeploymentError("Docker deployment timeout must be at least one second") + compose_file = out_dir / COMPOSE_FILENAME + if not compose_file.is_file(): + raise DockerDeploymentError( + f"Docker Compose package not found: {compose_file}; regenerate the scenario package" + ) + + name = _validate_project_name(project_name or default_project_name(out_dir.name)) + with _operation_lock(out_dir): + path = manifest_path(out_dir) + if path.exists(): + existing = load_manifest(out_dir) + if existing.state != DockerDeploymentState.DESTROYED: + raise DockerDeploymentError( + f"output already has a Docker deployment in state {existing.state.value!r}; " + f"run 'goe status {out_dir} --provider docker' or " + f"'goe destroy {out_dir} --provider docker'" + ) + + now = _utc_now() + manifest = DockerDeploymentManifest( + run_id=out_dir.name, + output_dir=str(out_dir), + project_name=name, + state=DockerDeploymentState.STARTING, + created_at=now, + updated_at=now, + ) + _write_manifest(out_dir, manifest) + runner = runner_factory(compose_file, name) + try: + progress("Checking Docker Compose") + runner.check_available() + progress("Starting and provisioning scenario containers") + runner.up(timeout) + rows = runner.status() + if not rows: + raise DockerDeploymentError("Docker Compose started no scenario services") + manifest.services = _service_map(rows) + manifest.state = DockerDeploymentState.READY + manifest.error = None + _write_manifest(out_dir, manifest) + return manifest + except Exception as exc: + error = exc if isinstance(exc, DockerDeploymentError) else DockerDeploymentError(str(exc)) + manifest.state = DockerDeploymentState.FAILED + manifest.error = str(error) + try: + manifest.services = _service_map(runner.status()) + except Exception: + pass + _write_manifest(out_dir, manifest) + raise error + + +def status( + out_dir: Path, + *, + runner_factory: RunnerFactory = DockerComposeRunner, +) -> tuple[DockerDeploymentManifest, list[dict]]: + """Return the saved deployment and current Compose service state.""" + out_dir = Path(out_dir).resolve() + manifest = load_manifest(out_dir) + if manifest.state == DockerDeploymentState.DESTROYED: + return manifest, [] + runner = runner_factory(out_dir / manifest.compose_file, manifest.project_name) + runner.check_available() + return manifest, runner.status() + + +def destroy( + out_dir: Path, + *, + progress: Progress = _noop, + runner_factory: RunnerFactory = DockerComposeRunner, +) -> DockerDeploymentManifest: + """Remove a local scenario's containers, networks, and named volumes.""" + out_dir = Path(out_dir).resolve() + with _operation_lock(out_dir): + manifest = load_manifest(out_dir) + if manifest.state == DockerDeploymentState.DESTROYED: + return manifest + manifest.state = DockerDeploymentState.DESTROYING + _write_manifest(out_dir, manifest) + runner = runner_factory(out_dir / manifest.compose_file, manifest.project_name) + try: + progress("Removing scenario containers and network") + runner.check_available() + runner.down() + except Exception as exc: + error = exc if isinstance(exc, DockerDeploymentError) else DockerDeploymentError(str(exc)) + manifest.state = DockerDeploymentState.FAILED + manifest.error = str(error) + _write_manifest(out_dir, manifest) + raise error + manifest.state = DockerDeploymentState.DESTROYED + manifest.services = {} + manifest.error = None + _write_manifest(out_dir, manifest) + return manifest diff --git a/goe/flow/__main__.py b/goe/flow/__main__.py index 5b7508d..a3b47b6 100644 --- a/goe/flow/__main__.py +++ b/goe/flow/__main__.py @@ -67,12 +67,141 @@ def main() -> None: "or just port for 1:1 mapping. Auto-detects if none given.", ) + deploy_p = sub.add_parser("deploy", help="Deploy an existing output package") + deploy_sub = deploy_p.add_subparsers(dest="provider", required=True) + docker_p = deploy_sub.add_parser( + "docker", help="Deploy scenario containers to the local Docker daemon" + ) + docker_p.add_argument("out_dir", type=Path, help="Output package or run ID") + docker_p.add_argument( + "--project-name", + default=None, + help="Docker Compose project name (generated from the run ID when omitted)", + ) + docker_p.add_argument( + "--timeout", + type=int, + default=600, + help="Seconds to wait for provisioning to finish (default: 600)", + ) + + status_p = sub.add_parser("status", help="Show local Docker deployment status") + status_p.add_argument("deployment", type=Path, help="Output package or run ID") + status_p.add_argument( + "--provider", choices=("docker",), default=None, + help="Deployment provider (optional on this branch)", + ) + + destroy_p = sub.add_parser("destroy", help="Destroy a local Docker deployment") + destroy_p.add_argument("deployment", type=Path, help="Output package or run ID") + destroy_p.add_argument( + "--provider", choices=("docker",), default=None, + help="Deployment provider (optional on this branch)", + ) + destroy_p.add_argument("--yes", action="store_true", help="Destroy without prompting") + args = parser.parse_args() if args.command == "run": _run(args) elif args.command == "test": _test(args) + elif args.command == "deploy": + _deploy_docker(args) + elif args.command == "status": + _deployment_status(args) + elif args.command == "destroy": + _destroy_deployment(args) + + +def _resolve_deployment_dir(value: Path) -> Path: + direct = Path(value).expanduser() + if direct.is_dir(): + return direct.resolve() + by_run_id = Path("output") / direct + if by_run_id.is_dir(): + return by_run_id.resolve() + return direct.resolve() + + +def _docker_deployment_progress(message: str) -> None: + print(f"[docker] {message}...") + + +def _deploy_docker(args) -> None: + from goe.deploy.docker import DockerDeploymentError, deploy + + out_dir = _resolve_deployment_dir(args.out_dir) + try: + manifest = deploy( + out_dir, + project_name=args.project_name, + timeout=args.timeout, + progress=_docker_deployment_progress, + ) + except (DockerDeploymentError, FileNotFoundError, ValueError) as exc: + print(f"Docker deployment failed: {exc}", file=sys.stderr) + if (out_dir / ".docker" / "manifest.json").is_file(): + print( + f"State was preserved. Inspect with: goe status {out_dir} --provider docker", + file=sys.stderr, + ) + print( + f"Clean up with: goe destroy {out_dir} --provider docker", + file=sys.stderr, + ) + sys.exit(1) + + print("\nDocker deployment ready") + print(f" Project: {manifest.project_name}") + for service, current in manifest.services.items(): + state = current.get("State", "unknown") + health = current.get("Health") or "n/a" + print(f" {service}: {state}, health={health}") + print(f" Status: goe status {out_dir} --provider docker") + print(f" Teardown: goe destroy {out_dir} --provider docker") + + +def _deployment_status(args) -> None: + from goe.deploy.docker import DockerDeploymentError, status + + out_dir = _resolve_deployment_dir(args.deployment) + try: + manifest, services = status(out_dir) + except (DockerDeploymentError, FileNotFoundError, RuntimeError) as exc: + print(f"Unable to read Docker deployment status: {exc}", file=sys.stderr) + sys.exit(1) + print(f"Docker deployment {manifest.run_id}: {manifest.state.value}") + print(f" Project: {manifest.project_name}") + if manifest.error: + print(f" Error: {manifest.error}") + for current in services: + service = current.get("Service") or current.get("Name") or "unknown" + state = current.get("State", "unknown") + health = current.get("Health") or "n/a" + print(f" {service}: {state}, health={health}") + + +def _destroy_deployment(args) -> None: + from goe.deploy.docker import DockerDeploymentError, destroy, load_manifest + + out_dir = _resolve_deployment_dir(args.deployment) + try: + manifest = load_manifest(out_dir) + except (FileNotFoundError, ValueError) as exc: + print(f"Unable to load Docker deployment: {exc}", file=sys.stderr) + sys.exit(1) + if not args.yes: + print(f"Destroy local Docker deployment {manifest.run_id} ({manifest.project_name})?") + if input("Continue? [y/N]: ").strip().lower() not in {"y", "yes"}: + print("Destroy cancelled.") + return + try: + result = destroy(out_dir, progress=_docker_deployment_progress) + except (DockerDeploymentError, FileNotFoundError, RuntimeError) as exc: + print(f"Docker destroy failed: {exc}", file=sys.stderr) + sys.exit(1) + print(f"Docker deployment {result.run_id} is {result.state.value}.") def _run(args) -> None: diff --git a/goe/packaging/packager.py b/goe/packaging/packager.py index ae13477..f0feede 100644 --- a/goe/packaging/packager.py +++ b/goe/packaging/packager.py @@ -1,11 +1,11 @@ """Packager — assemble built entities into a self-contained deploy package. Single-system: all entities deploy onto one box → one ``deploy.sh``, one -``playbook.yaml``, one ``README.md``. +``docker-compose.yml``, one ``playbook.yaml``, one ``README.md``. Multi-system: entities grouped by system_id → per-system ``_deploy.sh`` -files, a ``docker-compose.yml`` (one ubuntu:22.04 service per system on a shared -``goe_net`` network), and the same ``playbook.yaml`` / ``README.md``. +files, a ``docker-compose.yml`` (one ubuntu:22.04 service per system on an +isolated project network), and the same ``playbook.yaml`` / ``README.md``. When a chain procedure is provided (from the L3 chain test), it is also written to ``chain_playbook.yaml`` regardless of single- vs multi-system. @@ -75,10 +75,10 @@ def _build_docker_compose( per_system_scripts: dict[str, str], include_attacker: bool = False, ) -> str: - """Generate a docker-compose.yml for a multi-system run. + """Generate a docker-compose.yml for a local scenario deployment. Each system becomes a service running ubuntu:22.04. Its deploy script is - embedded as an inline command so ``docker-compose up`` fully configures it. + embedded as an inline command so ``docker compose up`` fully configures it. When ``include_attacker`` is set, a Kali ``attacker`` service (with ``solve.sh`` mounted at ``/goe/solve.sh``) is added on the same network so the solve is @@ -94,13 +94,19 @@ def _build_docker_compose( b64 = base64.b64encode(script.encode()).decode("ascii") if script else "" deploy_cmd = ( - f"bash -c 'echo {b64} | base64 -d > /deploy.sh && bash /deploy.sh && sleep infinity'" - if b64 else "sleep infinity" + "bash -c '" + f"echo {b64} | base64 -d > /deploy.sh && " + "chmod +x /deploy.sh && bash /deploy.sh && " + "touch /tmp/goe-deploy-ready && exec sleep infinity'" + if b64 + else "bash -c 'touch /tmp/goe-deploy-ready && exec sleep infinity'" ) port_mappings: list[str] = [] for p in system.network.exposed_ports: - port_mappings.append(f"{p}:{p}") + # These scenarios are intentionally vulnerable. A local deployment + # must not become reachable from the LAN merely by starting it. + port_mappings.append(f"127.0.0.1:{p}:{p}") svc: dict = { "image": "ubuntu:22.04", @@ -112,6 +118,12 @@ def _build_docker_compose( }, "command": deploy_cmd, "environment": ["DEBIAN_FRONTEND=noninteractive"], + "healthcheck": { + "test": ["CMD", "test", "-f", "/tmp/goe-deploy-ready"], + "interval": "2s", + "timeout": "1s", + "retries": 3600, + }, } if port_mappings: svc["ports"] = port_mappings @@ -129,11 +141,9 @@ def _build_docker_compose( } compose: dict = { - "version": "3.8", "services": services, "networks": { "default": { - "name": "goe_net", "driver": "bridge", } }, @@ -221,7 +231,7 @@ def _build_readme( "Start all systems:", "", "```bash", - "docker-compose up -d", + "goe deploy docker .", "```", "", "Or deploy each system manually:", @@ -239,10 +249,10 @@ def _build_readme( lines += [ "## Running", "", - "Deploy the full single-box environment:", + "Deploy the full single-box environment in Docker:", "", "```bash", - "bash deploy.sh", + "goe deploy docker .", "```", "", "Attack steps for each entity are in `playbook.yaml` " @@ -290,7 +300,8 @@ def package( ) -> Path: """Assemble PASSED entities into a self-contained package under ``out_dir``. - Single-system: writes ``deploy.sh``, ``playbook.yaml``, ``README.md``. + Single-system: writes ``deploy.sh``, ``docker-compose.yml``, ``playbook.yaml``, + ``README.md``. Multi-system: writes ``_deploy.sh`` per system, ``docker-compose.yml``, ``playbook.yaml``, ``README.md``. @@ -348,18 +359,6 @@ def package( deploy_path.write_text(combined, encoding="utf-8") deploy_path.chmod(0o755) - # Per-system scripts dict for docker-compose generation - per_system_scripts: dict[str, str] = {} - for system in graph.systems: - sid = system.id - deploy_path = out_dir / f"{sid}_deploy.sh" - if deploy_path.exists(): - per_system_scripts[sid] = deploy_path.read_text(encoding="utf-8") - - (out_dir / "docker-compose.yml").write_text( - _build_docker_compose(graph, per_system_scripts, include_attacker=solve_written), - encoding="utf-8", - ) else: # Single-system: one combined deploy.sh (unchanged from Phase 3) deploy_sh = _build_deploy_sh(graph, built, order) @@ -367,6 +366,19 @@ def package( deploy_path.write_text(deploy_sh, encoding="utf-8") deploy_path.chmod(0o755) + # Every package gets the same persistent local deployment path. Scripts are + # embedded in the Compose model so the package stays self-contained. + per_system_scripts: dict[str, str] = {} + for system in graph.systems: + script_name = f"{system.id}_deploy.sh" if multi else "deploy.sh" + deploy_path = out_dir / script_name + if deploy_path.exists(): + per_system_scripts[system.id] = deploy_path.read_text(encoding="utf-8") + (out_dir / "docker-compose.yml").write_text( + _build_docker_compose(graph, per_system_scripts, include_attacker=solve_written), + encoding="utf-8", + ) + # Per-entity playbook (always) (out_dir / "playbook.yaml").write_text( _build_playbook(built, order), encoding="utf-8" diff --git a/tests/test_docker_deploy.py b/tests/test_docker_deploy.py new file mode 100644 index 0000000..cb2bfdf --- /dev/null +++ b/tests/test_docker_deploy.py @@ -0,0 +1,141 @@ +"""Unit tests for persistent local Docker deployment (no daemon required).""" + +from __future__ import annotations + +import json + +import pytest + +from goe.deploy.docker import ( + DockerDeploymentError, + DockerDeploymentState, + default_project_name, + deploy, + destroy, + load_manifest, + status, +) + + +class FakeComposeRunner: + instances = [] + fail_up = False + rows = [ + { + "Service": "web", + "Name": "project-web-1", + "State": "running", + "Health": "healthy", + } + ] + + def __init__(self, compose_file, project_name): + self.compose_file = compose_file + self.project_name = project_name + self.checked = False + self.started = False + self.removed = False + type(self).instances.append(self) + + def check_available(self): + self.checked = True + + def up(self, timeout): + self.started = True + self.timeout = timeout + if type(self).fail_up: + raise DockerDeploymentError("provisioning failed") + + def status(self): + return list(type(self).rows) + + def down(self): + self.removed = True + + +@pytest.fixture(autouse=True) +def _reset_runner(): + FakeComposeRunner.instances = [] + FakeComposeRunner.fail_up = False + FakeComposeRunner.rows = [ + { + "Service": "web", + "Name": "project-web-1", + "State": "running", + "Health": "healthy", + } + ] + + +def _package(tmp_path): + out = tmp_path / "Run With Spaces" + out.mkdir() + (out / "docker-compose.yml").write_text( + "services:\n web:\n image: ubuntu:22.04\n", encoding="utf-8" + ) + return out + + +def test_deploy_waits_for_compose_and_persists_ready_manifest(tmp_path): + out = _package(tmp_path) + + manifest = deploy(out, timeout=42, runner_factory=FakeComposeRunner) + + runner = FakeComposeRunner.instances[-1] + assert runner.checked is True + assert runner.started is True + assert runner.timeout == 42 + assert manifest.state == DockerDeploymentState.READY + assert manifest.project_name == default_project_name(out.name) + assert manifest.services["web"]["Health"] == "healthy" + assert load_manifest(out).state == DockerDeploymentState.READY + + +def test_deploy_rejects_an_active_manifest(tmp_path): + out = _package(tmp_path) + deploy(out, runner_factory=FakeComposeRunner) + + with pytest.raises(DockerDeploymentError, match="already has a Docker deployment"): + deploy(out, runner_factory=FakeComposeRunner) + + +def test_failed_deploy_records_partial_state_for_cleanup(tmp_path): + out = _package(tmp_path) + FakeComposeRunner.fail_up = True + + with pytest.raises(DockerDeploymentError, match="provisioning failed"): + deploy(out, runner_factory=FakeComposeRunner) + + manifest = load_manifest(out) + assert manifest.state == DockerDeploymentState.FAILED + assert manifest.error == "provisioning failed" + assert manifest.services["web"]["State"] == "running" + + +def test_status_and_destroy_use_saved_compose_project(tmp_path): + out = _package(tmp_path) + deployed = deploy( + out, project_name="goe-local-test", runner_factory=FakeComposeRunner + ) + + manifest, rows = status(out, runner_factory=FakeComposeRunner) + assert manifest.project_name == deployed.project_name + assert rows[0]["Service"] == "web" + + result = destroy(out, runner_factory=FakeComposeRunner) + assert FakeComposeRunner.instances[-1].removed is True + assert result.state == DockerDeploymentState.DESTROYED + + +@pytest.mark.parametrize("name", ["Uppercase", "-starts-with-dash", "bad.name"]) +def test_invalid_project_names_are_rejected(tmp_path, name): + out = _package(tmp_path) + with pytest.raises(DockerDeploymentError, match="project name"): + deploy(out, project_name=name, runner_factory=FakeComposeRunner) + + +def test_manifest_is_valid_json(tmp_path): + out = _package(tmp_path) + deploy(out, runner_factory=FakeComposeRunner) + payload = json.loads((out / ".docker" / "manifest.json").read_text()) + assert payload["provider"] == "docker" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5b0b2d3..5ca9b11 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -44,6 +44,22 @@ def test_package_writes_three_files(tmp_path): assert (out / "deploy.sh").exists() assert (out / "playbook.yaml").exists() assert (out / "README.md").exists() + assert (out / "docker-compose.yml").exists() + + +def test_single_system_compose_waits_for_provisioning(tmp_path): + graph = _graph() + built = {"sqli_entity": _outcome("sqli_entity", "echo ready")} + out = package(graph, built, tmp_path / "pkg") + + compose = yaml.safe_load((out / "docker-compose.yml").read_text()) + target = compose["services"]["target_system"] + assert target["healthcheck"]["test"] == [ + "CMD", "test", "-f", "/tmp/goe-deploy-ready" + ] + assert "touch /tmp/goe-deploy-ready" in target["command"] + assert "name" not in compose["networks"]["default"] + assert all(mapping.startswith("127.0.0.1:") for mapping in target["ports"]) def test_deploy_topo_order_and_postprocess(tmp_path): diff --git a/tests/test_phase4.py b/tests/test_phase4.py index 4152b60..3a0f632 100644 --- a/tests/test_phase4.py +++ b/tests/test_phase4.py @@ -227,8 +227,8 @@ def test_port_collision_scoped_per_system(self, tmp_path): # No collision — different systems even if same port assert warnings == [] - def test_single_system_still_emits_deploy_sh(self, tmp_path): - """Single-system path must remain unchanged.""" + def test_single_system_emits_deploy_sh_and_compose(self, tmp_path): + """Single-system packages support both direct scripts and Docker deployment.""" from goe.packaging.packager import package graph = _single_graph() @@ -237,7 +237,7 @@ def test_single_system_still_emits_deploy_sh(self, tmp_path): } out = package(graph, built, tmp_path / "out") assert (out / "deploy.sh").exists() - assert not (out / "docker-compose.yml").exists() + assert (out / "docker-compose.yml").exists() # ---------------------------------------------------------------------------