diff --git a/.kiro/specs/bootstrap-ai-coding/design-architecture.md b/.kiro/specs/bootstrap-ai-coding/design-architecture.md index 8549974..3e128a7 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-architecture.md +++ b/.kiro/specs/bootstrap-ai-coding/design-architecture.md @@ -18,7 +18,7 @@ graph TD BuildResAgent["internal/agents/buildresources\n(pseudo-agent module)"] FutureAgent["internal/agents/other\n(future agent module)"] DockerDaemon["Docker Daemon"] - Container["Container\n(sshd + dev user + enabled agents)"] + Container["Container\n(sshd + container user + enabled agents)"] User -->|"bac [--agents ...]"| CLI CLI --> Naming @@ -36,7 +36,7 @@ graph TD The core packages (`internal/cmd`, `internal/naming`, `internal/docker`, `internal/ssh`, `internal/datadir`, `internal/agent`) have **no import dependency** on any package under `internal/agents/`. Agent modules are wired in exclusively via `main.go` blank imports. -> **Note (Req 28 — Module Consolidation):** The former `internal/credentials` and `internal/portfinder` packages have been merged into `internal/datadir`. Both dealt with per-project persistent state (credential paths, port selection/persistence) and had only `cmd/root.go` as their consumer. Consolidating them reduces package count without introducing import cycles or mixing unrelated concerns. +> **Note (Module Consolidation):** The former `internal/credentials` and `internal/portfinder` packages have been merged into `internal/datadir`. Both dealt with per-project persistent state (credential paths, port selection/persistence) and had only `cmd/root.go` as their consumer. Consolidating them reduces package count without introducing import cycles or mixing unrelated concerns. ### Package Layout @@ -117,7 +117,7 @@ sequenceDiagram else No image or --rebuild CLI->>Docker: Inspect Base_Container_Image for UID/GID conflict (Req 10a) alt Conflicting_Image_User found (existing user has Host_User UID or GID) - CLI->>User: "User '' (UID/GID) already exists in base image. Rename to 'dev'? [y/N]" + CLI->>User: "User '' (UID/GID) already exists in base image. Rename to ''? [y/N]" alt User confirms rename CLI->>CLI: Set user_strategy = rename (use usermod -l in Dockerfile) else User declines diff --git a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md index 151bf54..49f04d5 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md +++ b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md @@ -196,7 +196,7 @@ RUN echo manifest > /bac-manifest.json ← manifest FROM bac-base:latest RUN SSH host key injection ← per-project (core Req 13) RUN SSH authorized_keys ← per-user key (core Req 4) -RUN sshd_config hardening ← stable +RUN sshd_config hardening + Port/ListenAddress ← per-project (Req 26.2) RUN mkdir /run/sshd ← stable CMD ["/usr/sbin/sshd", "-D"] ← always last (Req 21.2) ``` diff --git a/.kiro/specs/bootstrap-ai-coding/design-components.md b/.kiro/specs/bootstrap-ai-coding/design-components.md index de4ef8b..984b58b 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-components.md +++ b/.kiro/specs/bootstrap-ai-coding/design-components.md @@ -322,13 +322,25 @@ func validateRestartPolicy(policy string) error { **Application in `docker/runner.go`** (`CreateContainer`): -The `ContainerSpec.RestartPolicy` field is mapped to the Docker SDK's `container.RestartPolicy` struct in `HostConfig`: +The `ContainerSpec.RestartPolicy` field is mapped to the Docker SDK's `container.RestartPolicy` struct in `HostConfig`. The network mode depends on `ContainerSpec.HostNetworkOff` (Req 26): ```go import "github.com/docker/docker/api/types/container" +// Host network mode (default: HostNetworkOff == false) hostConfig := &container.HostConfig{ - // ... existing port bindings, mounts, etc. + NetworkMode: "host", // Req 26: share host network namespace + Mounts: mounts, + RestartPolicy: container.RestartPolicy{ + Name: container.RestartPolicyMode(spec.RestartPolicy), + }, + // No PortBindings — host network mode makes them unnecessary (Req 26.4) +} + +// Bridge mode (HostNetworkOff == true, i.e. --host-network-off IS set) +hostConfig := &container.HostConfig{ + PortBindings: portBindings, // maps container:22 → host:SSH_Port + Mounts: mounts, RestartPolicy: container.RestartPolicy{ Name: container.RestartPolicyMode(spec.RestartPolicy), }, @@ -343,6 +355,21 @@ hostConfig := &container.HostConfig{ 4. Passes it to `ContainerSpec.RestartPolicy` when constructing the spec 5. `docker/runner.go` applies it in `CreateContainer` +**`--host-network-off` flag (Req 26):** + +```go +rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, + "Disable host network mode; use bridge networking with port mapping") +``` + +**Threading from CLI to runner:** + +1. `cmd/root.go` reads `--host-network-off` flag value (default: `false`) +2. Passes it as the `hostNetworkOff` parameter to `runStart` +3. `runStart` sets `ContainerSpec.HostNetworkOff` when constructing the spec +4. `docker/runner.go` selects `NetworkMode: "host"` or bridge + port bindings in `CreateContainer` +5. `runStart` passes it to `NewInstanceImageBuilder` to control whether sshd_config includes `Port`/`ListenAddress` directives + **Behaviour with `--stop-and-remove`:** When `--stop-and-remove` is used, the container is stopped via `docker stop` (which sends SIGTERM) and then removed. A container with `unless-stopped` policy that was explicitly stopped will NOT restart on reboot — Docker tracks the "stopped by user" state. Removal deletes the container entirely, so there is nothing to restart. diff --git a/.kiro/specs/bootstrap-ai-coding/design-data-models.md b/.kiro/specs/bootstrap-ai-coding/design-data-models.md index 460519f..5b616aa 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-data-models.md +++ b/.kiro/specs/bootstrap-ai-coding/design-data-models.md @@ -43,6 +43,7 @@ type Config struct { NoUpdateKnownHosts bool NoUpdateSSHConfig bool RestartPolicy string // Docker restart policy (default: "unless-stopped") + HostNetworkOff bool // Req 26: when true, use bridge mode instead of host network CredStoreOverrides map[string]string HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity } @@ -52,14 +53,16 @@ type Config struct { ```go type ContainerSpec struct { - Name string - ImageTag string - Dockerfile string - Mounts []Mount - SSHPort int - Labels map[string]string - RestartPolicy string // Req 25: Docker restart policy name - HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) + Name string + ImageTag string + Dockerfile string + Mounts []Mount + SSHPort int + Labels map[string]string + NoCache bool // When true, disable Docker layer cache during image build + HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network + RestartPolicy string // Req 25: Docker restart policy name + HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) } type Mount struct { @@ -93,7 +96,7 @@ type SessionSummary struct { | `--stop-and-remove` and `--purge` both set | CLI-1 | Descriptive error → stderr, exit 1 | | START or STOP mode and `` absent | CLI-2 | Usage message → stderr, exit 1 | | PURGE mode and `` provided | CLI-2 | Descriptive error → stderr, exit 1 | -| STOP or PURGE mode and any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy` set | CLI-3 | Descriptive error naming the incompatible flag(s) → stderr, exit 1 | +| STOP or PURGE mode and any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy`, `--host-network-off` set | CLI-3 | Descriptive error naming the incompatible flag(s) → stderr, exit 1 | | `--port` value outside 1024–65535 | CLI-5 | Descriptive error → stderr, exit 1 | | `--agents` parses to empty list | CLI-6 | Descriptive error → stderr, exit 1 | | `--agents` contains unknown agent ID | CLI-6 | Unknown ID + available IDs → stderr, exit 1 | diff --git a/.kiro/specs/bootstrap-ai-coding/design-docker.md b/.kiro/specs/bootstrap-ai-coding/design-docker.md index 2934c89..1be557f 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-docker.md +++ b/.kiro/specs/bootstrap-ai-coding/design-docker.md @@ -42,8 +42,10 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, // NewInstanceImageBuilder produces the Dockerfile for bac-:latest. // Starts with FROM bac-base:latest, adds only per-project SSH config + CMD. +// When hostNetworkOff is false (default), sshd_config includes Port and ListenAddress +// directives for host network mode. When true, sshd uses default port 22 (bridge mode). func NewInstanceImageBuilder(info *hostinfo.Info, - publicKey, hostKeyPriv, hostKeyPub string) *DockerfileBuilder + publicKey, hostKeyPriv, hostKeyPub string, sshPort int, hostNetworkOff bool) *DockerfileBuilder ``` The existing `NewDockerfileBuilder` is replaced by these two functions. Agent `Install()` methods are called on the base builder only. The instance builder has no agent steps — it's just SSH key injection + CMD. @@ -116,13 +118,36 @@ When `--rebuild` is set: 3. Build Instance_Image (inherits fresh base) 4. Create and start new container +### `--host-network-off` and Instance_Image + +The `--host-network-off` flag (Req 26) affects the Instance_Image content: + +- **Default (host network mode):** `NewInstanceImageBuilder` appends `Port ` and `ListenAddress 127.0.0.1` to sshd_config. The container is created with `NetworkMode: "host"` and no port bindings. +- **`--host-network-off` set (bridge mode):** `NewInstanceImageBuilder` omits the `Port` and `ListenAddress` directives — sshd uses its default port 22. The container is created with bridge networking and Docker port bindings (`127.0.0.1:` → container port 22). + +Changing `--host-network-off` between invocations produces a different Instance_Image (different sshd_config). The CLI detects this mismatch and requires `--rebuild` to regenerate the Instance_Image. + ### `--stop-and-remove` Behavior No change to image handling. Only the container is stopped/removed. Both Base_Image and Instance_Image remain cached for fast restart. ### `--purge` Behavior -Removes all images (both `bac-base:latest` and all `bac-:latest` instance images) via the existing `bac.managed` label filter. +Image removal proceeds in dependency order: + +1. Remove Instance_Images (have `bac.container` label) — children of Base_Image +2. `ImagesPrune(dangling=true)` — removes untagged intermediate build layers that still reference Base_Image +3. Remove Base_Image (no `bac.container` label) — suppress "No such image" errors (image already removed by prune in step 2) + +Docker refuses to delete a parent image while children exist. Dangling build cache layers count as children. "No such image" errors in step 3 are skipped because the prune already removed those images. + +#### Reasoning + +- **`bac.container` label as partition key:** All bac-managed images carry `bac.managed=true`. Instance_Images additionally carry `bac.container=`. This distinguishes children from parent without inspecting image ancestry or parsing FROM directives at runtime. +- **Dangling prune between steps 1 and 3:** `--rebuild` creates new layers and old Instance_Image layers become dangling (untagged). These still reference `bac-base` as their parent in Docker's image graph. Without pruning, Base_Image removal fails with "image has dependent child images." The `dangling=true` filter only removes untagged, unreferenced images — it cannot remove tagged images from other tools. +- **Suppressing "No such image":** The prune in step 2 may remove images that were in the original image list (captured before removal began). Step 3 then attempts to remove an already-gone image. This is the desired outcome, so the error is skipped. + +**Validates: TL-7** ### Constants Addition diff --git a/.kiro/specs/bootstrap-ai-coding/design-properties.md b/.kiro/specs/bootstrap-ai-coding/design-properties.md index c30845f..2cc2358 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-properties.md +++ b/.kiro/specs/bootstrap-ai-coding/design-properties.md @@ -215,11 +215,11 @@ --- -#### Property 22b: SSH port is always bound to the selected host port +#### Property 22b: SSH port configuration matches the selected network mode -*For any* valid port number, the container spec SHALL contain a port binding mapping container port `constants.ContainerSSHPort/tcp` to that host port. +*For any* valid port number and network mode setting: WHEN host network mode is active, the Instance_Image Dockerfile SHALL contain sshd_config directives setting `Port ` and `ListenAddress 127.0.0.1`, and the container spec SHALL use `NetworkMode: "host"` with no `PortBindings` or `ExposedPorts`. WHEN bridge mode is active, the Instance_Image Dockerfile SHALL NOT contain `Port` or `ListenAddress` directives, and the container spec SHALL contain a port binding mapping container port `constants.ContainerSSHPort/tcp` to `constants.HostBindIP:`. -**Validates: Req 12.4** +**Validates: Req 12.4, Req 26.2, Req 26.4, Req 26.6** --- @@ -291,7 +291,7 @@ #### Property 34: START-only flags in STOP or PURGE mode always produce errors (CLI-3) -*For any* invocation in STOP or PURGE mode where any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, or `--no-update-ssh-config` is set, the CLI SHALL return a non-nil error identifying the incompatible flag(s). +*For any* invocation in STOP or PURGE mode where any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy`, or `--host-network-off` is set, the CLI SHALL return a non-nil error identifying the incompatible flag(s). **Validates: CLI-3** @@ -329,6 +329,14 @@ --- +#### Property 57: --host-network-off controls network mode and sshd_config + +*For any* container creation: WHEN `HostNetworkOff` is `false` (default), the `ContainerSpec` SHALL produce a container with `NetworkMode: "host"` and no port bindings, and the Instance_Image Dockerfile SHALL contain `Port ` and `ListenAddress 127.0.0.1` in sshd_config. WHEN `HostNetworkOff` is `true`, the `ContainerSpec` SHALL produce a container with default bridge networking and port bindings mapping `constants.ContainerSSHPort/tcp` to `constants.HostBindIP:`, and the Instance_Image Dockerfile SHALL NOT contain `Port` or `ListenAddress` directives. + +**Validates: Req 26.1, 26.2, 26.4, 26.6, 26.7, 26.13** + +--- + ### Agent Module Properties #### Property 27: All registered agents satisfy the Agent interface diff --git a/.kiro/specs/bootstrap-ai-coding/design.md b/.kiro/specs/bootstrap-ai-coding/design.md index f9dcf01..4422ee3 100644 --- a/.kiro/specs/bootstrap-ai-coding/design.md +++ b/.kiro/specs/bootstrap-ai-coding/design.md @@ -33,7 +33,7 @@ The design is split across multiple focused files: ## Related Documents -- `requirements-core.md` — core application requirements (Req 1–22, including Req 22: Dynamic Container User Identity) -- `requirements-agents.md` — agent module requirements (CC-1–CC-6 for Claude Code, AC-1–AC-6 for Augment Code) -- `requirements-cli-combinations.md` — valid and invalid CLI flag combinations (CLI-1–CLI-6) +- `requirements-core.md` — core application requirements (Req 1–26, including Req 22: Dynamic Container User Identity, Req 25: Restart Policy, Req 26: Host Network Mode) +- `requirements-agents.md` — agent module requirements (CC-1–CC-8 for Claude Code, AC-1–AC-6 for Augment Code, BR-1–BR-6 for Build Resources) +- `requirements-cli-combinations.md` — valid and invalid CLI flag combinations (CLI-1–CLI-7) - `requirements-two-layer-image.md` — two-layer Docker image requirements (TL-1–TL-11) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md index 0831bef..59c7988 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md @@ -285,7 +285,7 @@ Build Resources is a pseudo-agent that does not provide an AI coding tool. Inste - `uv --version` - `cmake --version` - `javac -version` - - `go version` (executed via `bash -lc` to pick up `/etc/profile.d/golang.sh`) + - `/usr/local/go/bin/go version` 2. THE Health_Check SHALL be invoked by the core after the Container starts. 3. IF any Health_Check command fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Build Resources agent and the specific tool that failed. @@ -311,3 +311,4 @@ Build Resources is a pseudo-agent that does not provide an AI coding tool. Inste 1. THE `constants.DefaultAgents` value SHALL include `"build-resources"` so that the module is enabled by default when the `--agents` flag is omitted. 2. THE user SHALL be able to exclude Build Resources by specifying `--agents` without `build-resources` in the list. + diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md b/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md index e89e431..6d3872d 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md @@ -17,6 +17,7 @@ This document defines which flag combinations are valid, invalid, or redundant. | `C` | `--no-update-ssh-config` | | `V` | `--verbose` | | `D` | `--docker-restart-policy` | +| `H` | `--host-network-off` | | `S` | `--stop-and-remove` | | `U` | `--purge` | @@ -71,11 +72,11 @@ IF START or STOP mode AND `P` is absent THEN THE CLI SHALL print a usage message ### Requirement CLI-3: START-only flags are invalid in STOP and PURGE modes -`A`, `T`, `K`, `R`, `N`, `C`, `V`, and `D` are only meaningful in START mode. They have no effect on STOP or PURGE operations and must not be silently ignored. +`A`, `T`, `K`, `R`, `N`, `C`, `V`, `D`, and `H` are only meaningful in START mode. They have no effect on STOP or PURGE operations and must not be silently ignored. -**Formal:** `(S ∨ U) → ¬(A ∨ T ∨ K ∨ R ∨ N ∨ C ∨ V ∨ D)` +**Formal:** `(S ∨ U) → ¬(A ∨ T ∨ K ∨ R ∨ N ∨ C ∨ V ∨ D ∨ H)` -IF STOP or PURGE mode AND any of `A`, `T`, `K`, `R`, `N`, `C`, `V`, `D` is provided THEN THE CLI SHALL print a descriptive error to stderr identifying the incompatible flag(s) and exit with a non-zero exit code. +IF STOP or PURGE mode AND any of `A`, `T`, `K`, `R`, `N`, `C`, `V`, `D`, `H` is provided THEN THE CLI SHALL print a descriptive error to stderr identifying the incompatible flag(s) and exit with a non-zero exit code. --- @@ -125,33 +126,36 @@ IF `D` is provided AND its value is not one of `no`, `always`, `unless-stopped`, The table below lists all meaningful flag combinations. `✓` = present, `∅` = absent/default, `✗` = forbidden. -| Mode | P | A | T | K | R | N | C | V | D | S | U | Valid? | -|---|---|---|---|---|---|---|---|---|---|---|---|---| -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ minimal start | -| START | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom agents | -| START | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom port | -| START | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom SSH key | -| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip known_hosts update | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ skip SSH config update | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ verbose build output | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ custom restart policy | -| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ force rebuild + verbose | -| START | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ∅ | ∅ | ✓ all start flags | -| STOP | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | -| PURGE | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✗ no mode, no path | -| — | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: A with S | -| — | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: T with S | -| — | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: R with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: N with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: C with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ∅ | ✗ CLI-3: V with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ∅ | ✗ CLI-3: D with S | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: N with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: C with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ✗ CLI-3: V with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ✗ CLI-3: D with U | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ✗ CLI-1: S ∧ U | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-2: P with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-2: S without P | +| Mode | P | A | T | K | R | N | C | V | D | H | S | U | Valid? | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ minimal start | +| START | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom agents | +| START | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom port | +| START | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom SSH key | +| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip known_hosts update | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip SSH config update | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ verbose build output | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ custom restart policy | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ disable host network | +| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild + verbose | +| START | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ∅ | ∅ | ✓ all start flags | +| STOP | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | +| PURGE | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✗ no mode, no path | +| — | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: A with S | +| — | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: T with S | +| — | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: R with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: N with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: C with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: V with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ∅ | ✗ CLI-3: D with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ∅ | ✗ CLI-3: H with S | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: N with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: C with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: V with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ✗ CLI-3: D with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ✗ CLI-3: H with U | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ✗ CLI-1: S ∧ U | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-2: P with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-2: S without P | diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 0cc0303..236d556 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -31,7 +31,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana - **Enabled_Agents**: The set of Agent modules selected by the user for a given Container, specified via CLI flag or tool configuration. - **Credential_Store**: The directory on the Host where an Agent's authentication tokens are persisted between Sessions. Each Agent module declares its own default Credential_Store path via the Agent_Interface. - **Credential_Volume**: The Docker bind-mount that makes an Agent's Credential_Store accessible inside the Container at the path the Agent expects. -- **SSH_Port**: The host-side TCP port mapped to port 22 inside the Container. The CLI selects the SSH_Port by starting at `2222` and incrementing by 1 until a free port is found on the Host. The selected port is persisted in the Tool_Data_Dir for the project so the same port is reused on subsequent runs. Can be overridden per invocation via `--port`. +- **SSH_Port**: The TCP port on which the SSH_Server is reachable from the Host at `127.0.0.1`. In host network mode (Req 26, default), sshd listens directly on this port inside the Container. In bridge mode (`--host-network-off`), Docker maps the Container's port 22 to this port on the Host. The CLI selects the SSH_Port by starting at `2222` and incrementing by 1 until a free port is found on the Host. The selected port is persisted in the Tool_Data_Dir for the project so the same port is reused on subsequent runs. Can be overridden per invocation via `--port`. - **Container_User_Home**: The home directory of the Container_User inside the Container. Defined as the Host_User's home directory path (e.g. `/home/alice`). This ensures absolute paths stored by tools resolve identically inside the Container and on the Host. - **Tool_Data_Dir**: The directory on the Host where the CLI stores all persistent data for a given project (SSH host keys, SSH port assignment, agent manifests). Located at `~/.config/bootstrap-ai-coding//`. - **Known_Hosts_File**: The SSH client's `~/.ssh/known_hosts` file on the Host. @@ -79,8 +79,8 @@ The core application is responsible for all orchestration: Docker lifecycle mana #### Acceptance Criteria -1. WHEN a Container is started, THE SSH_Server SHALL be running and listening on port 22 inside the Container. -2. WHILE the Container is running, THE SSH_Server SHALL accept incoming connections on the SSH_Port. +1. WHEN a Container is started, THE SSH_Server SHALL be running and listening on the SSH_Port. In host network mode (Req 26, default), sshd listens on `127.0.0.1:` directly. In bridge mode (`--host-network-off`), sshd listens on port 22 and Docker maps it to the SSH_Port on the Host. +2. WHILE the Container is running, THE SSH_Server SHALL accept incoming connections on the SSH_Port from localhost. 3. IF the SSH_Server fails to start inside the Container, THEN THE CLI SHALL stop the Container, print a descriptive error message to stderr, and exit with a non-zero exit code. --- @@ -91,7 +91,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana #### Acceptance Criteria -1. THE CLI SHALL read the user's Public_Key from `~/.ssh/id_ed25519.pub`, `~/.ssh/id_rsa.pub`, or a path supplied via a `--ssh-key` option, in that order of precedence. +1. THE CLI SHALL read the user's Public_Key from a path supplied via the `--ssh-key` option (highest precedence), or from `~/.ssh/id_ed25519.pub`, or from `~/.ssh/id_rsa.pub`, in that order of precedence (first found wins). 2. WHEN a Container is started, THE CLI SHALL install the Public_Key into the Container's `~/.ssh/authorized_keys` for the Container_User. 3. WHEN a user connects to the SSH_Server using the corresponding private key, THE SSH_Server SHALL authenticate the connection without prompting for a password. 4. IF no Public_Key can be located, THEN THE CLI SHALL print a descriptive error message to stderr and exit with a non-zero exit code. @@ -142,7 +142,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana 2. THE Agent_Registry SHALL allow Agent modules to register themselves without requiring changes to core system code. Adding a new Agent SHALL require only a new module that implements the Agent_Interface and registers itself. 3. IF an Agent identifier supplied by the user is not found in the Agent_Registry, THEN THE CLI SHALL print a descriptive error message to stderr listing the unknown identifier and exit with a non-zero exit code. 4. THE CLI SHALL accept an `--agents` flag whose value is a comma-separated list of Agent identifiers specifying the Enabled_Agents for the Container. -5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable both `claude-code` and `augment-code` as the default Enabled_Agents (i.e. the default value of `--agents` is `"claude-code,augment-code"`). +5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable `claude-code`, `augment-code`, and `build-resources` as the default Enabled_Agents (i.e. the default value of `--agents` is `constants.DefaultAgents`). See also BR-6 in `requirements-agents.md`. --- @@ -227,7 +227,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana 1. WHEN the CLI starts a Container for the first time, THE CLI SHALL select the SSH_Port by checking port `2222` on the Host; if it is in use, THE CLI SHALL increment by 1 and repeat until a free port is found. 2. THE selected SSH_Port SHALL be persisted in the Tool_Data_Dir for the project so that subsequent invocations reuse the same port. 3. THE CLI SHALL accept a `--port` flag that overrides the automatic SSH_Port selection for the Container; the overridden value SHALL also be persisted in the Tool_Data_Dir. -4. WHEN a Container is started, THE CLI SHALL bind the Container's internal SSH port 22 to the SSH_Port on the Host. +4. WHEN a Container is started, THE SSH_Server inside the Container SHALL be reachable on `127.0.0.1:` from the Host. In host network mode (Req 26), sshd listens directly on that address via sshd_config. In bridge mode, Docker port mapping binds the Container's port 22 to `127.0.0.1:` on the Host. 5. IF the persisted SSH_Port is in use on the Host by a process other than the Container for this project when the Container is started, THE CLI SHALL print a descriptive error message to stderr identifying the port conflict and exit with a non-zero exit code. 6. THE CLI SHALL print the SSH_Port and the full SSH connection command as part of the session summary defined in Requirement 17. @@ -438,3 +438,26 @@ The core application is responsible for all orchestration: Docker lifecycle mana 8. IF the `--docker-restart-policy` flag is provided with an invalid value (not one of `no`, `always`, `unless-stopped`, `on-failure`), THEN THE CLI SHALL print a descriptive error message to stderr listing the valid values and exit with a non-zero exit code. 9. THE `--docker-restart-policy` flag SHALL only be valid in START mode; it is a START-only flag subject to the CLI-3 constraint. 10. WHEN a Container already exists and the CLI reconnects to it, THE CLI SHALL NOT modify the existing Container's restart policy — the policy is set only at Container creation time. + +--- + +### Requirement 26: Host Network Mode + +**User Story:** As a developer, I want the container to share the host's network namespace by default, so that services running on the host are directly accessible from inside the container, and services started inside the container are directly accessible from the host browser — without any additional port forwarding configuration. I also want the option to disable host networking and fall back to bridge mode with port mapping if needed. + +#### Acceptance Criteria + +1. THE CLI SHALL accept a `--host-network-off` flag (boolean, default: absent/false) that disables Docker host network mode. When `--host-network-off` is NOT set (the default), host network mode is active. When `--host-network-off` IS set, the Container uses bridge networking with port mapping. +2. WHEN `--host-network-off` is NOT set (the default), THE CLI SHALL configure the Container with Docker host network mode (`NetworkMode: "host"`), so the Container shares the Host's network namespace. +3. WHEN `--host-network-off` is NOT set, THE SSH_Server inside the Container SHALL be configured to listen on `127.0.0.1:` via sshd_config directives (`Port ` and `ListenAddress 127.0.0.1`) instead of the default port 22. +4. WHEN `--host-network-off` is NOT set, Docker port binding configuration (`PortBindings`, `ExposedPorts`) SHALL NOT be set on the Container — they are ignored by Docker in host network mode and would produce a warning. +5. WHEN `--host-network-off` is NOT set, THE Container SHALL be able to reach any TCP/UDP service listening on the Host's loopback or external interfaces without additional configuration. +6. WHEN `--host-network-off` IS set, THE CLI SHALL configure the Container with the default Docker bridge network and SHALL map the Container's internal SSH port (`constants.ContainerSSHPort`, port 22) to the SSH_Port on the Host via Docker port bindings (`HostIP: constants.HostBindIP`, `HostPort: SSH_Port`). +7. WHEN `--host-network-off` IS set, THE SSH_Server inside the Container SHALL listen on port 22 (the default sshd port). The sshd_config SHALL NOT include `Port` or `ListenAddress` directives — sshd uses its defaults. +8. THE existing SSH_Port selection logic (Requirement 12) SHALL remain unchanged regardless of `--host-network-off` — the port is auto-selected starting at `constants.SSHPortStart` (2222), incrementing until free, and persisted in the Tool_Data_Dir. +9. THE `--port` flag SHALL continue to override the SSH_Port regardless of `--host-network-off`. +10. THE `WaitForSSH` function SHALL connect to `127.0.0.1:` to verify SSH readiness in both modes (in host mode sshd listens directly on that port; in bridge mode Docker maps it). +11. THE `HostBindIP` constant (`127.0.0.1`) SHALL be used as the sshd bind address in host mode and as the Docker port binding IP in bridge mode, ensuring SSH is only accessible from localhost in both cases. +12. THE `--host-network-off` flag SHALL only be valid in START mode; it is a START-only flag subject to the CLI-3 constraint. +13. THE `--host-network-off` value SHALL influence the Instance_Image build: when absent (host mode), sshd_config includes `Port ` and `ListenAddress 127.0.0.1`; when set (bridge mode), these directives are omitted. +14. WHEN `--host-network-off` is changed between invocations for the same project (e.g. added or removed), THE CLI SHALL require `--rebuild` to regenerate the Instance_Image with the correct sshd_config. IF the network mode has changed and `--rebuild` is not set, THE CLI SHALL print a message instructing the user to run with `--rebuild` and exit with a zero exit code. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md index ac31f23..632afda 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md @@ -48,9 +48,9 @@ This feature splits the current monolithic Container_Image build (core Req 14) i 1. WHEN an Instance_Image build is needed (no existing image, base was rebuilt, or `--rebuild`), THE Builder SHALL produce a Dockerfile starting with `FROM bac-base:latest`. 2. THE Instance_Image SHALL inject the project's persisted SSH host key pair (core Req 13) into `/etc/ssh/ssh_host_ed25519_key` (`0600`) and `.pub` (`0644`). 3. THE Instance_Image SHALL inject Public_Key (core Req 4) into `/.ssh/authorized_keys` (`0600`), directory at `0700`, owned by Container_User. -4. THE Instance_Image SHALL append sshd_config: `PasswordAuthentication no`, `PermitRootLogin no`, `PubkeyAuthentication yes`. +4. THE Instance_Image SHALL append sshd_config directives: `PasswordAuthentication no`, `PermitRootLogin no`, `PubkeyAuthentication yes`. WHEN host network mode is active (default, `--host-network-off` NOT set), THE Instance_Image SHALL additionally append `Port ` and `ListenAddress 127.0.0.1` to configure sshd to listen on the project's SSH_Port bound to localhost only. WHEN bridge mode is active (`--host-network-off` IS set), these port/address directives SHALL be omitted (sshd uses its default port 22). 5. THE Instance_Image SHALL create `/run/sshd`. -6. THE Instance_Image SHALL set `CMD ["/usr/sbin/sshd", "-D"]` as the final instruction. +6. THE Instance_Image SHALL set `CMD ["/usr/sbin/sshd", "-D"]` as the final instruction. sshd reads the port and bind address from sshd_config; no `-p` flag is needed in CMD. 7. THE Instance_Image SHALL be tagged `:latest`. 8. THE Instance_Image SHALL carry labels `bac.managed` = `"true"` and `bac.container` = Container_Name. @@ -121,6 +121,7 @@ This feature splits the current monolithic Container_Image build (core Req 14) i 2. IF confirmed: stop/remove all bac-managed containers, remove all bac-managed images (Base_Image + all Instance_Images), delete Tool_Data_Dir root, remove all Known_Hosts_Entries, remove all SSH_Config_Entries. 3. IF not confirmed, print "Purge cancelled." and exit 0. 4. IF removal of an individual item fails, print warning to stderr and continue. +5. Image removal SHALL proceed in dependency order: Instance_Images (children, identified by `bac.container` label) SHALL be removed before Base_Image (parent). This prevents Docker's "image has dependent child images" error. ### Requirement TL-8: Agent Manifest Change Detection diff --git a/.kiro/specs/bootstrap-ai-coding/requirements.md b/.kiro/specs/bootstrap-ai-coding/requirements.md index a05f522..cf7f4c6 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -1,7 +1,8 @@ # Requirements -The requirements for this project are split across three documents: +The requirements for this project are split across multiple documents: -- **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–21. -- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code and Augment Code, plus the template for future agents. Requirements CC-1–CC-6 and AC-1–AC-6. -- **[requirements-cli-combinations.md](./requirements-cli-combinations.md)** — Formal rules for valid and invalid CLI flag combinations. Requirements CLI-1–CLI-6. +- **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, host network mode, restart policy, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–26. +- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code (CC-1–CC-8), Augment Code (AC-1–AC-6), and Build Resources (BR-1–BR-6). +- **[requirements-cli-combinations.md](./requirements-cli-combinations.md)** — Formal rules for valid and invalid CLI flag combinations. Requirements CLI-1–CLI-7. +- **[requirements-two-layer-image.md](./requirements-two-layer-image.md)** — Two-layer Docker image architecture (Base_Image + Instance_Image). Requirements TL-1–TL-11. diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md index e539ae4..bbdeac0 100644 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -1,88 +1,3 @@ -# Tasks: Container Restart Policy (Req 25, CLI-7) +# Implementation Plan -## Task Dependency Graph - -``` -Task 1 (constants) → Task 2 (ContainerSpec) → Task 3 (runner) → Task 4 (cmd flags) → Task 5 (tests) -``` - ---- - -## Task 1: Add `DefaultRestartPolicy` constant - -- [x] Add `DefaultRestartPolicy = "unless-stopped"` to `internal/constants/constants.go` - -### Files to modify -- `internal/constants/constants.go` - -### Acceptance criteria -- `constants.DefaultRestartPolicy` exists and equals `"unless-stopped"` -- No other package hardcodes the default restart policy string - ---- - -## Task 2: Add `RestartPolicy` field to `ContainerSpec` - -- [x] Add `RestartPolicy string` field to the `ContainerSpec` struct in `internal/docker/runner.go` - -### Files to modify -- `internal/docker/runner.go` - -### Acceptance criteria -- `ContainerSpec` has a `RestartPolicy string` field -- Existing code that constructs `ContainerSpec` still compiles (field is zero-value safe) - ---- - -## Task 3: Apply restart policy in `CreateContainer` - -- [x] In `CreateContainer` (`internal/docker/runner.go`), set `HostConfig.RestartPolicy` from `spec.RestartPolicy` -- [x] If `spec.RestartPolicy` is empty, default to `constants.DefaultRestartPolicy` - -### Files to modify -- `internal/docker/runner.go` - -### Acceptance criteria -- Containers created via `CreateContainer` have the Docker restart policy set -- When `RestartPolicy` is empty string, `unless-stopped` is used as fallback -- When `RestartPolicy` is explicitly set, that value is used - ---- - -## Task 4: Add `--docker-restart-policy` flag and validation in CLI - -- [x] Add `flagDockerRestartPolicy string` variable -- [x] Register `--docker-restart-policy` flag in `init()` with default `constants.DefaultRestartPolicy` -- [x] Add `"docker-restart-policy"` to the `startOnly` map in `ValidateStartOnlyFlags` -- [x] Add validation: reject values not in `{no, always, unless-stopped, on-failure}` -- [x] Pass the flag value to `ContainerSpec.RestartPolicy` when creating the container in `runStart` - -### Files to modify -- `internal/cmd/root.go` - -### Acceptance criteria -- `--docker-restart-policy` flag is registered with default `"unless-stopped"` -- Invalid values produce a descriptive error listing valid options (exit 1) -- Flag is rejected in STOP and PURGE modes (CLI-3) -- The value is threaded through to `ContainerSpec.RestartPolicy` in `runStart` - ---- - -## Task 5: Add unit and property-based tests - -- [x] Add `TestRestartPolicyDefaultIsUnlessStopped` — verify flag default -- [x] Add `TestRestartPolicyInvalidValueRejected` — verify invalid values produce errors -- [x] Add `TestRestartPolicyFlagWithStopRejected` — verify CLI-3 for STOP mode -- [x] Add `TestRestartPolicyFlagWithPurgeRejected` — verify CLI-3 for PURGE mode -- [x] Add `TestRestartPolicyAppliedToContainerSpec` — verify the value reaches `HostConfig` -- [x] Add property test (Property 55): for any string, validation accepts iff it's in the valid set -- [x] Add property test (Property 56): for any valid policy, ContainerSpec.RestartPolicy matches - -### Files to modify -- `internal/cmd/root_test.go` -- `internal/docker/runner_test.go` (or new file `internal/docker/runner_restart_test.go`) - -### Acceptance criteria -- All new tests pass with `go test ./...` -- Property tests use `pgregory.net/rapid` with minimum 100 iterations -- Property test tag format: `// Feature: bootstrap-ai-coding, Property N: ` +No pending implementation tasks. diff --git a/.kiro/steering/agent-module.md b/.kiro/steering/agent-module.md index 7935081..f998065 100644 --- a/.kiro/steering/agent-module.md +++ b/.kiro/steering/agent-module.md @@ -21,11 +21,11 @@ package aider import ( "context" + "fmt" "os" "path/filepath" "github.com/koudis/bootstrap-ai-coding/internal/agent" - "github.com/koudis/bootstrap-ai-coding/internal/constants" "github.com/koudis/bootstrap-ai-coding/internal/docker" ) @@ -55,9 +55,9 @@ func (a *aiderAgent) CredentialStorePath() string { } // ContainerMountPath returns where credentials are mounted inside the container. -// Always use constants.ContainerUserHome as the base — never hardcode "/home/dev". -func (a *aiderAgent) ContainerMountPath() string { - return filepath.Join(constants.ContainerUserHome, ".aider") +// The homeDir parameter is the container user's home directory (from hostinfo.Info.HomeDir). +func (a *aiderAgent) ContainerMountPath(homeDir string) string { + return filepath.Join(homeDir, ".aider") } // HasCredentials reports whether the credential store contains valid auth tokens. @@ -149,7 +149,7 @@ Add a new section to `.kiro/specs/bootstrap-ai-coding/requirements-agents.md` fo | `ID()` | Unique, stable, kebab-case string (e.g. `"claude-code"`, `"aider"`) | | `Install(b)` | Appends `RUN` steps to `b`; must be idempotent and self-contained | | `CredentialStorePath()` | Default host path for auth tokens; may use `~/` prefix | -| `ContainerMountPath()` | Absolute path inside container; use `constants.ContainerUserHome` as base | +| `ContainerMountPath(homeDir)` | Absolute path inside container; `homeDir` is the container user's home (from `hostinfo.Info.HomeDir`) | | `HasCredentials(path)` | `(true, nil)` if tokens exist; `(false, nil)` if empty; `(false, err)` on error | | `HealthCheck(ctx, c, id)` | `nil` if agent is ready; non-nil error if not. `c` is the existing `*docker.Client` — do not create a new one. | @@ -158,12 +158,12 @@ Add a new section to `.kiro/specs/bootstrap-ai-coding/requirements-agents.md` fo Agent modules may import: - `github.com/koudis/bootstrap-ai-coding/internal/agent` — to call `agent.Register()` - `github.com/koudis/bootstrap-ai-coding/internal/docker` — for `*docker.DockerfileBuilder` and `*docker.Client` -- `github.com/koudis/bootstrap-ai-coding/internal/constants` — for `ContainerUserHome` and other glossary values +- `github.com/koudis/bootstrap-ai-coding/internal/constants` — for glossary values - `github.com/koudis/bootstrap-ai-coding/internal/pathutil` — for `ExpandHome` if needed - Standard library packages Agent modules must **NOT** import: -- `cmd`, `naming`, `ssh`, `datadir`, `docker/runner` +- `cmd`, `naming`, `ssh`, `datadir`, `docker/runner`, `hostinfo` - Any other agent module ## Naming Convention diff --git a/.kiro/steering/cli-flags.md b/.kiro/steering/cli-flags.md index 897d53c..b0a3cd9 100644 --- a/.kiro/steering/cli-flags.md +++ b/.kiro/steering/cli-flags.md @@ -27,12 +27,13 @@ The path to the project directory on the host. Mounted into the container at `co Comma-separated list of agent IDs to install in the container. -- Default: `constants.DefaultAgents` (`"claude-code,augment-code"`) +- Default: `constants.DefaultAgents` (`"claude-code,augment-code,build-resources"`) - Example: `--agents claude-code` - Example: `--agents augment-code` - Example: `--agents claude-code,augment-code` +- Example: `--agents claude-code,augment-code,build-resources` - Unknown IDs produce an error listing available agents -- **Validates:** Req 7.4, 7.5 +- **Validates:** Req 7.4, 7.5, BR-6 --- diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index d33faf3..5597986 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -8,18 +8,19 @@ All project-wide constants are defined in `constants/constants.go`. Every value This means: - No `"ubuntu:26.04"` string literals outside `constants/` -- No `"dev"` username literals outside `constants/` - No `2222` port literals outside `constants/` - No `0o700` / `0o600` permission literals outside `constants/` - etc. +> **Note:** `Container_User` and `Container_User_Home` are NOT constants. They are resolved at runtime via `hostinfo.Current()` (Req 22) and passed through the `*hostinfo.Info` struct. The container user's username and home directory match the host user who invoked the CLI. + ## Constants Reference | Constant | Value | Glossary Term | |---|---|---| | `BaseContainerImage` | `"ubuntu:26.04"` | `Base_Container_Image` | -| `ContainerUser` | `"dev"` | `Container_User` (username) | -| `ContainerUserHome` | `"/home/" + ContainerUser` | `Container_User_Home` | +| `BaseImageName` | `"bac-base"` | Base image name for two-layer architecture (TL-11) | +| `BaseImageTag` | `BaseImageName + ":latest"` | Full base image reference (TL-11) | | `WorkspaceMountPath` | `"/workspace"` | `Mounted_Volume` (container path) | | `SSHPortStart` | `2222` | `SSH_Port` (starting value) | | `ContainerSSHPort` | `22` | SSH port inside the container | @@ -30,16 +31,20 @@ This means: | `ManifestFilePath` | `"/bac-manifest.json"` | manifest file inside image | | `ClaudeCodeAgentName` | `"claude-code"` | Agent_ID for Claude Code (CC-1) | | `AugmentCodeAgentName` | `"augment-code"` | Agent_ID for Augment Code (AC-1) | -| `DefaultAgents` | `"claude-code,augment-code"` | default `Enabled_Agents` (Req 7.5) | +| `BuildResourcesAgentName` | `"build-resources"` | Agent_ID for Build Resources (BR-1) | +| `DefaultAgents` | `"claude-code,augment-code,build-resources"` | default `Enabled_Agents` (Req 7.5, BR-6) | | `SSHHostKeyType` | `"ed25519"` | SSH host key algorithm | | `MinDockerVersion` | `"20.10"` | minimum Docker version (Req 6.3) | | `ToolDataDirPerm` | `0o700` | Tool_Data_Dir permissions (Req 15.2) | | `ToolDataFilePerm` | `0o600` | Tool_Data_Dir file permissions (Req 15.3) | +| `GitConfigPerm` | `0o444` | Injected .gitconfig permissions (Req 24) | | `KnownHostsFile` | `"~/.ssh/known_hosts"` | Known_Hosts_File (Req 18) | | `SSHConfigFile` | `"~/.ssh/config"` | SSH_Config_File (Req 19) | | `SSHDirPerm` | `0o700` | ~/.ssh directory permissions (Req 18.5) | -| `HostBindIP` | `"127.0.0.1"` | IP address containers bind SSH port to on the host (Req R7) | -| `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.7) | +| `KeyringProfileScript` | `"/etc/profile.d/dbus-keyring.sh"` | D-Bus/gnome-keyring startup script (CC-7) | +| `HostBindIP` | `"127.0.0.1"` | IP address containers bind SSH port to on the host (Req 26) | +| `DefaultRestartPolicy` | `"unless-stopped"` | Docker restart policy (Req 25.2) | +| `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.8) | ### Variables (not const — Go does not support slice/map constants) @@ -49,6 +54,17 @@ This means: | `KnownHostsPatterns` | `["[localhost]", "127.0.0.1"]` | Known_Hosts_Entry host patterns (Req 18) | | `Version` | `"dev"` (overridden via ldflags) | build version | +### Runtime-Resolved Values (NOT constants) + +| Value | Source | Glossary Term | +|---|---|---| +| Container_User username | `hostinfo.Current().Username` | `Container_User` (Req 22) | +| Container_User home | `hostinfo.Current().HomeDir` | `Container_User_Home` (Req 22) | +| Container_User UID | `hostinfo.Current().UID` | Host_User UID (Req 10) | +| Container_User GID | `hostinfo.Current().GID` | Host_User GID (Req 10) | + +These are resolved once at CLI startup via `hostinfo.Current()` and threaded through all operations via `*hostinfo.Info`. + ## Import Pattern ```go diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index 736fea9..f91728e 100644 --- a/.kiro/steering/product.md +++ b/.kiro/steering/product.md @@ -21,7 +21,7 @@ Data directory: ~/.config/bootstrap-ai-coding// Project directory: /path/to/project SSH port: 2222 SSH connect: ssh bac- -Enabled agents: claude-code, augment-code +Enabled agents: claude-code, augment-code, build-resources ``` ## Key design goals @@ -44,7 +44,7 @@ Developers who want to run AI coding agents (Claude Code, Augment Code, etc.) in | Flag | Description | |---|---| | `` | (positional) Path to the project directory to mount | -| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code`) | +| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources`) | | `--port ` | Override the SSH port (default: auto-selected from 2222 upward) | | `--ssh-key ` | Override the SSH public key path | | `--rebuild` | Force a full container image rebuild | diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index 7435bb4..e6c4815 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -13,15 +13,21 @@ bootstrap-ai-coding/ ├── pathutil/ │ └── pathutil.go # Shared path helpers (ExpandHome) — zero internal dependencies │ + ├── hostinfo/ + │ └── hostinfo.go # Runtime host user identity (Username, HomeDir, UID, GID) — replaces former ContainerUser/ContainerUserHome constants + │ ├── cmd/ - │ └── root.go # Cobra root command, flag definitions, top-level orchestration logic + │ ├── root.go # Cobra root command, flag definitions, top-level orchestration logic + │ ├── builds.go # Two-layer image cache detection (determineBuilds) + │ ├── stop.go # --stop-and-remove flow (testable via StopDockerAPI interface) + │ └── purge.go # --purge flow (testable via PurgeDockerAPI interface) │ ├── naming/ │ └── naming.go # Container name resolution from project path ("bac-" prefix, human-readable, collision-resistant) │ ├── docker/ │ ├── client.go # Docker SDK client wrapper; prerequisite checks (daemon reachable, version >= constants.MinDockerVersion) - │ ├── builder.go # DockerfileBuilder — incremental Dockerfile assembly + │ ├── builder.go # DockerfileBuilder — two-layer Dockerfile assembly (base + instance) │ └── runner.go # Container create/start/stop/inspect helpers │ ├── ssh/ @@ -42,15 +48,17 @@ bootstrap-ai-coding/ └── agents/ ├── claude/ │ └── claude.go # Claude Code agent module (reference implementation) - └── augment/ - └── augment.go # Augment Code agent module + ├── augment/ + │ └── augment.go # Augment Code agent module + └── buildresources/ + └── buildresources.go # Build Resources pseudo-agent (dev toolchains) # future agents: internal/agents//.go — no core files change ``` ## Architectural Rules - **All packages live under `internal/`.** The Go compiler enforces that nothing outside this module can import them. -- **Core has zero knowledge of agents.** Packages under `internal/cmd/`, `internal/naming/`, `internal/docker/`, `internal/ssh/`, `internal/datadir/`, `internal/pathutil/`, and `internal/agent/` must never import anything under `internal/agents/`. +- **Core has zero knowledge of agents.** Packages under `internal/cmd/`, `internal/naming/`, `internal/docker/`, `internal/ssh/`, `internal/datadir/`, `internal/pathutil/`, `internal/hostinfo/`, and `internal/agent/` must never import anything under `internal/agents/`. - **Agent modules are wired in via blank imports in `main.go` only.** Each agent's `init()` calls `agent.Register()`. - **Agent modules may import `internal/agent`, `internal/docker`, `internal/constants`, and `internal/pathutil` from the core.** They must not import `internal/cmd`, `internal/naming`, `internal/ssh`, `internal/datadir`, or `internal/docker/runner`. - **No package may hardcode values that exist in `internal/constants/`.** Always import and reference `constants.*`. @@ -63,13 +71,15 @@ bootstrap-ai-coding/ // In main.go: import ( "github.com/koudis/bootstrap-ai-coding/internal/cmd" - _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" _ "github.com/koudis/bootstrap-ai-coding/internal/agents/augment" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/buildresources" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" ) // In internal packages: import ( "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" "github.com/koudis/bootstrap-ai-coding/internal/pathutil" "github.com/koudis/bootstrap-ai-coding/internal/naming" ) @@ -80,12 +90,12 @@ import ( - Container names: `bac-` derived from the project directory name (sanitized to `[a-z0-9_.-]`); falls back to `bac-_` on conflict, then `bac-_-2`, `-3`, … — checked only against existing `bac-`-prefixed containers - Tool data directory: `~/.config/bootstrap-ai-coding//` — stores SSH port, SSH host key, agent manifest - Base image: always `ubuntu:26.04` (constants.BaseContainerImage) — no other base image or Ubuntu version -- Container user: `dev` (constants.ContainerUser), UID/GID matching the host user who invoked the CLI -- Container user home: `/home/dev` (constants.ContainerUserHome) +- Container user: matches the Host_User's username (resolved at runtime via `hostinfo.Current()`), UID/GID matching the host user who invoked the CLI +- Container user home: matches the Host_User's home directory path (resolved at runtime via `hostinfo.Current()`) - Workspace mount: `/workspace` (constants.WorkspaceMountPath) - SSH port: starts at `2222` (constants.SSHPortStart), increments until free, persisted per project - SSH host key type: `ed25519` (constants.SSHHostKeyType) — generated once per project, reused across rebuilds - Manifest file inside image: `/bac-manifest.json` (constants.ManifestFilePath) — lists enabled agent IDs for rebuild detection -- Default agents: `claude-code,augment-code` (constants.DefaultAgents) +- Default agents: `claude-code,augment-code,build-resources` (constants.DefaultAgents) - File permissions: Tool_Data_Dir `0700` (constants.ToolDataDirPerm), all files within `0600` (constants.ToolDataFilePerm) - Headless keyring: D-Bus session bus + gnome-keyring-daemon started via `/etc/profile.d/dbus-keyring.sh` on SSH login — enables libsecret-based credential storage (CC-7) diff --git a/internal/agents/augment/integration_test.go b/internal/agents/augment/integration_test.go index b88a2fa..5b21c97 100644 --- a/internal/agents/augment/integration_test.go +++ b/internal/agents/augment/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) @@ -142,6 +141,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() @@ -160,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, true) diff --git a/internal/agents/buildresources/buildresources.go b/internal/agents/buildresources/buildresources.go index 84b1bf4..5407cfe 100644 --- a/internal/agents/buildresources/buildresources.go +++ b/internal/agents/buildresources/buildresources.go @@ -104,7 +104,7 @@ func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, {[]string{"uv", "--version"}, "uv"}, {[]string{"cmake", "--version"}, "cmake"}, {[]string{"javac", "-version"}, "javac"}, - {[]string{"bash", "-lc", "go version"}, "go"}, + {[]string{"/usr/local/go/bin/go", "version"}, "go"}, {[]string{"rg", "--version"}, "ripgrep"}, {[]string{"fdfind", "--version"}, "fd-find"}, {[]string{"jq", "--version"}, "jq"}, diff --git a/internal/agents/buildresources/integration_test.go b/internal/agents/buildresources/integration_test.go index 659c2bc..0f837fa 100644 --- a/internal/agents/buildresources/integration_test.go +++ b/internal/agents/buildresources/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) @@ -142,6 +141,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() @@ -160,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, true) diff --git a/internal/agents/claude/integration_test.go b/internal/agents/claude/integration_test.go index 0051f3f..c8df42c 100644 --- a/internal/agents/claude/integration_test.go +++ b/internal/agents/claude/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) @@ -142,6 +141,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() @@ -160,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, false) diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go index 7a87bb0..c6630bd 100644 --- a/internal/cmd/purge.go +++ b/internal/cmd/purge.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -17,6 +18,7 @@ type PurgeDockerAPI interface { ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) + ImagesPrune(ctx context.Context, pruneFilter filters.Args) (image.PruneReport, error) } // RunPurgeWith implements the container and image removal portion of the purge @@ -49,18 +51,50 @@ func RunPurgeWith(api PurgeDockerAPI) error { for _, ctr := range containers { _ = api.ContainerStop(ctx, ctr.ID, container.StopOptions{}) if err := api.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{Force: true}); err != nil { - fmt.Printf("warning: removing container %s: %v\n", ctr.ID, err) + fmt.Fprintf(os.Stderr, "warning: removing container %s: %v\n", ctr.ID, err) } } - // Remove all images. + // Remove images in dependency order: instance images (children) first, then + // prune dangling intermediate layers, then the base image (parent). Docker + // refuses to delete a parent image while child images still reference it via + // FROM — this includes untagged intermediate build cache layers. + var instanceImages, baseImages []image.Summary for _, img := range images { + if img.Labels["bac.container"] != "" { + instanceImages = append(instanceImages, img) + } else { + baseImages = append(baseImages, img) + } + } + + // 1. Remove instance images (children of bac-base). + for _, img := range instanceImages { + if _, err := api.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { + tag := img.ID + if len(img.RepoTags) > 0 { + tag = img.RepoTags[0] + } + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) + } + } + + // 2. Prune dangling images (untagged intermediate build layers that may + // still reference bac-base as their parent). + danglingFilter := filters.NewArgs() + danglingFilter.Add("dangling", "true") + if _, err := api.ImagesPrune(ctx, danglingFilter); err != nil { + fmt.Fprintf(os.Stderr, "warning: pruning dangling images: %v\n", err) + } + + // 3. Remove base image(s) now that children are gone. + for _, img := range baseImages { if _, err := api.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { tag := img.ID if len(img.RepoTags) > 0 { tag = img.RepoTags[0] } - fmt.Printf("warning: removing image %s: %v\n", tag, err) + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) } } diff --git a/internal/cmd/purge_test.go b/internal/cmd/purge_test.go index 9e89a56..de73a64 100644 --- a/internal/cmd/purge_test.go +++ b/internal/cmd/purge_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/stretchr/testify/require" @@ -18,6 +19,7 @@ type mockPurgeDockerAPI struct { images []image.Summary removedImageIDs []string + pruned bool } func (m *mockPurgeDockerAPI) ContainerList(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { @@ -41,6 +43,11 @@ func (m *mockPurgeDockerAPI) ImageRemove(_ context.Context, imageID string, _ im return nil, nil } +func (m *mockPurgeDockerAPI) ImagesPrune(_ context.Context, _ filters.Args) (image.PruneReport, error) { + m.pruned = true + return image.PruneReport{}, nil +} + // TestPurgeRemovesBothBaseAndInstanceImages verifies that the purge flow removes // both the base image (bac-base:latest) and instance images (e.g. bac-myproject:latest) // when both are present with bac.managed=true labels. @@ -108,6 +115,57 @@ func TestPurgeRemovesMultipleInstanceImages(t *testing.T) { require.Contains(t, mock.removedImageIDs, "sha256:instance-b") } +// TestPurgeRemovesInstanceImagesBeforeBaseImage verifies that purge removes +// instance images (children) before the base image (parent) to avoid Docker's +// "image has dependent child images" error. +// Validates: TL-7.4 +func TestPurgeRemovesInstanceImagesBeforeBaseImage(t *testing.T) { + mock := &mockPurgeDockerAPI{ + containers: []container.Summary{}, + // Deliberately list base image FIRST to verify reordering. + images: []image.Summary{ + { + ID: "sha256:base111", + RepoTags: []string{"bac-base:latest"}, + Labels: map[string]string{"bac.managed": "true"}, + }, + { + ID: "sha256:instance-a", + RepoTags: []string{"bac-projecta:latest"}, + Labels: map[string]string{"bac.managed": "true", "bac.container": "bac-projecta"}, + }, + { + ID: "sha256:instance-b", + RepoTags: []string{"bac-projectb:latest"}, + Labels: map[string]string{"bac.managed": "true", "bac.container": "bac-projectb"}, + }, + }, + } + + err := cmd.RunPurgeWith(mock) + require.NoError(t, err) + + require.Len(t, mock.removedImageIDs, 3) + + // Find the index of the base image in the removal order. + baseIdx := -1 + for i, id := range mock.removedImageIDs { + if id == "sha256:base111" { + baseIdx = i + break + } + } + require.NotEqual(t, -1, baseIdx, "base image must be removed") + + // All instance images must appear before the base image. + for i, id := range mock.removedImageIDs { + if id == "sha256:instance-a" || id == "sha256:instance-b" { + require.Less(t, i, baseIdx, + "instance image %s (index %d) must be removed before base image (index %d)", id, i, baseIdx) + } + } +} + // TestPurgeAlsoStopsAndRemovesContainers verifies that purge stops and removes // containers before removing images. // Validates: TL-7 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b648982..2e1c701 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -74,6 +75,7 @@ func ValidateStartOnlyFlags(mode Mode, changedFlags []string) error { "no-update-ssh-config": true, "verbose": true, "docker-restart-policy": true, + "host-network-off": true, } for _, name := range changedFlags { if startOnly[name] { @@ -147,6 +149,7 @@ var ( flagNoUpdateSSHConfig bool flagVerbose bool flagDockerRestartPolicy string + flagHostNetworkOff bool ) var rootCmd = &cobra.Command{ @@ -175,6 +178,7 @@ func init() { rootCmd.Flags().BoolVar(&flagNoUpdateSSHConfig, "no-update-ssh-config", false, "Skip automatic ~/.ssh/config management") rootCmd.Flags().BoolVarP(&flagVerbose, "verbose", "v", false, "Stream Docker build output to stdout in real time") rootCmd.Flags().StringVar(&flagDockerRestartPolicy, "docker-restart-policy", constants.DefaultRestartPolicy, "Docker restart policy for the container (no, always, unless-stopped, on-failure)") + rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, "Disable host network mode; use bridge networking with port mapping") } func run(cmd *cobra.Command, args []string) error { @@ -253,60 +257,12 @@ func run(cmd *cobra.Command, args []string) error { case ModePurge: return runPurge(dockerClient) default: - return runStart(dockerClient, args[0], enabledAgents) + return runStart(dockerClient, args[0], enabledAgents, flagHostNetworkOff) } } -func modeFlag(m Mode) string { - if m == ModeStop { - return "--stop-and-remove" - } - return "--purge" -} - func runStop(c *dockerpkg.Client, projectPath string) error { - existingNames, err := dockerpkg.ListBACContainerNames(context.Background(), c) - if err != nil { - return fmt.Errorf("listing existing containers: %w", err) - } - containerName, err := naming.ContainerName(projectPath, existingNames) - if err != nil { - return fmt.Errorf("deriving container name: %w", err) - } - info, err := dockerpkg.InspectContainer(context.Background(), c, containerName) - if err != nil { - return err - } - if info == nil { - fmt.Printf("No container found for project %s\n", projectPath) - return nil - } - if err := dockerpkg.StopContainer(context.Background(), c, containerName); err != nil { - if !strings.Contains(err.Error(), "not running") { - return err - } - } - if err := dockerpkg.RemoveContainer(context.Background(), c, containerName); err != nil { - return err - } - fmt.Printf("Container %s stopped and removed.\n", containerName) - - // Remove known_hosts entries for this project's SSH port (Req 18.7). - dd, err := datadir.New(containerName) - if err == nil { - if port, err := dd.ReadPort(); err == nil && port != 0 { - if khErr := sshpkg.RemoveKnownHostsEntries(port); khErr != nil { - fmt.Fprintf(os.Stderr, "warning: removing known_hosts entries: %v\n", khErr) - } - } - } - - // Remove SSH config entry for this container (Req 19.7). - if cfgErr := sshpkg.RemoveSSHConfigEntry(containerName); cfgErr != nil { - fmt.Fprintf(os.Stderr, "warning: removing SSH config entry: %v\n", cfgErr) - } - - return nil + return RunStopWith(c, projectPath) } func runPurge(c *dockerpkg.Client) error { @@ -363,12 +319,45 @@ func runPurge(c *dockerpkg.Client) error { } } + // Remove images in dependency order: instance images (children) first, then + // prune dangling intermediate layers, then the base image (parent). + var instanceImages, baseImages []image.Summary for _, img := range images { + if img.Labels["bac.container"] != "" { + instanceImages = append(instanceImages, img) + } else { + baseImages = append(baseImages, img) + } + } + + for _, img := range instanceImages { + tag := img.ID + if len(img.RepoTags) > 0 { + tag = img.RepoTags[0] + } + if _, err := c.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) + } + } + + // Prune dangling images (untagged intermediate build layers that may still + // reference bac-base as their parent). + danglingFilter := filters.NewArgs() + danglingFilter.Add("dangling", "true") + if _, err := c.ImagesPrune(ctx, danglingFilter); err != nil { + fmt.Fprintf(os.Stderr, "warning: pruning dangling images: %v\n", err) + } + + for _, img := range baseImages { tag := img.ID if len(img.RepoTags) > 0 { tag = img.RepoTags[0] } if _, err := c.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { + // Skip "No such image" — already removed by the dangling prune step. + if strings.Contains(err.Error(), "No such image") { + continue + } fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) } } @@ -394,7 +383,7 @@ func runPurge(c *dockerpkg.Client) error { return nil } -func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Agent) error { +func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Agent, hostNetworkOff bool) error { ctx := context.Background() absPath, err := filepath.Abs(projectPath) @@ -532,6 +521,19 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age return fmt.Errorf("determining build requirements: %w", buildErr) } + // Network mode change detection: only check when an image already exists + // (i.e., not a first run where both layers need building). + if !needBase && !flagRebuild { + persistedOff, err := dd.ReadHostNetworkOff() + if err != nil { + return fmt.Errorf("reading persisted host network mode: %w", err) + } + if persistedOff != hostNetworkOff { + fmt.Println("Network mode changed — run with --rebuild to update the image.") + return nil + } + } + if needBase { // Check for UID/GID conflict in base image strategy := dockerpkg.UserStrategyCreate @@ -578,8 +580,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: baseLabels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, NoCache: flagRebuild, } @@ -595,7 +596,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age } if needInstance { - instanceBuilder := dockerpkg.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + instanceBuilder := dockerpkg.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, sshPort, hostNetworkOff) instanceBuilder.Finalize() instanceSpec := dockerpkg.ContainerSpec{ @@ -603,8 +604,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age ImageTag: imageTag, Dockerfile: instanceBuilder.Build(), Labels: labels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } fmt.Println("Building instance image...") @@ -613,6 +613,10 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age fmt.Fprint(os.Stderr, buildOutput) return fmt.Errorf("instance image build failed: %w", err) } + + if err := dd.WriteHostNetworkOff(hostNetworkOff); err != nil { + return fmt.Errorf("persisting host network mode: %w", err) + } } containerInfo, err := dockerpkg.InspectContainer(ctx, c, containerName) @@ -658,12 +662,13 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age } spec := dockerpkg.ContainerSpec{ - Name: containerName, - ImageTag: imageTag, - Mounts: mounts, - SSHPort: sshPort, - Labels: labels, - RestartPolicy: flagDockerRestartPolicy, + Name: containerName, + ImageTag: imageTag, + Mounts: mounts, + SSHPort: sshPort, + Labels: labels, + RestartPolicy: flagDockerRestartPolicy, + HostNetworkOff: hostNetworkOff, } if _, err := dockerpkg.CreateContainer(ctx, c, spec); err != nil { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 929dc9a..09eefa7 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -385,6 +385,37 @@ func TestPropertyRestartPolicyValidationAcceptsIffValid(t *testing.T) { }) } +// TestHostNetworkOffFlagAcceptedInStartMode verifies that --host-network-off +// is accepted when used in START mode (no error from flag validation). +func TestHostNetworkOffFlagAcceptedInStartMode(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModeStart, []string{"host-network-off"}) + require.NoError(t, err, "--host-network-off must be accepted in START mode") +} + +// TestHostNetworkOffFlagWithStopRejected verifies that --host-network-off +// is rejected when used with --stop-and-remove (CLI-3). +// Validates: CLI-3 +func TestHostNetworkOffFlagWithStopRejected(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModeStop, []string{"host-network-off"}) + require.Error(t, err) + require.Contains(t, err.Error(), "--host-network-off", + "error must name the offending flag") + require.Contains(t, err.Error(), "--stop-and-remove", + "error must name the conflicting mode flag") +} + +// TestHostNetworkOffFlagWithPurgeRejected verifies that --host-network-off +// is rejected when used with --purge (CLI-3). +// Validates: CLI-3 +func TestHostNetworkOffFlagWithPurgeRejected(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModePurge, []string{"host-network-off"}) + require.Error(t, err) + require.Contains(t, err.Error(), "--host-network-off", + "error must name the offending flag") + require.Contains(t, err.Error(), "--purge", + "error must name the conflicting mode flag") +} + // TestRestartPolicyDefaultIsUnlessStopped verifies that the --docker-restart-policy // flag has default value "unless-stopped" (i.e., constants.DefaultRestartPolicy). // Validates: Req 25.2 diff --git a/internal/cmd/stop.go b/internal/cmd/stop.go index 70f5049..2d90cbe 100644 --- a/internal/cmd/stop.go +++ b/internal/cmd/stop.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/koudis/bootstrap-ai-coding/internal/datadir" @@ -29,9 +30,12 @@ type StopDockerAPI interface { func RunStopWith(api StopDockerAPI, projectPath string) error { ctx := context.Background() - // List existing bac-managed container names. + // List existing bac-managed container names (filtered by bac.managed label). + f := filters.NewArgs() + f.Add("label", "bac.managed=true") containers, err := api.ContainerList(ctx, container.ListOptions{ - All: true, + All: true, + Filters: f, }) if err != nil { return fmt.Errorf("listing existing containers: %w", err) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index a731b10..110d0c1 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -135,9 +135,8 @@ var Version = "dev" var ( // PublicKeyDefaultPaths lists the candidate Public_Key file paths on the Host, - // in order of precedence (highest first). The CLI tries each in turn before - // falling back to the --ssh-key flag value. - // Defined by Req 4.1: ~/.ssh/id_ed25519.pub → ~/.ssh/id_rsa.pub → --ssh-key. + // tried in order after the --ssh-key flag (which has highest precedence). + // Defined by Req 4.1: --ssh-key → ~/.ssh/id_ed25519.pub → ~/.ssh/id_rsa.pub. // Declared as a var (not const) because Go does not support slice constants. PublicKeyDefaultPaths = []string{ "~/.ssh/id_ed25519.pub", diff --git a/internal/datadir/datadir.go b/internal/datadir/datadir.go index b4a9d00..11d5c7e 100644 --- a/internal/datadir/datadir.go +++ b/internal/datadir/datadir.go @@ -120,6 +120,33 @@ func (d *DataDir) WriteManifest(agentIDs []string) error { return nil } +// WriteHostNetworkOff persists the hostNetworkOff boolean as "true" or "false" +// in a file named host_network_off with constants.ToolDataFilePerm. +func (d *DataDir) WriteHostNetworkOff(off bool) error { + p := filepath.Join(d.path, "host_network_off") + if err := os.WriteFile(p, []byte(strconv.FormatBool(off)), constants.ToolDataFilePerm); err != nil { + return fmt.Errorf("writing host_network_off file: %w", err) + } + return nil +} + +// ReadHostNetworkOff reads the persisted hostNetworkOff value. +// Returns (false, nil) if the file does not exist (default: host network ON). +func (d *DataDir) ReadHostNetworkOff() (bool, error) { + data, err := os.ReadFile(filepath.Join(d.path, "host_network_off")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("reading host_network_off file: %w", err) + } + val, err := strconv.ParseBool(strings.TrimSpace(string(data))) + if err != nil { + return false, fmt.Errorf("parsing host_network_off file: %w", err) + } + return val, nil +} + // PurgeRoot removes the entire Tool_Data_Dir root and all its contents. func PurgeRoot() error { return os.RemoveAll(pathutil.ExpandHome(constants.ToolDataDirRoot)) diff --git a/internal/datadir/datadir_test.go b/internal/datadir/datadir_test.go index d1bf54c..c20be7d 100644 --- a/internal/datadir/datadir_test.go +++ b/internal/datadir/datadir_test.go @@ -285,6 +285,66 @@ func TestReadManifestCorruptJSON(t *testing.T) { require.Error(t, err, "ReadManifest must error on invalid JSON") } +// TestHostNetworkOffRoundTrip verifies that WriteHostNetworkOff followed by +// ReadHostNetworkOff returns the same boolean value. +func TestHostNetworkOffRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + // Not yet written — should return false, nil (default: host network ON). + got, err := dd.ReadHostNetworkOff() + require.NoError(t, err) + require.False(t, got) + + // Write true and read back. + require.NoError(t, dd.WriteHostNetworkOff(true)) + got, err = dd.ReadHostNetworkOff() + require.NoError(t, err) + require.True(t, got) + + // Write false and read back. + require.NoError(t, dd.WriteHostNetworkOff(false)) + got, err = dd.ReadHostNetworkOff() + require.NoError(t, err) + require.False(t, got) +} + +// TestWriteHostNetworkOffFilePermission asserts that WriteHostNetworkOff writes +// the file with constants.ToolDataFilePerm. +func TestWriteHostNetworkOffFilePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not applicable on Windows") + } + + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + require.NoError(t, dd.WriteHostNetworkOff(true)) + + info, err := os.Stat(filepath.Join(dd.Path(), "host_network_off")) + require.NoError(t, err) + require.Equal(t, os.FileMode(constants.ToolDataFilePerm), info.Mode().Perm(), + "host_network_off file has mode %04o, want %04o", info.Mode().Perm(), constants.ToolDataFilePerm) +} + +// TestReadHostNetworkOffCorruptContent verifies that ReadHostNetworkOff returns +// an error when the file contains non-boolean content. +func TestReadHostNetworkOffCorruptContent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dd.Path(), "host_network_off"), []byte("not-a-bool"), constants.ToolDataFilePerm)) + + _, err = dd.ReadHostNetworkOff() + require.Error(t, err, "ReadHostNetworkOff must error on non-boolean content") +} + // TestExpandHomeNoTilde verifies that expandHome (via New) handles paths // without a tilde prefix correctly. We exercise this indirectly by setting // HOME to an absolute path and confirming New succeeds. diff --git a/internal/docker/builder.go b/internal/docker/builder.go index fb7d8d6..7b2d705 100644 --- a/internal/docker/builder.go +++ b/internal/docker/builder.go @@ -149,7 +149,7 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, conflicting // info carries the runtime-resolved Container_User identity (Req 22). // publicKey is the content of the user's SSH public key. // hostKeyPriv and hostKeyPub are the persisted SSH host key pair contents. -func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKeyPub string) *DockerfileBuilder { +func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKeyPub string, sshPort int, hostNetworkOff bool) *DockerfileBuilder { b := &DockerfileBuilder{info: info} // 1. FROM the shared base image @@ -181,7 +181,11 @@ func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKe )) // 4. Harden sshd_config - b.Run("echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config") + sshdConfig := "echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config" + if !hostNetworkOff { + sshdConfig += fmt.Sprintf(" && echo 'Port %d' >> /etc/ssh/sshd_config && echo 'ListenAddress %s' >> /etc/ssh/sshd_config", sshPort, constants.HostBindIP) + } + b.Run(sshdConfig) // 5. Ensure sshd runtime dir exists b.Run("mkdir -p /run/sshd") diff --git a/internal/docker/builder_test.go b/internal/docker/builder_test.go index d4c8aa6..a63df2e 100644 --- a/internal/docker/builder_test.go +++ b/internal/docker/builder_test.go @@ -62,6 +62,7 @@ func newInstanceBuilder(uid, gid int) *docker.DockerfileBuilder { testInfo(uid, gid), fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) } @@ -402,6 +403,7 @@ func TestPropertyPublicKeyInjected_Create(t *testing.T) { testInfo(uid, gid), publicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) content := b.Build() @@ -423,6 +425,7 @@ func TestPropertyPublicKeyInjected_Rename(t *testing.T) { testInfo(uid, gid), publicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) content := b.Build() @@ -450,6 +453,7 @@ func TestPropertySSHHostKeyInjected_Create(t *testing.T) { testInfo(uid, gid), fixedPublicKey, hostKeyPriv, hostKeyPub, + 2222, false, ) content := b.Build() @@ -477,6 +481,7 @@ func TestPropertySSHHostKeyInjected_Rename(t *testing.T) { testInfo(uid, gid), fixedPublicKey, hostKeyPriv, hostKeyPub, + 2222, false, ) content := b.Build() @@ -878,7 +883,7 @@ func TestPropertyDockerfileSSHAndUserForAnyUsername(t *testing.T) { "Base Dockerfile must contain sudoers entry for username %q with NOPASSWD", username) // Assert: sshd CMD is in the instance layer - ib := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub) + ib := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, 2222, false) ib.Finalize() instanceContent := ib.Build() require.Contains(t, instanceContent, `CMD ["/usr/sbin/sshd", "-D"]`, @@ -930,7 +935,7 @@ func TestPropertyDockerfileUsesRuntimeUsernameAndHomeDir(t *testing.T) { "sudoers must reference the drawn username %q", username) // Build an instance image Dockerfile - instanceBuilder := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub) + instanceBuilder := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, 2222, false) instanceContent := instanceBuilder.Build() // Assert the instance Dockerfile contains the drawn username in chown @@ -1322,7 +1327,7 @@ func TestPropertyTwoLayer_InstanceImageStartsWithFROM(t *testing.T) { GID: gid, } - b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, 2222, false) lines := b.Lines() wantFrom := "FROM " + constants.BaseImageName + ":latest" @@ -1404,7 +1409,7 @@ func TestPropertyTwoLayer_InstanceImageEndsWithCMDAfterFinalize(t *testing.T) { GID: gid, } - b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, 2222, false) b.Finalize() lines := b.Lines() @@ -1458,3 +1463,38 @@ func TestRunAsUserUsesInfoUsername(t *testing.T) { require.Equal(t, "USER root", lines[linesBefore+2], "RunAsUser must switch back to root") } + +// --------------------------------------------------------------------------- +// Property 57: --host-network-off controls network mode and sshd_config +// --------------------------------------------------------------------------- + +// Feature: bootstrap-ai-coding, Property 57: --host-network-off controls network mode and sshd_config +func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + port := rapid.IntRange(1024, 65535).Draw(t, "port") + hostNetworkOff := rapid.Bool().Draw(t, "hostNetworkOff") + + // Build instance image with drawn port and hostNetworkOff values + b := docker.NewInstanceImageBuilder( + testInfo(1000, 1000), + fixedPublicKey, + fixedHostKeyPriv, fixedHostKeyPub, + port, hostNetworkOff, + ) + content := b.Build() + + if !hostNetworkOff { + // When host network is used, sshd_config must contain Port and ListenAddress + require.Contains(t, content, fmt.Sprintf("Port %d", port), + "when !hostNetworkOff, sshd_config must contain Port %d", port) + require.Contains(t, content, "ListenAddress 127.0.0.1", + "when !hostNetworkOff, sshd_config must contain ListenAddress 127.0.0.1") + } else { + // When host network is off, sshd_config must NOT contain Port or ListenAddress directives + require.NotContains(t, content, "Port ", + "when hostNetworkOff, sshd_config must NOT contain Port directive") + require.NotContains(t, content, "ListenAddress", + "when hostNetworkOff, sshd_config must NOT contain ListenAddress directive") + } + }) +} diff --git a/internal/docker/client.go b/internal/docker/client.go index 77ab412..a09ecd4 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/pkg/stdcopy" @@ -140,6 +141,10 @@ func (c *Client) ImageRemove(ctx context.Context, imageID string, options image. return c.inner.ImageRemove(ctx, imageID, options) } +func (c *Client) ImagesPrune(ctx context.Context, pruneFilter filters.Args) (image.PruneReport, error) { + return c.inner.ImagesPrune(ctx, pruneFilter) +} + func (c *Client) ContainerExecCreate(ctx context.Context, containerID string, options container.ExecOptions) (container.ExecCreateResponse, error) { return c.inner.ContainerExecCreate(ctx, containerID, options) } diff --git a/internal/docker/export_test.go b/internal/docker/export_test.go new file mode 100644 index 0000000..36a1a99 --- /dev/null +++ b/internal/docker/export_test.go @@ -0,0 +1,10 @@ +package docker + +import dockerclient "github.com/docker/docker/client" + +// NewClientForTest creates a Client wrapping the given Docker SDK client. +// This is exported only for testing (via the _test.go convention) so that +// external test packages can inject a fake Docker client. +func NewClientForTest(inner *dockerclient.Client) *Client { + return &Client{inner: inner} +} diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index 4ac53d2..41c4102 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -33,8 +33,7 @@ var ( sharedImageTag string sharedClient *docker.Client sharedProjectDir string - sharedHostUID int - sharedHostGID int + sharedHostInfo *hostinfo.Info ) // TestMain ensures the base image is removed from the local Docker image store @@ -78,15 +77,14 @@ func buildSharedImage(t *testing.T) { info, err := hostinfo.Current() require.NoError(t, err, "getting host info") - sharedHostUID = info.UID - sharedHostGID = info.GID + sharedHostInfo = info sharedClient, err = docker.NewClient() require.NoError(t, err, "connecting to Docker daemon") strategy := docker.UserStrategyCreate conflictingUser := "" - conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, sharedHostUID, sharedHostGID) + conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, sharedHostInfo.UID, sharedHostInfo.GID) require.NoError(t, err, "checking base image for UID/GID conflicts") if conflictingImageUser != nil { strategy = docker.UserStrategyRename @@ -103,6 +101,7 @@ func buildSharedImage(t *testing.T) { info, userPubKey, hostKeyPriv, hostKeyPub, + 2222, true, ) instanceBuilder.Finalize() @@ -114,8 +113,7 @@ func buildSharedImage(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: builder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + HostInfo: sharedHostInfo, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) @@ -130,8 +128,7 @@ func buildSharedImage(t *testing.T) { {HostPath: sharedProjectDir, ContainerPath: constants.WorkspaceMountPath}, }, Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + HostInfo: sharedHostInfo, } _, err = docker.BuildImage(ctx, sharedClient, spec, false) @@ -157,15 +154,15 @@ func startContainerFromSharedImage(t *testing.T) (containerName string, sshPort containerName = constants.ContainerNamePrefix + sanitize(dirName) spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: sharedImageTag, + Name: containerName, + ImageTag: sharedImageTag, Mounts: []docker.Mount{ {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, }, - SSHPort: port, - Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + HostNetworkOff: true, } _, err = docker.CreateContainer(ctx, sharedClient, spec) @@ -406,8 +403,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: builder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -418,6 +414,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { info, userPubKey, hostKeyPriv, hostKeyPub, + 2222, false, ) instanceBuilder.Finalize() spec := docker.ContainerSpec{ @@ -429,8 +426,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { }, SSHPort: port, Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, spec, false) @@ -623,6 +619,333 @@ func TestContainerHostnameMatchesContainerName(t *testing.T) { require.Equal(t, 0, exitCode, "hostname command should exit 0") } +// ---------------------------------------------------------------------------- +// 9.1 TestHostNetworkModeSSHReachable +// Validates: Req 26 — host network mode SSH reachability +// ---------------------------------------------------------------------------- + +func TestHostNetworkModeSSHReachable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "hostnet-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a high port to avoid conflicts + sshPort := 22222 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with host network mode (hostNetworkOff=false) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, false) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + HostNetworkOff: false, // host network mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with host network mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with host network mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Assert: SSH is reachable on 127.0.0.1:sshPort + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in host network mode", sshPort) +} + +// ---------------------------------------------------------------------------- +// 9.3 TestHostNetworkCanReachHostService +// Validates: Req 26 — host network mode shares the host's network namespace +// ---------------------------------------------------------------------------- + +func TestHostNetworkCanReachHostService(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + // Step 1: Start a TCP listener on a random port on the host. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "starting TCP listener on host") + t.Cleanup(func() { ln.Close() }) + + // Extract the port from the listener address. + _, portStr, err := net.SplitHostPort(ln.Addr().String()) + require.NoError(t, err, "splitting host:port from listener address") + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "hostreach-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a high port for SSH to avoid conflicts + sshPort := 22224 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with host network mode (hostNetworkOff=false) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, false) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + HostNetworkOff: false, // host network mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with host network mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with host network mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Wait for the container to be running (SSH ready) + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in host network mode", sshPort) + + // Step 5: Use bash /dev/tcp trick to test connectivity to the host listener. + // This works without netcat being installed. + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "bash", "-c", fmt.Sprintf("echo > /dev/tcp/127.0.0.1/%s", portStr), + }) + require.NoError(t, err, "exec to test connectivity to host service on port %s", portStr) + require.Equal(t, 0, exitCode, + "container in host network mode must be able to reach host service on 127.0.0.1:%s", portStr) +} + +// ---------------------------------------------------------------------------- +// 9.2 TestBridgeModeSSHReachable +// Validates: Req 26 — bridge mode (hostNetworkOff=true) SSH reachability +// ---------------------------------------------------------------------------- + +func TestBridgeModeSSHReachable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "bridge-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a different high port to avoid conflicts with host network test + sshPort := 22223 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with bridge mode (hostNetworkOff=true) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, true) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + HostNetworkOff: true, // bridge mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with bridge mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with bridge mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Assert: SSH is reachable on 127.0.0.1:sshPort + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in bridge mode", sshPort) +} + // ---------------------------------------------------------------------------- // Internal helpers // ---------------------------------------------------------------------------- @@ -741,8 +1064,7 @@ func TestTwoLayerBuildCycle(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: baseLabels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -759,7 +1081,7 @@ func TestTwoLayerBuildCycle(t *testing.T) { // ------------------------------------------------------------------------- // Subtask 2: Build instance image FROM base, verify it exists with correct labels // ------------------------------------------------------------------------- - instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, port, true) instanceBuilder.Finalize() instanceLabels := map[string]string{ @@ -767,16 +1089,16 @@ func TestTwoLayerBuildCycle(t *testing.T) { "bac.container": containerName, } instanceSpec := docker.ContainerSpec{ - Name: containerName, - ImageTag: instanceImageTag, - Dockerfile: instanceBuilder.Build(), + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), Mounts: []docker.Mount{ {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, }, - SSHPort: port, - Labels: instanceLabels, - HostUID: info.UID, - HostGID: info.GID, + SSHPort: port, + Labels: instanceLabels, + HostInfo: info, + HostNetworkOff: true, } _, err = docker.BuildImage(ctx, client, instanceSpec, false) diff --git a/internal/docker/runner.go b/internal/docker/runner.go index 40f9aac..f6d8236 100644 --- a/internal/docker/runner.go +++ b/internal/docker/runner.go @@ -21,6 +21,7 @@ import ( "github.com/docker/go-connections/nat" "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" ) // Mount represents a single Docker bind mount. @@ -32,16 +33,16 @@ type Mount struct { // ContainerSpec is the fully resolved specification for a container. type ContainerSpec struct { - Name string // Deterministic container name (bac-<12hex>) - ImageTag string // Docker image tag (derived from container name) - Dockerfile string // Complete Dockerfile content (assembled by DockerfileBuilder) - Mounts []Mount // All bind mounts: /workspace + per-agent credential stores - SSHPort int // Host-side TCP port mapped to container port 22 - Labels map[string]string // Docker labels for identification - HostUID int // Host user UID (passed as build arg for dev user) - HostGID int // Host user GID (passed as build arg for dev user) - NoCache bool // When true, disable Docker layer cache during image build - RestartPolicy string // Docker restart policy (e.g. "unless-stopped"); empty means use default + Name string // Deterministic container name (bac-<12hex>) + ImageTag string // Docker image tag (derived from container name) + Dockerfile string // Complete Dockerfile content (assembled by DockerfileBuilder) + Mounts []Mount // All bind mounts: /workspace + per-agent credential stores + SSHPort int // SSH port (used in sshd_config for host mode, or Docker port mapping for bridge mode) + Labels map[string]string // Docker labels for identification + HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) + NoCache bool // When true, disable Docker layer cache during image build + HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network + RestartPolicy string // Docker restart policy (e.g. "unless-stopped"); empty means use default } func buildContextFromDockerfile(dockerfile string) (io.Reader, error) { @@ -162,14 +163,6 @@ func ResolveRestartPolicy(spec ContainerSpec) string { // CreateContainer creates a container from the given spec. func CreateContainer(ctx context.Context, c *Client, spec ContainerSpec) (string, error) { - sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) - portBindings := nat.PortMap{ - sshPort: []nat.PortBinding{ - {HostIP: constants.HostBindIP, HostPort: fmt.Sprintf("%d", spec.SSHPort)}, - }, - } - exposedPorts := nat.PortSet{sshPort: struct{}{}} - var mounts []mount.Mount for _, m := range spec.Mounts { mounts = append(mounts, mount.Mount{ @@ -182,19 +175,37 @@ func CreateContainer(ctx context.Context, c *Client, spec ContainerSpec) (string restartPolicy := ResolveRestartPolicy(spec) + containerCfg := &container.Config{ + Image: spec.ImageTag, + Hostname: spec.Name, + Labels: spec.Labels, + } + + hostCfg := &container.HostConfig{ + Mounts: mounts, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(restartPolicy)}, + } + + if !spec.HostNetworkOff { + // Host network mode (default): share the host's network namespace. + // Do NOT set PortBindings or ExposedPorts — they are ignored in host + // network mode and would produce a Docker warning. + hostCfg.NetworkMode = container.NetworkMode("host") + } else { + // Bridge mode: map container SSH port to host port. + sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) + hostCfg.PortBindings = nat.PortMap{ + sshPort: []nat.PortBinding{ + {HostIP: constants.HostBindIP, HostPort: fmt.Sprintf("%d", spec.SSHPort)}, + }, + } + containerCfg.ExposedPorts = nat.PortSet{sshPort: struct{}{}} + } + resp, err := c.ContainerCreate( ctx, - &container.Config{ - Image: spec.ImageTag, - Hostname: spec.Name, - Labels: spec.Labels, - ExposedPorts: exposedPorts, - }, - &container.HostConfig{ - PortBindings: portBindings, - Mounts: mounts, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(restartPolicy)}, - }, + containerCfg, + hostCfg, nil, nil, spec.Name, diff --git a/internal/docker/runner_network_test.go b/internal/docker/runner_network_test.go new file mode 100644 index 0000000..3826ae7 --- /dev/null +++ b/internal/docker/runner_network_test.go @@ -0,0 +1,140 @@ +package docker_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/docker/docker/api/types/container" + dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" +) + +// createRequest captures the JSON body sent to the Docker daemon's +// /containers/create endpoint. The Docker SDK sends container.Config and +// container.HostConfig as a combined JSON payload. +type createRequest struct { + HostConfig container.HostConfig `json:"HostConfig"` + // ExposedPorts in the container config + ExposedPorts nat.PortSet `json:"ExposedPorts"` +} + +// newFakeDockerClient creates a *docker.Client backed by a fake HTTP server +// that captures the ContainerCreate request body. The returned channel receives +// the decoded createRequest when ContainerCreate is called. +func newFakeDockerClient(t *testing.T) (*docker.Client, chan createRequest) { + t.Helper() + ch := make(chan createRequest, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Match the containers/create endpoint (path includes version prefix) + if r.Method == http.MethodPost && r.URL.Path[len(r.URL.Path)-18:] == "/containers/create" { + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode create request: %v", err) + } + ch <- req + // Return a valid CreateResponse + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"Id":"fake-container-id","Warnings":[]}`) + return + } + // Default: return 200 for version negotiation (_ping, version, etc.) + if r.URL.Path == "/_ping" || r.URL.Path == "/v1.47/_ping" { + w.Header().Set("Api-Version", "1.47") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{}`) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // Create a Docker SDK client pointing at our fake server. + inner, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost(srv.URL), + dockerclient.WithHTTPClient(srv.Client()), + dockerclient.WithAPIVersionNegotiation(), + ) + require.NoError(t, err) + + // Wrap in our Client type using the exported constructor pattern. + // Since Client wraps the inner client, we use NewClientFromInner (test helper). + client := docker.NewClientForTest(inner) + return client, ch +} + +// TestCreateContainerHostNetworkMode verifies that CreateContainer sets +// NetworkMode: "host" and no port bindings when HostNetworkOff == false. +func TestCreateContainerHostNetworkMode(t *testing.T) { + client, ch := newFakeDockerClient(t) + + spec := docker.ContainerSpec{ + Name: "bac-test-host", + ImageTag: "bac-test:latest", + SSHPort: 2222, + HostNetworkOff: false, + Labels: map[string]string{"bac.managed": "true"}, + } + + id, err := docker.CreateContainer(context.Background(), client, spec) + require.NoError(t, err) + require.Equal(t, "fake-container-id", id) + + req := <-ch + require.Equal(t, container.NetworkMode("host"), req.HostConfig.NetworkMode, + "HostNetworkOff=false must set NetworkMode to 'host'") + require.Empty(t, req.HostConfig.PortBindings, + "HostNetworkOff=false must not set PortBindings") + require.Empty(t, req.ExposedPorts, + "HostNetworkOff=false must not set ExposedPorts") +} + +// TestCreateContainerBridgeMode verifies that CreateContainer uses port bindings +// and does not set NetworkMode: "host" when HostNetworkOff == true. +func TestCreateContainerBridgeMode(t *testing.T) { + client, ch := newFakeDockerClient(t) + + spec := docker.ContainerSpec{ + Name: "bac-test-bridge", + ImageTag: "bac-test:latest", + SSHPort: 3333, + HostNetworkOff: true, + Labels: map[string]string{"bac.managed": "true"}, + } + + id, err := docker.CreateContainer(context.Background(), client, spec) + require.NoError(t, err) + require.Equal(t, "fake-container-id", id) + + req := <-ch + + // NetworkMode should NOT be "host" + require.NotEqual(t, container.NetworkMode("host"), req.HostConfig.NetworkMode, + "HostNetworkOff=true must not set NetworkMode to 'host'") + + // PortBindings should map ContainerSSHPort/tcp → HostBindIP:SSHPort + sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) + bindings, ok := req.HostConfig.PortBindings[sshPort] + require.True(t, ok, "HostNetworkOff=true must set PortBindings for %s", sshPort) + require.Len(t, bindings, 1) + require.Equal(t, constants.HostBindIP, bindings[0].HostIP) + require.Equal(t, fmt.Sprintf("%d", spec.SSHPort), bindings[0].HostPort) + + // ExposedPorts should include the SSH port + _, exposed := req.ExposedPorts[sshPort] + require.True(t, exposed, "HostNetworkOff=true must set ExposedPorts for %s", sshPort) +}