diff --git a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md index 3b4a33e..0ba8072 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md +++ b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md @@ -199,6 +199,7 @@ RUN useradd ← stable per user RUN sudoers ← stable RUN dbus-x11 gnome-keyring libsecret-1-0 ← keyring (CC-7) RUN /etc/profile.d/dbus-keyring.sh ← keyring startup +RUN /etc/profile.d/workspace-cd.sh ← workspace cd-on-login (Req 27) RUN gitconfig ← git config (Req 24) RUN curl ca-certificates git + nodejs ← Claude/Augment shared deps RUN npm install -g @anthropic-ai/claude-code ← Claude Code diff --git a/.kiro/specs/bootstrap-ai-coding/design-components.md b/.kiro/specs/bootstrap-ai-coding/design-components.md index 984b58b..a780ae6 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-components.md +++ b/.kiro/specs/bootstrap-ai-coding/design-components.md @@ -241,6 +241,22 @@ This script runs on every SSH login (interactive shells source `/etc/profile.d/* --- +## Workspace Working Directory (Req 27) + +A shell profile script (`/etc/profile.d/workspace-cd.sh`) is installed that changes the working directory to `/workspace` on SSH login, so the user lands directly in their project directory without a manual `cd`. + +```sh +#!/bin/sh +# /etc/profile.d/workspace-cd.sh — land in the workspace directory on SSH login +cd /workspace 2>/dev/null || true +``` + +The `2>/dev/null || true` suppresses errors if `/workspace` is not mounted (defensive; in practice the mount is always present). Like the keyring script, this runs on every interactive SSH login via the `/etc/profile.d/*.sh` sourcing mechanism. + +**Validates: Req 27** + +--- + ## Git Configuration Forwarding (Req 24) The `DockerfileBuilder` injects the host user's `~/.gitconfig` into the container image at build time, following the same pattern as SSH host key injection (step 6 in the constructor). The git config content is read by the caller (`cmd/root.go`) and passed to the builder as an optional string parameter. diff --git a/.kiro/specs/bootstrap-ai-coding/design-properties.md b/.kiro/specs/bootstrap-ai-coding/design-properties.md index 2cc2358..d17235f 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-properties.md +++ b/.kiro/specs/bootstrap-ai-coding/design-properties.md @@ -337,6 +337,14 @@ --- +#### Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript + +*For any* UID/GID combination and user strategy (Create or Rename), the Base_Image Dockerfile produced by `NewBaseImageBuilder` SHALL contain a `RUN` instruction that creates a script at `constants.WorkspaceProfileScript` (`/etc/profile.d/workspace-cd.sh`) which references `constants.WorkspaceMountPath` (`/workspace`) and is made executable (`chmod +x`). + +**Validates: Req 27.1, 27.2, 27.3** + +--- + ### Agent Module Properties #### Property 27: All registered agents satisfy the Agent interface diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 7d27177..c307c40 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -462,3 +462,17 @@ The core application is responsible for all orchestration: Docker lifecycle mana 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. + +--- + +### Requirement 27: Workspace as Default Working Directory on SSH Login + +**User Story:** As a developer, I want to land directly in `/workspace` when I SSH into the container, so that I can start working on my project immediately without navigating there manually. + +#### Acceptance Criteria + +1. WHEN a user connects to the Container via SSH, THE shell session SHALL have its working directory set to `constants.WorkspaceMountPath` (`/workspace`). +2. THE working directory change SHALL be implemented via a shell profile script at `constants.WorkspaceProfileScript` (`/etc/profile.d/workspace-cd.sh`) installed in the Base_Image layer. +3. THE profile script SHALL fail silently (no error output, no non-zero exit) if `/workspace` is not accessible. +4. THE Container_User's home directory SHALL remain unchanged — only the initial working directory of the SSH login session is affected. +5. THE integration test SHALL verify this requirement via an actual SSH connection (using `golang.org/x/crypto/ssh`) — not via `docker exec` or `su -l` — to confirm the real user path works end-to-end. diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index e963435..e6fcca3 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -47,6 +47,7 @@ This means: | `BaseImageTag` | `"bac-base:latest"` | Full base image reference (TL-11) | | `GitConfigPerm` | `0o444` | Injected .gitconfig permissions (Req 24) | | `KeyringProfileScript` | `"/etc/profile.d/dbus-keyring.sh"` | Keyring startup script path (CC-7) | +| `WorkspaceProfileScript` | `"/etc/profile.d/workspace-cd.sh"` | Workspace cd-on-login script path (Req 27) | | `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.7) | ### Variables (not const — Go does not support slice/map constants) diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index 3007c73..d516971 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -98,3 +98,4 @@ import ( - Default agents: `claude-code,augment-code,build-resources,open-code,codex` (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) +- Workspace landing directory: `/etc/profile.d/workspace-cd.sh` changes the working directory to `/workspace` on SSH login (Req 27) diff --git a/README.md b/README.md index 82ac779..5bb3743 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # bootstrap-ai-coding -`bootstrap-ai-coding` (`bac`) is a Go CLI tool that provisions an isolated Docker container for AI-assisted coding sessions. +`bootstrap-ai-coding` (`bac`) is a Go CLI tool that provisions an isolated Docker container for AI-assisted coding sessions. Your agent credentials are bind-mounted from the host — no login or settings transfer needed, just run and code. -Primarily designed to work with Visual Studio Code but it works with any IDE with code-over-ssh. +Primarily designed to work with Codium and Visual Studio Code but it works with any IDE with code-over-ssh. ## Install @@ -18,7 +18,7 @@ wget https://github.com/koudis/bootstrap-ai-coding/releases/latest/download/bac- 1. `bac ` 2. Open Visual Studio Code, press Ctrl+Shift+P, run Remote-SSH and choose `bac-` target to connect. -3. `` can be found under `/workspace` +3. Your shell starts in `/workspace` — your mounted project directory, ready to go. where `project_folder_name` is the name of the bottom-most folder in `project_path`. (`/my/nice/project` → `project`) @@ -43,7 +43,7 @@ SSH connect: ssh bac-myproject Enabled agents: claude-code, augment-code, build-resources ``` -After that, `ssh bac-myproject` works — no port or username to remember. +After that, `ssh bac-myproject` works — no port or username to remember. You land directly in `/workspace` (your mounted project directory). ## Prerequisites diff --git a/internal/constants/constants.go b/internal/constants/constants.go index f982725..48e10b0 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -117,6 +117,10 @@ const ( // Satisfies CC-7. KeyringProfileScript = "/etc/profile.d/dbus-keyring.sh" + // WorkspaceProfileScript is the path to the shell profile script inside the + // container that changes the working directory to WorkspaceMountPath on SSH login. + WorkspaceProfileScript = "/etc/profile.d/workspace-cd.sh" + // HostBindIP is the IP address containers bind their SSH port to on the host. // Satisfies R7. HostBindIP = "127.0.0.1" diff --git a/internal/docker/builder.go b/internal/docker/builder.go index 4a6bc9a..0214863 100644 --- a/internal/docker/builder.go +++ b/internal/docker/builder.go @@ -116,7 +116,12 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, conflicting b.Run(fmt.Sprintf("printf '%s' > %s && chmod +x %s", keyringScript, constants.KeyringProfileScript, constants.KeyringProfileScript)) - // 7. Inject host user's ~/.gitconfig into the container (Req 24). + // 7. Install profile.d script that changes to the workspace directory on SSH login. + workspaceCdScript := fmt.Sprintf(`#!/bin/sh\ncd %s 2>/dev/null || true\n`, constants.WorkspaceMountPath) + b.Run(fmt.Sprintf("printf '%s' > %s && chmod +x %s", + workspaceCdScript, constants.WorkspaceProfileScript, constants.WorkspaceProfileScript)) + + // 8. Inject host user's ~/.gitconfig into the container (Req 24). if b.gitConfig != "" { encoded := base64.StdEncoding.EncodeToString([]byte(b.gitConfig)) gitConfigPath := fmt.Sprintf("%s/.gitconfig", info.HomeDir) diff --git a/internal/docker/builder_test.go b/internal/docker/builder_test.go index c897200..949c4a8 100644 --- a/internal/docker/builder_test.go +++ b/internal/docker/builder_test.go @@ -582,6 +582,42 @@ func TestKeyringProfileScriptPresentInRenameStrategy(t *testing.T) { "Rename strategy Dockerfile must also create keyring profile script") } +// --------------------------------------------------------------------------- +// Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript +// --------------------------------------------------------------------------- + +// Feature: bootstrap-ai-coding, Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript +// Validates: Req 27 +func TestPropertyWorkspaceProfileScriptCreated(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + uid := rapid.IntRange(1000, 65000).Draw(t, "uid") + gid := rapid.IntRange(1000, 65000).Draw(t, "gid") + + b := newCreateBuilder(uid, gid) + content := b.Build() + + require.Contains(t, content, constants.WorkspaceProfileScript, + "Dockerfile must reference WorkspaceProfileScript path %q", constants.WorkspaceProfileScript) + require.Contains(t, content, constants.WorkspaceMountPath, + "Workspace script must reference WorkspaceMountPath %q", constants.WorkspaceMountPath) + require.Contains(t, content, "chmod +x "+constants.WorkspaceProfileScript, + "Workspace script must be made executable") + }) +} + +// TestWorkspaceProfileScriptPresentInRenameStrategy verifies that the workspace-cd +// setup is also present when using UserStrategyRename. +// Validates: Req 27 +func TestWorkspaceProfileScriptPresentInRenameStrategy(t *testing.T) { + b := newRenameBuilder(1000, 1000, "ubuntu") + content := b.Build() + + require.Contains(t, content, constants.WorkspaceProfileScript, + "Rename strategy Dockerfile must also create workspace profile script") + require.Contains(t, content, constants.WorkspaceMountPath, + "Rename strategy Dockerfile must reference WorkspaceMountPath in workspace script") +} + // --------------------------------------------------------------------------- // Node.js deduplication tracking tests // --------------------------------------------------------------------------- diff --git a/internal/docker/integration_container_test.go b/internal/docker/integration_container_test.go new file mode 100644 index 0000000..e066426 --- /dev/null +++ b/internal/docker/integration_container_test.go @@ -0,0 +1,130 @@ +//go:build integration + +package docker_test + +import ( + "context" + "fmt" + "net" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" +) + +// ---------------------------------------------------------------------------- +// 16.1 TestContainerStartsAndSSHConnects +// Validates: Req 3.3, 4.3 +// ---------------------------------------------------------------------------- + +func TestContainerStartsAndSSHConnects(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + _, sshPort, _, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + addr := fmt.Sprintf("127.0.0.1:%d", sshPort) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + require.NoError(t, err, "expected TCP connection to SSH port %d to succeed", sshPort) + conn.Close() +} + +// ---------------------------------------------------------------------------- +// 16.2 TestWorkspaceMountLiveSync +// Validates: Req 2.3 +// ---------------------------------------------------------------------------- + +func TestWorkspaceMountLiveSync(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + containerName, _, client, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + ctx := context.Background() + + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "bash", "-c", "echo 'hello from container' > /workspace/sync-test.txt", + }) + require.NoError(t, err, "exec to create file in /workspace") + require.Equal(t, 0, exitCode, "expected exit 0 when creating file in /workspace") + + exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{ + "test", "-f", constants.WorkspaceMountPath + "/sync-test.txt", + }) + require.NoError(t, err, "exec to verify file in /workspace") + require.Equal(t, 0, exitCode, "expected file to exist at %s/sync-test.txt", constants.WorkspaceMountPath) +} + +// ---------------------------------------------------------------------------- +// 16.3 TestFileOwnershipMatchesHostUser +// Validates: Req 10.6 +// ---------------------------------------------------------------------------- + +func TestFileOwnershipMatchesHostUser(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + containerName, _, client, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + ctx := context.Background() + + info, err := hostinfo.Current() + require.NoError(t, err) + + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "su", "-c", "touch /workspace/ownership-test.txt", info.Username, + }) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "expected exit 0 when creating file") + + checkUID := fmt.Sprintf(`[ "$(stat -c '%%u' /workspace/ownership-test.txt)" = "%d" ]`, info.UID) + exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkUID}) + require.NoError(t, err, "exec to check file UID") + require.Equal(t, 0, exitCode, + "expected file UID inside container to match host user UID=%d", info.UID) + + checkGID := fmt.Sprintf(`[ "$(stat -c '%%g' /workspace/ownership-test.txt)" = "%d" ]`, info.GID) + exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkGID}) + require.NoError(t, err, "exec to check file GID") + require.Equal(t, 0, exitCode, + "expected file GID inside container to match host user GID=%d", info.GID) +} + +// ---------------------------------------------------------------------------- +// 16.12 TestContainerHostnameMatchesContainerName +// Validates: Req 23.1, 23.2 +// ---------------------------------------------------------------------------- + +func TestContainerHostnameMatchesContainerName(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + containerName, _, client, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + ctx := context.Background() + + // Verify via container inspect that the hostname is set correctly. + info, err := docker.InspectContainer(ctx, client, containerName) + require.NoError(t, err, "inspecting container") + require.NotNil(t, info, "container should exist") + require.Equal(t, containerName, info.Config.Hostname, + "container hostname should match container name") + + // Also verify by running `hostname` inside the container. + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{"hostname"}) + require.NoError(t, err, "exec hostname command") + require.Equal(t, 0, exitCode, "hostname command should exit 0") +} diff --git a/internal/docker/integration_lifecycle_test.go b/internal/docker/integration_lifecycle_test.go new file mode 100644 index 0000000..e86f568 --- /dev/null +++ b/internal/docker/integration_lifecycle_test.go @@ -0,0 +1,298 @@ +//go:build integration + +package docker_test + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" +) + +// ---------------------------------------------------------------------------- +// 16.4 TestCredentialVolumePersistedAcrossRestart +// Validates: Req 8.6 +// ---------------------------------------------------------------------------- + +func TestCredentialVolumePersistedAcrossRestart(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + containerName, sshPort, client, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + ctx := context.Background() + + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "bash", "-c", "echo 'persistent' > /workspace/persist-test.txt", + }) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "expected exit 0 when writing sentinel file") + + err = docker.StopContainer(ctx, client, containerName) + require.NoError(t, err, "stopping container") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "restarting container") + + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 30*time.Second) + require.NoError(t, err, "waiting for SSH after restart") + + exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{ + "test", "-f", "/workspace/persist-test.txt", + }) + require.NoError(t, err) + require.Equal(t, 0, exitCode, "expected sentinel file to persist across container restart") +} + +// ---------------------------------------------------------------------------- +// 16.7 TestPurgeRemovesContainersAndImages +// Validates: Req 16.2, 16.4 +// ---------------------------------------------------------------------------- + +func TestPurgeRemovesContainersAndImages(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + containerName, _, _, _ := startContainerFromSharedImage(t) + // Note: we do NOT register the cleanup here because we are testing purge. + + client, err := docker.NewClient() + require.NoError(t, err) + + // Verify the container exists before purge. + info, err := docker.InspectContainer(ctx, client, containerName) + require.NoError(t, err) + require.NotNil(t, info, "container should exist before purge") + + // Run purge logic: stop + remove container. + err = docker.StopContainer(ctx, client, containerName) + require.NoError(t, err, "stopping container during purge") + + err = docker.RemoveContainer(ctx, client, containerName) + require.NoError(t, err, "removing container during purge") + + // Assert container is gone. + info, err = docker.InspectContainer(ctx, client, containerName) + require.NoError(t, err) + require.Nil(t, info, "container should be gone after purge") +} + +// ---------------------------------------------------------------------------- +// 11.1–11.5 TestTwoLayerBuildCycle +// Validates: TL-1, TL-2, TL-5, TL-6 +// Full two-layer build cycle: base → instance → container → stop → rebuild +// ---------------------------------------------------------------------------- + +func TestTwoLayerBuildCycle(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 + "twolayer-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + port, err := findFreePort() + require.NoError(t, err, "finding free port") + + // 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 + } + + // Manifest for the base image label + manifestJSON := `["test-agent"]` + + // Cleanup: remove images and container at end of test + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + // Remove instance image + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + // Note: we do NOT remove bac-base:latest here because other tests may use it. + // The shared buildSharedImage() already builds it; removing it would break other tests. + }) + + // ------------------------------------------------------------------------- + // Subtask 1: Build base image, verify it exists with correct labels + // ------------------------------------------------------------------------- + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseLabels := map[string]string{ + "bac.managed": "true", + "bac.manifest": manifestJSON, + } + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: baseLabels, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Inspect base image and verify labels + baseInspect, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) + require.NoError(t, err, "inspecting base image") + require.Equal(t, "true", baseInspect.Config.Labels["bac.managed"], + "base image must have bac.managed=true label") + require.Equal(t, manifestJSON, baseInspect.Config.Labels["bac.manifest"], + "base image must have bac.manifest label with correct JSON") + + // ------------------------------------------------------------------------- + // Subtask 2: Build instance image FROM base, verify it exists with correct labels + // ------------------------------------------------------------------------- + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, port, true) + instanceBuilder.Finalize() + + instanceLabels := map[string]string{ + "bac.managed": "true", + "bac.container": containerName, + } + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: port, + Labels: instanceLabels, + HostInfo: info, + HostNetworkOff: true, + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image") + + // Inspect instance image and verify labels + instanceInspect, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) + require.NoError(t, err, "inspecting instance image") + require.Equal(t, "true", instanceInspect.Config.Labels["bac.managed"], + "instance image must have bac.managed=true label") + require.Equal(t, containerName, instanceInspect.Config.Labels["bac.container"], + "instance image must have bac.container= label") + + // ------------------------------------------------------------------------- + // Subtask 3: Start container from instance image, verify SSH connectivity + // ------------------------------------------------------------------------- + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container from instance image") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) + require.NoError(t, err, "waiting for SSH to be ready in two-layer container") + + // Verify sshd is running inside the container + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "pgrep", "-x", "sshd", + }) + require.NoError(t, err, "exec pgrep sshd") + require.Equal(t, 0, exitCode, "sshd should be running inside the container") + + // ------------------------------------------------------------------------- + // Subtask 4: Stop and remove container — verify both images still exist + // ------------------------------------------------------------------------- + err = docker.StopContainer(ctx, client, containerName) + require.NoError(t, err, "stopping container") + + err = docker.RemoveContainer(ctx, client, containerName) + require.NoError(t, err, "removing container") + + // Verify container is gone + containerInfo, err := docker.InspectContainer(ctx, client, containerName) + require.NoError(t, err) + require.Nil(t, containerInfo, "container should be gone after removal") + + // Verify base image still exists + _, _, err = client.ImageInspectWithRaw(ctx, constants.BaseImageTag) + require.NoError(t, err, "base image must still exist after container removal") + + // Verify instance image still exists + _, _, err = client.ImageInspectWithRaw(ctx, instanceImageTag) + require.NoError(t, err, "instance image must still exist after container removal") + + // ------------------------------------------------------------------------- + // Subtask 5: Rebuild (--rebuild equivalent) — verify both images are recreated + // ------------------------------------------------------------------------- + + // Record the image IDs before rebuild + baseBeforeRebuild, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) + require.NoError(t, err) + baseIDBeforeRebuild := baseBeforeRebuild.ID + + instanceBeforeRebuild, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) + require.NoError(t, err) + instanceIDBeforeRebuild := instanceBeforeRebuild.ID + + // Rebuild base with NoCache: true (simulating --rebuild) + baseSpec.NoCache = true + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "rebuilding base image with no-cache") + + // Rebuild instance (inherits fresh base) + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "rebuilding instance image after base rebuild") + + // Verify both images were recreated (different IDs) + baseAfterRebuild, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) + require.NoError(t, err) + require.NotEqual(t, baseIDBeforeRebuild, baseAfterRebuild.ID, + "base image ID must change after no-cache rebuild") + + instanceAfterRebuild, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) + require.NoError(t, err) + require.NotEqual(t, instanceIDBeforeRebuild, instanceAfterRebuild.ID, + "instance image ID must change after rebuild") + + // Verify labels are still correct after rebuild + require.Equal(t, "true", baseAfterRebuild.Config.Labels["bac.managed"]) + require.Equal(t, manifestJSON, baseAfterRebuild.Config.Labels["bac.manifest"]) + require.Equal(t, "true", instanceAfterRebuild.Config.Labels["bac.managed"]) + require.Equal(t, containerName, instanceAfterRebuild.Config.Labels["bac.container"]) +} diff --git a/internal/docker/integration_main_test.go b/internal/docker/integration_main_test.go new file mode 100644 index 0000000..d9af89b --- /dev/null +++ b/internal/docker/integration_main_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package docker_test + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + dockerimage "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" + "github.com/koudis/bootstrap-ai-coding/internal/testutil" +) + +// ---------------------------------------------------------------------------- +// Package-level shared image state — built once in TestMain, reused by tests. +// ---------------------------------------------------------------------------- + +var ( + sharedImageTag string + sharedClient *docker.Client + sharedProjectDir string + sharedHostInfo *hostinfo.Info + sharedUserPrivKey string // PEM-encoded private key for SSH auth in tests +) + +// TestMain ensures the base image is removed from the local Docker image store +// before the integration suite runs (for TestAFindConflictingUserPullsImageIfAbsent), +// then builds a shared image that most tests reuse. +func TestMain(m *testing.M) { + if _, err := exec.LookPath("docker"); err != nil { + os.Exit(m.Run()) + } + + testutil.RequireIntegrationConsent() + + if err := testutil.EnsureBaseImageAbsent(); err != nil { + fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +// buildSharedImage builds the shared image once (idempotent). Tests that need +// a container call this, then create their own container from the shared image. +// The image is built on first call; subsequent calls are no-ops. +func buildSharedImage(t *testing.T) { + t.Helper() + + if sharedImageTag != "" { + return // already built + } + + ctx := context.Background() + + var err error + sharedProjectDir = t.TempDir() + + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + userPrivKey, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + sharedUserPrivKey = userPrivKey + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + sharedHostInfo = info + + sharedClient, err = docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + strategy := docker.UserStrategyCreate + conflictingUser := "" + 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 + conflictingUser = conflictingImageUser.Username + } + + builder := docker.NewBaseImageBuilder( + info, + strategy, conflictingUser, + "", + ) + + instanceBuilder := docker.NewInstanceImageBuilder( + info, + userPubKey, + hostKeyPriv, hostKeyPub, + 2222, true, + ) + instanceBuilder.Finalize() + + sharedImageTag = constants.ContainerNamePrefix + "integration-shared:latest" + + // Build base image first + baseSpec := docker.ContainerSpec{ + Name: constants.ContainerNamePrefix + "integration-shared", + ImageTag: constants.BaseImageTag, + Dockerfile: builder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + } + + _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image from base + spec := docker.ContainerSpec{ + Name: constants.ContainerNamePrefix + "integration-shared", + ImageTag: sharedImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: sharedProjectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + } + + _, err = docker.BuildImage(ctx, sharedClient, spec, false) + require.NoError(t, err, "building shared container image") +} + +// startContainerFromSharedImage creates and starts a new container from the +// shared image with a unique name and port. Returns the container name, port, +// client, and cleanup function. +func startContainerFromSharedImage(t *testing.T) (containerName string, sshPort int, client *docker.Client, cleanup func()) { + t.Helper() + + buildSharedImage(t) + + ctx := context.Background() + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + + port, err := findFreePort() + require.NoError(t, err, "finding free port") + + containerName = constants.ContainerNamePrefix + sanitize(dirName) + + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: sharedImageTag, + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + HostNetworkOff: true, + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + require.NoError(t, err, "creating container") + + err = docker.StartContainer(ctx, sharedClient, containerName) + require.NoError(t, err, "starting container") + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) + require.NoError(t, err, "waiting for SSH to be ready") + + cleanup = func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, sharedClient, containerName) + _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) + } + + return containerName, port, sharedClient, cleanup +} + +// ---------------------------------------------------------------------------- +// Internal helpers +// ---------------------------------------------------------------------------- + +func findFreePort() (int, error) { + for port := constants.SSHPortStart; port < 65535; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) +} + +func sanitize(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "tmp" + } + return result +} + +func forceRemoveOpts() dockerimage.RemoveOptions { + return dockerimage.RemoveOptions{Force: true} +} diff --git a/internal/docker/integration_misc_test.go b/internal/docker/integration_misc_test.go new file mode 100644 index 0000000..dd304eb --- /dev/null +++ b/internal/docker/integration_misc_test.go @@ -0,0 +1,172 @@ +//go:build integration + +package docker_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" +) + +// ---------------------------------------------------------------------------- +// TestBuildImageTimeoutEnforced +// Validates: Req 14.7 (Image_Build_Timeout) +// ---------------------------------------------------------------------------- + +const testBuildTimeout = 3 * time.Second + +func TestBuildImageTimeoutEnforced(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") + + hangingDockerfile := fmt.Sprintf("FROM %s\nRUN sleep 300\n", constants.BaseContainerImage) + + containerName := constants.ContainerNamePrefix + "timeout-test" + imageTag := containerName + ":latest" + + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: imageTag, + Dockerfile: hangingDockerfile, + Labels: map[string]string{"bac.managed": "true"}, + } + + t.Cleanup(func() { + cleanCtx := context.Background() + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == imageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + _, err = docker.BuildImageWithTimeout(ctx, client, spec, testBuildTimeout, false) + + require.Error(t, err, "BuildImageWithTimeout must return an error when the build exceeds the timeout") + require.Contains(t, err.Error(), "timed out", + "error message must mention 'timed out'; got: %v", err) +} + +// ---------------------------------------------------------------------------- +// TestAFindConflictingUserPullsImageIfAbsent +// Validates: Req 10a.1 — FindConflictingUser must succeed even when the base +// image is not present in the local Docker image store. +// +// Named with "A" prefix so Go's alphabetical test ordering runs this first. +// The base image is guaranteed absent by TestMain's call to +// EnsureBaseImageAbsent(), so this test simply calls FindConflictingUser and +// asserts it succeeds (pulling the image automatically). +// ---------------------------------------------------------------------------- + +func TestAFindConflictingUserPullsImageIfAbsent(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) + + result, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, + "FindConflictingUser must succeed even when the base image is not cached locally") + _ = result + + _, _, err = client.ImageInspectWithRaw(ctx, constants.BaseContainerImage) + require.NoError(t, err, + "base image should be present locally after FindConflictingUser pulls it") +} + +// ---------------------------------------------------------------------------- +// TestReadOnlyFileMountIsReadableButNotWritable +// Validates: CC-8 (read-only bind-mount of ~/.claude.json) — core mount plumbing +// ---------------------------------------------------------------------------- + +func TestReadOnlyFileMountIsReadableButNotWritable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + buildSharedImage(t) + + ctx := context.Background() + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + + // Create a temporary file to mount read-only into the container. + hostFile := filepath.Join(t.TempDir(), "config.json") + err := os.WriteFile(hostFile, []byte(`{"test":"read-only-mount"}`), 0o644) + require.NoError(t, err, "creating host file for RO mount") + + port, err := findFreePort() + require.NoError(t, err, "finding free port") + + containerName := constants.ContainerNamePrefix + sanitize(dirName) + "-ro" + containerFilePath := filepath.Join(sharedHostInfo.HomeDir, ".config-test.json") + + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: sharedImageTag, + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + {HostPath: hostFile, ContainerPath: containerFilePath, ReadOnly: true}, + }, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + HostNetworkOff: true, + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + require.NoError(t, err, "creating container with RO file mount") + + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, sharedClient, containerName) + _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) + }) + + err = docker.StartContainer(ctx, sharedClient, containerName) + require.NoError(t, err, "starting container with RO file mount") + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) + require.NoError(t, err, "waiting for SSH to be ready") + + // Verify the file is readable inside the container. + exitCode, err := docker.ExecInContainer(ctx, sharedClient, containerName, []string{ + "cat", containerFilePath, + }) + require.NoError(t, err, "exec cat on RO-mounted file") + require.Equal(t, 0, exitCode, "expected RO-mounted file to be readable") + + // Verify writes are rejected (read-only filesystem). + exitCode, err = docker.ExecInContainer(ctx, sharedClient, containerName, []string{ + "bash", "-c", fmt.Sprintf("echo 'write attempt' > %s", containerFilePath), + }) + require.NoError(t, err, "exec write attempt on RO-mounted file") + require.NotEqual(t, 0, exitCode, "expected write to RO-mounted file to fail") +} diff --git a/internal/docker/integration_network_test.go b/internal/docker/integration_network_test.go new file mode 100644 index 0000000..b191973 --- /dev/null +++ b/internal/docker/integration_network_test.go @@ -0,0 +1,350 @@ +//go:build integration + +package docker_test + +import ( + "context" + "fmt" + "net" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" +) + +// ---------------------------------------------------------------------------- +// 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 dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") + + // 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 dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") + + // 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 dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") + + // 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) +} diff --git a/internal/docker/integration_ssh_test.go b/internal/docker/integration_ssh_test.go new file mode 100644 index 0000000..a867274 --- /dev/null +++ b/internal/docker/integration_ssh_test.go @@ -0,0 +1,437 @@ +//go:build integration + +package docker_test + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" +) + +// ---------------------------------------------------------------------------- +// 16.6 TestSSHHostKeyStableAcrossRebuild +// Validates: Req 13.3 +// ---------------------------------------------------------------------------- + +func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err) + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err) + + u, err := user.Current() + require.NoError(t, err) + uid, err := strconv.Atoi(u.Uid) + require.NoError(t, err) + gid, err := strconv.Atoi(u.Gid) + require.NoError(t, err) + + info := &hostinfo.Info{ + Username: u.Username, + HomeDir: u.HomeDir, + UID: uid, + GID: gid, + } + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + sanitize(dirName) + imageTag := containerName + ":latest" + + port, err := findFreePort() + require.NoError(t, err) + + client, err := docker.NewClient() + require.NoError(t, err) + + buildAndGetFingerprint := func() string { + t.Helper() + + 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 + } + + builder := docker.NewBaseImageBuilder( + info, + strategy, conflictingUser, + "", + ) + + // Build base image + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: builder.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 + instanceBuilder := docker.NewInstanceImageBuilder( + info, + userPubKey, + hostKeyPriv, hostKeyPub, + 2222, false, + ) + instanceBuilder.Finalize() + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: imageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, client, spec, false) + require.NoError(t, err, "building instance image") + + return hostKeyPub + } + + fingerprint1 := buildAndGetFingerprint() + fingerprint2 := buildAndGetFingerprint() + + require.Equal(t, fingerprint1, fingerprint2, + "SSH host key fingerprint must be stable across rebuilds") + + t.Cleanup(func() { + cleanCtx := context.Background() + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == imageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) +} + +// ---------------------------------------------------------------------------- +// 16.5 TestSSHPortPersistenceAcrossRestarts +// Validates: Req 12.2 +// ---------------------------------------------------------------------------- + +func TestSSHPortPersistenceAcrossRestarts(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + containerName, sshPort, client, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + ctx := context.Background() + + originalPort := sshPort + + err := docker.StopContainer(ctx, client, containerName) + require.NoError(t, err, "stopping container") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "restarting container") + + err = docker.WaitForSSH(ctx, "127.0.0.1", originalPort, 30*time.Second) + require.NoError(t, err, "waiting for SSH after restart on original port %d", originalPort) + + addr := fmt.Sprintf("127.0.0.1:%d", originalPort) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + require.NoError(t, err, "expected SSH port %d to be reachable after restart", originalPort) + conn.Close() +} + +// ---------------------------------------------------------------------------- +// 16.10 TestKnownHostsEntriesLifecycle +// Validates: Req 18.1–18.2, 18.7 +// ---------------------------------------------------------------------------- + +func TestKnownHostsEntriesLifecycle(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + _, sshPort, _, cleanup := startContainerFromSharedImage(t) + + _, hostPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err) + + err = sshpkg.SyncKnownHosts(sshPort, hostPubKey, false) + require.NoError(t, err, "SyncKnownHosts should succeed") + + khPath := filepath.Join(tempHome, ".ssh", "known_hosts") + data, err := os.ReadFile(khPath) + require.NoError(t, err, "known_hosts file should exist") + content := string(data) + + localhostEntry := fmt.Sprintf("[localhost]:%d", sshPort) + loopbackEntry := fmt.Sprintf("127.0.0.1:%d", sshPort) + require.True(t, strings.Contains(content, localhostEntry), + "known_hosts should contain [localhost]:%d entry", sshPort) + require.True(t, strings.Contains(content, loopbackEntry), + "known_hosts should contain 127.0.0.1:%d entry", sshPort) + + cleanup() + + err = sshpkg.RemoveKnownHostsEntries(sshPort) + require.NoError(t, err, "RemoveKnownHostsEntries should succeed") + + data, err = os.ReadFile(khPath) + require.NoError(t, err, "known_hosts file should still exist after removal") + content = string(data) + + require.False(t, strings.Contains(content, localhostEntry), + "known_hosts should NOT contain [localhost]:%d entry after removal", sshPort) + require.False(t, strings.Contains(content, loopbackEntry), + "known_hosts should NOT contain 127.0.0.1:%d entry after removal", sshPort) +} + +// ---------------------------------------------------------------------------- +// 16.11 TestSSHConfigEntryLifecycle +// Validates: Req 19.1–19.2, 19.7 +// ---------------------------------------------------------------------------- + +func TestSSHConfigEntryLifecycle(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + containerName, sshPort, _, cleanup := startContainerFromSharedImage(t) + + info, err := hostinfo.Current() + require.NoError(t, err) + + err = sshpkg.SyncSSHConfig(containerName, sshPort, info.Username, false) + require.NoError(t, err, "SyncSSHConfig should succeed") + + cfgPath := filepath.Join(tempHome, ".ssh", "config") + data, err := os.ReadFile(cfgPath) + require.NoError(t, err, "ssh config file should exist") + content := string(data) + + hostLine := fmt.Sprintf("Host %s", containerName) + portLine := fmt.Sprintf("Port %d", sshPort) + userLine := fmt.Sprintf("User %s", info.Username) + hostnameLine := "HostName localhost" + + require.True(t, strings.Contains(content, hostLine), + "ssh config should contain 'Host %s'", containerName) + require.True(t, strings.Contains(content, portLine), + "ssh config should contain 'Port %d'", sshPort) + require.True(t, strings.Contains(content, userLine), + "ssh config should contain 'User %s'", info.Username) + require.True(t, strings.Contains(content, hostnameLine), + "ssh config should contain 'HostName localhost'") + + cleanup() + + err = sshpkg.RemoveSSHConfigEntry(containerName) + require.NoError(t, err, "RemoveSSHConfigEntry should succeed") + + data, err = os.ReadFile(cfgPath) + require.NoError(t, err, "ssh config file should still exist after removal") + content = string(data) + + require.False(t, strings.Contains(content, hostLine), + "ssh config should NOT contain 'Host %s' after removal", containerName) +} + +// ---------------------------------------------------------------------------- +// TestLoginShellLandsInWorkspace +// Validates: Req 27.1, 27.2 +// ---------------------------------------------------------------------------- + +func TestLoginShellLandsInWorkspace(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + _, sshPort, _, cleanup := startContainerFromSharedImage(t) + t.Cleanup(cleanup) + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Parse the user private key that was baked into the shared image. + signer, err := gossh.ParsePrivateKey([]byte(sharedUserPrivKey)) + require.NoError(t, err, "parsing user private key for SSH auth") + + // Connect via SSH — the real path a user takes. + config := &gossh.ClientConfig{ + User: info.Username, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + addr := fmt.Sprintf("127.0.0.1:%d", sshPort) + + // Retry SSH dial — sshd may need a moment after the TCP port becomes reachable. + var client *gossh.Client + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + client, err = gossh.Dial("tcp", addr, config) + if err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + require.NoError(t, err, "SSH dial to %s", addr) + defer client.Close() + + session, err := client.NewSession() + require.NoError(t, err, "creating SSH session") + defer session.Close() + + var stdout bytes.Buffer + session.Stdout = &stdout + + // Use "bash -l -c pwd" to simulate the login shell that SSH spawns for + // interactive sessions. A plain "ssh host command" uses a non-login shell + // and won't source /etc/profile.d/*.sh. + err = session.Run("bash -l -c pwd") + require.NoError(t, err, "running pwd over SSH login shell") + + // Profile scripts (e.g. dbus-keyring.sh) may emit output before pwd. + // Extract only the last line which is the pwd result. + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + lastLine := lines[len(lines)-1] + require.Equal(t, constants.WorkspaceMountPath, lastLine, + "SSH login shell working directory should be %s", constants.WorkspaceMountPath) +} + +// ---------------------------------------------------------------------------- +// TestLoginShellFallsBackWhenWorkspaceMissing +// Validates: Req 27.3 +// ---------------------------------------------------------------------------- + +func TestLoginShellFallsBackWhenWorkspaceMissing(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + buildSharedImage(t) + + ctx := context.Background() + + dirName := fmt.Sprintf("no-ws-%d", time.Now().UnixNano()) + containerName := constants.ContainerNamePrefix + sanitize(dirName) + + port, err := findFreePort() + require.NoError(t, err, "finding free port") + + // Create container WITHOUT a /workspace mount — the directory won't exist. + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: sharedImageTag, + Mounts: []docker.Mount{}, // no /workspace + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + HostNetworkOff: true, + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + require.NoError(t, err, "creating container without /workspace mount") + + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, sharedClient, containerName) + _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) + }) + + err = docker.StartContainer(ctx, sharedClient, containerName) + require.NoError(t, err, "starting container") + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) + require.NoError(t, err, "waiting for SSH to be ready") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + signer, err := gossh.ParsePrivateKey([]byte(sharedUserPrivKey)) + require.NoError(t, err, "parsing user private key") + + config := &gossh.ClientConfig{ + User: info.Username, + Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + addr := fmt.Sprintf("127.0.0.1:%d", port) + + var sshClient *gossh.Client + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + sshClient, err = gossh.Dial("tcp", addr, config) + if err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + require.NoError(t, err, "SSH dial to %s", addr) + defer sshClient.Close() + + session, err := sshClient.NewSession() + require.NoError(t, err, "creating SSH session") + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + // Login shell must succeed even though /workspace doesn't exist. + err = session.Run("bash -l -c pwd") + require.NoError(t, err, "login shell must not fail when /workspace is missing") + + // Working directory should fall back to home dir. + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + lastLine := lines[len(lines)-1] + require.Equal(t, info.HomeDir, lastLine, + "working directory should fall back to home when /workspace is missing") + + // No errors on stderr from the profile script. + require.Empty(t, stderr.String(), + "profile script must not emit errors when /workspace is missing") +} diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go deleted file mode 100644 index ad31d24..0000000 --- a/internal/docker/integration_test.go +++ /dev/null @@ -1,1350 +0,0 @@ -//go:build integration - -package docker_test - -import ( - "context" - "fmt" - "net" - "os" - "os/exec" - "os/user" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - dockerimage "github.com/docker/docker/api/types/image" - "github.com/stretchr/testify/require" - - "github.com/koudis/bootstrap-ai-coding/internal/constants" - "github.com/koudis/bootstrap-ai-coding/internal/docker" - "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" - sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" - "github.com/koudis/bootstrap-ai-coding/internal/testutil" -) - -// ---------------------------------------------------------------------------- -// Package-level shared image state — built once in TestMain, reused by tests. -// ---------------------------------------------------------------------------- - -var ( - sharedImageTag string - sharedClient *docker.Client - sharedProjectDir string - sharedHostInfo *hostinfo.Info -) - -// TestMain ensures the base image is removed from the local Docker image store -// before the integration suite runs (for TestAFindConflictingUserPullsImageIfAbsent), -// then builds a shared image that most tests reuse. -func TestMain(m *testing.M) { - if _, err := exec.LookPath("docker"); err != nil { - os.Exit(m.Run()) - } - - testutil.RequireIntegrationConsent() - - if err := testutil.EnsureBaseImageAbsent(); err != nil { - fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) - os.Exit(1) - } - - os.Exit(m.Run()) -} - -// buildSharedImage builds the shared image once (idempotent). Tests that need -// a container call this, then create their own container from the shared image. -// The image is built on first call; subsequent calls are no-ops. -func buildSharedImage(t *testing.T) { - t.Helper() - - if sharedImageTag != "" { - return // already built - } - - ctx := context.Background() - - var err error - sharedProjectDir = t.TempDir() - - 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") - - info, err := hostinfo.Current() - require.NoError(t, err, "getting host info") - sharedHostInfo = info - - sharedClient, err = docker.NewClient() - require.NoError(t, err, "connecting to Docker daemon") - - strategy := docker.UserStrategyCreate - conflictingUser := "" - 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 - conflictingUser = conflictingImageUser.Username - } - - builder := docker.NewBaseImageBuilder( - info, - strategy, conflictingUser, - "", - ) - - instanceBuilder := docker.NewInstanceImageBuilder( - info, - userPubKey, - hostKeyPriv, hostKeyPub, - 2222, true, - ) - instanceBuilder.Finalize() - - sharedImageTag = constants.ContainerNamePrefix + "integration-shared:latest" - - // Build base image first - baseSpec := docker.ContainerSpec{ - Name: constants.ContainerNamePrefix + "integration-shared", - ImageTag: constants.BaseImageTag, - Dockerfile: builder.Build(), - Labels: map[string]string{"bac.managed": "true"}, - HostInfo: sharedHostInfo, - } - - _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) - require.NoError(t, err, "building base image") - - // Build instance image from base - spec := docker.ContainerSpec{ - Name: constants.ContainerNamePrefix + "integration-shared", - ImageTag: sharedImageTag, - Dockerfile: instanceBuilder.Build(), - Mounts: []docker.Mount{ - {HostPath: sharedProjectDir, ContainerPath: constants.WorkspaceMountPath}, - }, - Labels: map[string]string{"bac.managed": "true"}, - HostInfo: sharedHostInfo, - } - - _, err = docker.BuildImage(ctx, sharedClient, spec, false) - require.NoError(t, err, "building shared container image") -} - -// startContainerFromSharedImage creates and starts a new container from the -// shared image with a unique name and port. Returns the container name, port, -// client, and cleanup function. -func startContainerFromSharedImage(t *testing.T) (containerName string, sshPort int, client *docker.Client, cleanup func()) { - t.Helper() - - buildSharedImage(t) - - ctx := context.Background() - - projectDir := t.TempDir() - dirName := filepath.Base(projectDir) - - port, err := findFreePort() - require.NoError(t, err, "finding free port") - - containerName = constants.ContainerNamePrefix + sanitize(dirName) - - spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: sharedImageTag, - Mounts: []docker.Mount{ - {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, - }, - SSHPort: port, - Labels: map[string]string{"bac.managed": "true"}, - HostInfo: sharedHostInfo, - HostNetworkOff: true, - } - - _, err = docker.CreateContainer(ctx, sharedClient, spec) - require.NoError(t, err, "creating container") - - err = docker.StartContainer(ctx, sharedClient, containerName) - require.NoError(t, err, "starting container") - - err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) - require.NoError(t, err, "waiting for SSH to be ready") - - cleanup = func() { - cleanCtx := context.Background() - _ = docker.StopContainer(cleanCtx, sharedClient, containerName) - _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) - } - - return containerName, port, sharedClient, cleanup -} - -// ---------------------------------------------------------------------------- -// 16.1 TestContainerStartsAndSSHConnects -// Validates: Req 3.3, 4.3 -// ---------------------------------------------------------------------------- - -func TestContainerStartsAndSSHConnects(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - _, sshPort, _, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - addr := fmt.Sprintf("127.0.0.1:%d", sshPort) - conn, err := net.DialTimeout("tcp", addr, 5*time.Second) - require.NoError(t, err, "expected TCP connection to SSH port %d to succeed", sshPort) - conn.Close() -} - -// ---------------------------------------------------------------------------- -// 16.2 TestWorkspaceMountLiveSync -// Validates: Req 2.3 -// ---------------------------------------------------------------------------- - -func TestWorkspaceMountLiveSync(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - containerName, _, client, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - ctx := context.Background() - - exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ - "bash", "-c", "echo 'hello from container' > /workspace/sync-test.txt", - }) - require.NoError(t, err, "exec to create file in /workspace") - require.Equal(t, 0, exitCode, "expected exit 0 when creating file in /workspace") - - exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{ - "test", "-f", constants.WorkspaceMountPath + "/sync-test.txt", - }) - require.NoError(t, err, "exec to verify file in /workspace") - require.Equal(t, 0, exitCode, "expected file to exist at %s/sync-test.txt", constants.WorkspaceMountPath) -} - -// ---------------------------------------------------------------------------- -// 16.3 TestFileOwnershipMatchesHostUser -// Validates: Req 10.6 -// ---------------------------------------------------------------------------- - -func TestFileOwnershipMatchesHostUser(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - containerName, _, client, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - ctx := context.Background() - - info, err := hostinfo.Current() - require.NoError(t, err) - - exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ - "su", "-c", "touch /workspace/ownership-test.txt", info.Username, - }) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "expected exit 0 when creating file") - - checkUID := fmt.Sprintf(`[ "$(stat -c '%%u' /workspace/ownership-test.txt)" = "%d" ]`, info.UID) - exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkUID}) - require.NoError(t, err, "exec to check file UID") - require.Equal(t, 0, exitCode, - "expected file UID inside container to match host user UID=%d", info.UID) - - checkGID := fmt.Sprintf(`[ "$(stat -c '%%g' /workspace/ownership-test.txt)" = "%d" ]`, info.GID) - exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkGID}) - require.NoError(t, err, "exec to check file GID") - require.Equal(t, 0, exitCode, - "expected file GID inside container to match host user GID=%d", info.GID) -} - -// ---------------------------------------------------------------------------- -// 16.4 TestCredentialVolumePersistedAcrossRestart -// Validates: Req 8.6 -// ---------------------------------------------------------------------------- - -func TestCredentialVolumePersistedAcrossRestart(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - containerName, sshPort, client, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - ctx := context.Background() - - exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ - "bash", "-c", "echo 'persistent' > /workspace/persist-test.txt", - }) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "expected exit 0 when writing sentinel file") - - err = docker.StopContainer(ctx, client, containerName) - require.NoError(t, err, "stopping container") - - err = docker.StartContainer(ctx, client, containerName) - require.NoError(t, err, "restarting container") - - err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 30*time.Second) - require.NoError(t, err, "waiting for SSH after restart") - - exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{ - "test", "-f", "/workspace/persist-test.txt", - }) - require.NoError(t, err) - require.Equal(t, 0, exitCode, "expected sentinel file to persist across container restart") -} - -// ---------------------------------------------------------------------------- -// 16.5 TestSSHPortPersistenceAcrossRestarts -// Validates: Req 12.2 -// ---------------------------------------------------------------------------- - -func TestSSHPortPersistenceAcrossRestarts(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - containerName, sshPort, client, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - ctx := context.Background() - - originalPort := sshPort - - err := docker.StopContainer(ctx, client, containerName) - require.NoError(t, err, "stopping container") - - err = docker.StartContainer(ctx, client, containerName) - require.NoError(t, err, "restarting container") - - err = docker.WaitForSSH(ctx, "127.0.0.1", originalPort, 30*time.Second) - require.NoError(t, err, "waiting for SSH after restart on original port %d", originalPort) - - addr := fmt.Sprintf("127.0.0.1:%d", originalPort) - conn, err := net.DialTimeout("tcp", addr, 5*time.Second) - require.NoError(t, err, "expected SSH port %d to be reachable after restart", originalPort) - conn.Close() -} - -// ---------------------------------------------------------------------------- -// 16.6 TestSSHHostKeyStableAcrossRebuild -// Validates: Req 13.3 -// ---------------------------------------------------------------------------- - -func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() - require.NoError(t, err) - - _, userPubKey, err := sshpkg.GenerateHostKeyPair() - require.NoError(t, err) - - u, err := user.Current() - require.NoError(t, err) - uid, err := strconv.Atoi(u.Uid) - require.NoError(t, err) - gid, err := strconv.Atoi(u.Gid) - require.NoError(t, err) - - info := &hostinfo.Info{ - Username: u.Username, - HomeDir: u.HomeDir, - UID: uid, - GID: gid, - } - - projectDir := t.TempDir() - dirName := filepath.Base(projectDir) - containerName := constants.ContainerNamePrefix + sanitize(dirName) - imageTag := containerName + ":latest" - - port, err := findFreePort() - require.NoError(t, err) - - client, err := docker.NewClient() - require.NoError(t, err) - - buildAndGetFingerprint := func() string { - t.Helper() - - 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 - } - - builder := docker.NewBaseImageBuilder( - info, - strategy, conflictingUser, - "", - ) - - // Build base image - baseSpec := docker.ContainerSpec{ - Name: containerName, - ImageTag: constants.BaseImageTag, - Dockerfile: builder.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 - instanceBuilder := docker.NewInstanceImageBuilder( - info, - userPubKey, - hostKeyPriv, hostKeyPub, - 2222, false, - ) - instanceBuilder.Finalize() - spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: imageTag, - Dockerfile: instanceBuilder.Build(), - Mounts: []docker.Mount{ - {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, - }, - SSHPort: port, - Labels: map[string]string{"bac.managed": "true"}, - HostInfo: info, - } - - _, err = docker.BuildImage(ctx, client, spec, false) - require.NoError(t, err, "building instance image") - - return hostKeyPub - } - - fingerprint1 := buildAndGetFingerprint() - fingerprint2 := buildAndGetFingerprint() - - require.Equal(t, fingerprint1, fingerprint2, - "SSH host key fingerprint must be stable across rebuilds") - - t.Cleanup(func() { - cleanCtx := context.Background() - images, _ := docker.ListBACImages(cleanCtx, client) - for _, img := range images { - for _, tag := range img.RepoTags { - if tag == imageTag { - _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) - } - } - } - }) -} - -// ---------------------------------------------------------------------------- -// 16.7 TestPurgeRemovesContainersAndImages -// Validates: Req 16.2, 16.4 -// ---------------------------------------------------------------------------- - -func TestPurgeRemovesContainersAndImages(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - containerName, _, _, _ := startContainerFromSharedImage(t) - // Note: we do NOT register the cleanup here because we are testing purge. - - client, err := docker.NewClient() - require.NoError(t, err) - - // Verify the container exists before purge. - info, err := docker.InspectContainer(ctx, client, containerName) - require.NoError(t, err) - require.NotNil(t, info, "container should exist before purge") - - // Run purge logic: stop + remove container. - err = docker.StopContainer(ctx, client, containerName) - require.NoError(t, err, "stopping container during purge") - - err = docker.RemoveContainer(ctx, client, containerName) - require.NoError(t, err, "removing container during purge") - - // Assert container is gone. - info, err = docker.InspectContainer(ctx, client, containerName) - require.NoError(t, err) - require.Nil(t, info, "container should be gone after purge") -} - -// ---------------------------------------------------------------------------- -// 16.10 TestKnownHostsEntriesLifecycle -// Validates: Req 18.1–18.2, 18.7 -// ---------------------------------------------------------------------------- - -func TestKnownHostsEntriesLifecycle(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - _, sshPort, _, cleanup := startContainerFromSharedImage(t) - - _, hostPubKey, err := sshpkg.GenerateHostKeyPair() - require.NoError(t, err) - - err = sshpkg.SyncKnownHosts(sshPort, hostPubKey, false) - require.NoError(t, err, "SyncKnownHosts should succeed") - - khPath := filepath.Join(tempHome, ".ssh", "known_hosts") - data, err := os.ReadFile(khPath) - require.NoError(t, err, "known_hosts file should exist") - content := string(data) - - localhostEntry := fmt.Sprintf("[localhost]:%d", sshPort) - loopbackEntry := fmt.Sprintf("127.0.0.1:%d", sshPort) - require.True(t, strings.Contains(content, localhostEntry), - "known_hosts should contain [localhost]:%d entry", sshPort) - require.True(t, strings.Contains(content, loopbackEntry), - "known_hosts should contain 127.0.0.1:%d entry", sshPort) - - cleanup() - - err = sshpkg.RemoveKnownHostsEntries(sshPort) - require.NoError(t, err, "RemoveKnownHostsEntries should succeed") - - data, err = os.ReadFile(khPath) - require.NoError(t, err, "known_hosts file should still exist after removal") - content = string(data) - - require.False(t, strings.Contains(content, localhostEntry), - "known_hosts should NOT contain [localhost]:%d entry after removal", sshPort) - require.False(t, strings.Contains(content, loopbackEntry), - "known_hosts should NOT contain 127.0.0.1:%d entry after removal", sshPort) -} - -// ---------------------------------------------------------------------------- -// 16.11 TestSSHConfigEntryLifecycle -// Validates: Req 19.1–19.2, 19.7 -// ---------------------------------------------------------------------------- - -func TestSSHConfigEntryLifecycle(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - containerName, sshPort, _, cleanup := startContainerFromSharedImage(t) - - info, err := hostinfo.Current() - require.NoError(t, err) - - err = sshpkg.SyncSSHConfig(containerName, sshPort, info.Username, false) - require.NoError(t, err, "SyncSSHConfig should succeed") - - cfgPath := filepath.Join(tempHome, ".ssh", "config") - data, err := os.ReadFile(cfgPath) - require.NoError(t, err, "ssh config file should exist") - content := string(data) - - hostLine := fmt.Sprintf("Host %s", containerName) - portLine := fmt.Sprintf("Port %d", sshPort) - userLine := fmt.Sprintf("User %s", info.Username) - hostnameLine := "HostName localhost" - - require.True(t, strings.Contains(content, hostLine), - "ssh config should contain 'Host %s'", containerName) - require.True(t, strings.Contains(content, portLine), - "ssh config should contain 'Port %d'", sshPort) - require.True(t, strings.Contains(content, userLine), - "ssh config should contain 'User %s'", info.Username) - require.True(t, strings.Contains(content, hostnameLine), - "ssh config should contain 'HostName localhost'") - - cleanup() - - err = sshpkg.RemoveSSHConfigEntry(containerName) - require.NoError(t, err, "RemoveSSHConfigEntry should succeed") - - data, err = os.ReadFile(cfgPath) - require.NoError(t, err, "ssh config file should still exist after removal") - content = string(data) - - require.False(t, strings.Contains(content, hostLine), - "ssh config should NOT contain 'Host %s' after removal", containerName) -} - -// ---------------------------------------------------------------------------- -// 16.12 TestContainerHostnameMatchesContainerName -// Validates: Req 23.1, 23.2 -// ---------------------------------------------------------------------------- - -func TestContainerHostnameMatchesContainerName(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - containerName, _, client, cleanup := startContainerFromSharedImage(t) - t.Cleanup(cleanup) - - ctx := context.Background() - - // Verify via container inspect that the hostname is set correctly. - info, err := docker.InspectContainer(ctx, client, containerName) - require.NoError(t, err, "inspecting container") - require.NotNil(t, info, "container should exist") - require.Equal(t, containerName, info.Config.Hostname, - "container hostname should match container name") - - // Also verify by running `hostname` inside the container. - exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{"hostname"}) - require.NoError(t, err, "exec hostname command") - 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 dynamically allocated port to avoid conflicts - sshPort, err := findFreePort() - require.NoError(t, err, "finding free port") - - // 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 dynamically allocated port to avoid conflicts - sshPort, err := findFreePort() - require.NoError(t, err, "finding free port") - - // 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 dynamically allocated port to avoid conflicts - sshPort, err := findFreePort() - require.NoError(t, err, "finding free port") - - // 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 -// ---------------------------------------------------------------------------- - -func findFreePort() (int, error) { - for port := constants.SSHPortStart; port < 65535; port++ { - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err == nil { - ln.Close() - return port, nil - } - } - return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) -} - -func sanitize(s string) string { - s = strings.ToLower(s) - var b strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - } else { - b.WriteByte('-') - } - } - result := b.String() - for strings.Contains(result, "--") { - result = strings.ReplaceAll(result, "--", "-") - } - result = strings.Trim(result, "-") - if result == "" { - result = "tmp" - } - return result -} - -func forceRemoveOpts() dockerimage.RemoveOptions { - return dockerimage.RemoveOptions{Force: true} -} - -// ---------------------------------------------------------------------------- -// 11.1–11.5 TestTwoLayerBuildCycle -// Validates: TL-1, TL-2, TL-5, TL-6 -// Full two-layer build cycle: base → instance → container → stop → rebuild -// ---------------------------------------------------------------------------- - -func TestTwoLayerBuildCycle(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 + "twolayer-" + sanitize(dirName) - instanceImageTag := containerName + ":latest" - - port, err := findFreePort() - require.NoError(t, err, "finding free port") - - // 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 - } - - // Manifest for the base image label - manifestJSON := `["test-agent"]` - - // Cleanup: remove images and container at end of test - t.Cleanup(func() { - cleanCtx := context.Background() - _ = docker.StopContainer(cleanCtx, client, containerName) - _ = docker.RemoveContainer(cleanCtx, client, containerName) - // Remove instance image - images, _ := docker.ListBACImages(cleanCtx, client) - for _, img := range images { - for _, tag := range img.RepoTags { - if tag == instanceImageTag { - _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) - } - } - } - // Note: we do NOT remove bac-base:latest here because other tests may use it. - // The shared buildSharedImage() already builds it; removing it would break other tests. - }) - - // ------------------------------------------------------------------------- - // Subtask 1: Build base image, verify it exists with correct labels - // ------------------------------------------------------------------------- - baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") - baseLabels := map[string]string{ - "bac.managed": "true", - "bac.manifest": manifestJSON, - } - baseSpec := docker.ContainerSpec{ - Name: containerName, - ImageTag: constants.BaseImageTag, - Dockerfile: baseBuilder.Build(), - Labels: baseLabels, - HostInfo: info, - } - - _, err = docker.BuildImage(ctx, client, baseSpec, false) - require.NoError(t, err, "building base image") - - // Inspect base image and verify labels - baseInspect, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) - require.NoError(t, err, "inspecting base image") - require.Equal(t, "true", baseInspect.Config.Labels["bac.managed"], - "base image must have bac.managed=true label") - require.Equal(t, manifestJSON, baseInspect.Config.Labels["bac.manifest"], - "base image must have bac.manifest label with correct JSON") - - // ------------------------------------------------------------------------- - // Subtask 2: Build instance image FROM base, verify it exists with correct labels - // ------------------------------------------------------------------------- - instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, port, true) - instanceBuilder.Finalize() - - instanceLabels := map[string]string{ - "bac.managed": "true", - "bac.container": containerName, - } - instanceSpec := docker.ContainerSpec{ - Name: containerName, - ImageTag: instanceImageTag, - Dockerfile: instanceBuilder.Build(), - Mounts: []docker.Mount{ - {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, - }, - SSHPort: port, - Labels: instanceLabels, - HostInfo: info, - HostNetworkOff: true, - } - - _, err = docker.BuildImage(ctx, client, instanceSpec, false) - require.NoError(t, err, "building instance image") - - // Inspect instance image and verify labels - instanceInspect, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) - require.NoError(t, err, "inspecting instance image") - require.Equal(t, "true", instanceInspect.Config.Labels["bac.managed"], - "instance image must have bac.managed=true label") - require.Equal(t, containerName, instanceInspect.Config.Labels["bac.container"], - "instance image must have bac.container= label") - - // ------------------------------------------------------------------------- - // Subtask 3: Start container from instance image, verify SSH connectivity - // ------------------------------------------------------------------------- - _, err = docker.CreateContainer(ctx, client, instanceSpec) - require.NoError(t, err, "creating container from instance image") - - err = docker.StartContainer(ctx, client, containerName) - require.NoError(t, err, "starting container") - - err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) - require.NoError(t, err, "waiting for SSH to be ready in two-layer container") - - // Verify sshd is running inside the container - exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ - "pgrep", "-x", "sshd", - }) - require.NoError(t, err, "exec pgrep sshd") - require.Equal(t, 0, exitCode, "sshd should be running inside the container") - - // ------------------------------------------------------------------------- - // Subtask 4: Stop and remove container — verify both images still exist - // ------------------------------------------------------------------------- - err = docker.StopContainer(ctx, client, containerName) - require.NoError(t, err, "stopping container") - - err = docker.RemoveContainer(ctx, client, containerName) - require.NoError(t, err, "removing container") - - // Verify container is gone - containerInfo, err := docker.InspectContainer(ctx, client, containerName) - require.NoError(t, err) - require.Nil(t, containerInfo, "container should be gone after removal") - - // Verify base image still exists - _, _, err = client.ImageInspectWithRaw(ctx, constants.BaseImageTag) - require.NoError(t, err, "base image must still exist after container removal") - - // Verify instance image still exists - _, _, err = client.ImageInspectWithRaw(ctx, instanceImageTag) - require.NoError(t, err, "instance image must still exist after container removal") - - // ------------------------------------------------------------------------- - // Subtask 5: Rebuild (--rebuild equivalent) — verify both images are recreated - // ------------------------------------------------------------------------- - - // Record the image IDs before rebuild - baseBeforeRebuild, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) - require.NoError(t, err) - baseIDBeforeRebuild := baseBeforeRebuild.ID - - instanceBeforeRebuild, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) - require.NoError(t, err) - instanceIDBeforeRebuild := instanceBeforeRebuild.ID - - // Rebuild base with NoCache: true (simulating --rebuild) - baseSpec.NoCache = true - _, err = docker.BuildImage(ctx, client, baseSpec, false) - require.NoError(t, err, "rebuilding base image with no-cache") - - // Rebuild instance (inherits fresh base) - _, err = docker.BuildImage(ctx, client, instanceSpec, false) - require.NoError(t, err, "rebuilding instance image after base rebuild") - - // Verify both images were recreated (different IDs) - baseAfterRebuild, _, err := client.ImageInspectWithRaw(ctx, constants.BaseImageTag) - require.NoError(t, err) - require.NotEqual(t, baseIDBeforeRebuild, baseAfterRebuild.ID, - "base image ID must change after no-cache rebuild") - - instanceAfterRebuild, _, err := client.ImageInspectWithRaw(ctx, instanceImageTag) - require.NoError(t, err) - require.NotEqual(t, instanceIDBeforeRebuild, instanceAfterRebuild.ID, - "instance image ID must change after rebuild") - - // Verify labels are still correct after rebuild - require.Equal(t, "true", baseAfterRebuild.Config.Labels["bac.managed"]) - require.Equal(t, manifestJSON, baseAfterRebuild.Config.Labels["bac.manifest"]) - require.Equal(t, "true", instanceAfterRebuild.Config.Labels["bac.managed"]) - require.Equal(t, containerName, instanceAfterRebuild.Config.Labels["bac.container"]) -} - -// ---------------------------------------------------------------------------- -// TestBuildImageTimeoutEnforced -// Validates: Req 14.7 (Image_Build_Timeout) -// ---------------------------------------------------------------------------- - -const testBuildTimeout = 3 * time.Second - -func TestBuildImageTimeoutEnforced(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") - - hangingDockerfile := fmt.Sprintf("FROM %s\nRUN sleep 300\n", constants.BaseContainerImage) - - containerName := constants.ContainerNamePrefix + "timeout-test" - imageTag := containerName + ":latest" - - spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: imageTag, - Dockerfile: hangingDockerfile, - Labels: map[string]string{"bac.managed": "true"}, - } - - t.Cleanup(func() { - cleanCtx := context.Background() - images, _ := docker.ListBACImages(cleanCtx, client) - for _, img := range images { - for _, tag := range img.RepoTags { - if tag == imageTag { - _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) - } - } - } - }) - - _, err = docker.BuildImageWithTimeout(ctx, client, spec, testBuildTimeout, false) - - require.Error(t, err, "BuildImageWithTimeout must return an error when the build exceeds the timeout") - require.Contains(t, err.Error(), "timed out", - "error message must mention 'timed out'; got: %v", err) -} - -// ---------------------------------------------------------------------------- -// TestAFindConflictingUserPullsImageIfAbsent -// Validates: Req 10a.1 — FindConflictingUser must succeed even when the base -// image is not present in the local Docker image store. -// -// Named with "A" prefix so Go's alphabetical test ordering runs this first. -// The base image is guaranteed absent by TestMain's call to -// EnsureBaseImageAbsent(), so this test simply calls FindConflictingUser and -// asserts it succeeds (pulling the image automatically). -// ---------------------------------------------------------------------------- - -func TestAFindConflictingUserPullsImageIfAbsent(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) - - result, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) - require.NoError(t, err, - "FindConflictingUser must succeed even when the base image is not cached locally") - _ = result - - _, _, err = client.ImageInspectWithRaw(ctx, constants.BaseContainerImage) - require.NoError(t, err, - "base image should be present locally after FindConflictingUser pulls it") -} - -// ---------------------------------------------------------------------------- -// TestReadOnlyFileMountIsReadableButNotWritable -// Validates: CC-8 (read-only bind-mount of ~/.claude.json) — core mount plumbing -// ---------------------------------------------------------------------------- - -func TestReadOnlyFileMountIsReadableButNotWritable(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - buildSharedImage(t) - - ctx := context.Background() - - projectDir := t.TempDir() - dirName := filepath.Base(projectDir) - - // Create a temporary file to mount read-only into the container. - hostFile := filepath.Join(t.TempDir(), "config.json") - err := os.WriteFile(hostFile, []byte(`{"test":"read-only-mount"}`), 0o644) - require.NoError(t, err, "creating host file for RO mount") - - port, err := findFreePort() - require.NoError(t, err, "finding free port") - - containerName := constants.ContainerNamePrefix + sanitize(dirName) + "-ro" - containerFilePath := filepath.Join(sharedHostInfo.HomeDir, ".config-test.json") - - spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: sharedImageTag, - Mounts: []docker.Mount{ - {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, - {HostPath: hostFile, ContainerPath: containerFilePath, ReadOnly: true}, - }, - SSHPort: port, - Labels: map[string]string{"bac.managed": "true"}, - HostInfo: sharedHostInfo, - HostNetworkOff: true, - } - - _, err = docker.CreateContainer(ctx, sharedClient, spec) - require.NoError(t, err, "creating container with RO file mount") - - t.Cleanup(func() { - cleanCtx := context.Background() - _ = docker.StopContainer(cleanCtx, sharedClient, containerName) - _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) - }) - - err = docker.StartContainer(ctx, sharedClient, containerName) - require.NoError(t, err, "starting container with RO file mount") - - err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) - require.NoError(t, err, "waiting for SSH to be ready") - - // Verify the file is readable inside the container. - exitCode, err := docker.ExecInContainer(ctx, sharedClient, containerName, []string{ - "cat", containerFilePath, - }) - require.NoError(t, err, "exec cat on RO-mounted file") - require.Equal(t, 0, exitCode, "expected RO-mounted file to be readable") - - // Verify writes are rejected (read-only filesystem). - exitCode, err = docker.ExecInContainer(ctx, sharedClient, containerName, []string{ - "bash", "-c", fmt.Sprintf("echo 'write attempt' > %s", containerFilePath), - }) - require.NoError(t, err, "exec write attempt on RO-mounted file") - require.NotEqual(t, 0, exitCode, "expected write to RO-mounted file to fail") -}