package provisioner import ( "errors" "os" "path/filepath" "strings" "testing" "github.com/docker/docker/api/types/container" ) // TestValidateConfigSource covers issue #17: a workspace restart with no // template and no in-memory configFiles must be caught before Docker // starts a container destined to crash-loop on FileNotFoundError. func TestValidateConfigSource_ConfigFilesPresent(t *testing.T) { files := map[string][]byte{"config.yaml": []byte("name: test\n")} if err := ValidateConfigSource("", files); err != nil { t.Fatalf("expected nil error when configFiles has config.yaml, got %v", err) } } func TestValidateConfigSource_ConfigFilesEmptyValue(t *testing.T) { files := map[string][]byte{"config.yaml": {}} if err := ValidateConfigSource("", files); !errors.Is(err, ErrNoConfigSource) { t.Fatalf("expected ErrNoConfigSource for empty config.yaml bytes, got %v", err) } } func TestValidateConfigSource_TemplatePathWithConfig(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("name: x\n"), 0644); err != nil { t.Fatalf("setup: %v", err) } if err := ValidateConfigSource(dir, nil); err != nil { t.Fatalf("expected nil when template dir has config.yaml, got %v", err) } } func TestValidateConfigSource_TemplatePathMissingConfig(t *testing.T) { dir := t.TempDir() // empty dir if err := ValidateConfigSource(dir, nil); !errors.Is(err, ErrNoConfigSource) { t.Fatalf("expected ErrNoConfigSource for template dir without config.yaml, got %v", err) } } func TestValidateConfigSource_BothEmpty(t *testing.T) { if err := ValidateConfigSource("", nil); !errors.Is(err, ErrNoConfigSource) { t.Fatalf("expected ErrNoConfigSource when both sources empty, got %v", err) } } func TestValidateConfigSource_TemplateIsDirName(t *testing.T) { // If `config.yaml` at the template path is itself a directory (weird // but possible), the validator should reject it. dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "config.yaml"), 0755); err != nil { t.Fatalf("setup: %v", err) } if err := ValidateConfigSource(dir, nil); !errors.Is(err, ErrNoConfigSource) { t.Fatalf("expected ErrNoConfigSource when config.yaml is a dir, got %v", err) } } // baseHostConfig returns a fresh HostConfig with typical pre-tier binds, // mimicking what Start() builds before calling ApplyTierConfig. func baseHostConfig(pluginsPath string) *container.HostConfig { binds := []string{ "ws-abc123-configs:/configs", "ws-abc123-workspace:/workspace", } if pluginsPath != "" { binds = append(binds, pluginsPath+":/plugins:ro") } return &container.HostConfig{ Binds: binds, } } func TestApplyTierConfig_Tier1_Sandboxed(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 1, } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // T1 should strip /workspace mount — only config bind remains if len(hc.Binds) != 1 { t.Fatalf("T1: expected 1 bind (config only), got %d: %v", len(hc.Binds), hc.Binds) } if hc.Binds[0] != configMount { t.Errorf("T1: expected bind %q, got %q", configMount, hc.Binds[0]) } // ReadonlyRootfs must be set if !hc.ReadonlyRootfs { t.Error("T1: expected ReadonlyRootfs=true") } // Tmpfs at /tmp must be set if _, ok := hc.Tmpfs["/tmp"]; !ok { t.Error("T1: expected tmpfs mount at /tmp") } // Must NOT be privileged if hc.Privileged { t.Error("T1: must not be privileged") } // Must NOT have host network if hc.NetworkMode == "host" { t.Error("T1: must not have host network") } } func TestApplyTierConfig_Tier1_NoGlobalPlugins(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 1, } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // T1 should have only 1 bind: config (plugins are per-workspace in /configs/plugins/) if len(hc.Binds) != 1 { t.Fatalf("T1: expected 1 bind, got %d: %v", len(hc.Binds), hc.Binds) } if hc.Binds[0] != configMount { t.Errorf("T1: expected bind %q, got %q", configMount, hc.Binds[0]) } } func TestApplyTierConfig_Tier2_Standard(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") originalBinds := make([]string, len(hc.Binds)) copy(originalBinds, hc.Binds) cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 2, } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // T2 should NOT modify binds — /workspace mount stays if len(hc.Binds) != len(originalBinds) { t.Fatalf("T2: binds should be unchanged, got %v", hc.Binds) } // Memory limit: 512 MiB expectedMemory := int64(512 * 1024 * 1024) if hc.Resources.Memory != expectedMemory { t.Errorf("T2: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory) } // CPU limit: 1.0 CPU (1e9 NanoCPUs) expectedCPU := int64(1_000_000_000) if hc.Resources.NanoCPUs != expectedCPU { t.Errorf("T2: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs) } // Must NOT be privileged if hc.Privileged { t.Error("T2: must not be privileged") } // Must NOT have host network if hc.NetworkMode == "host" { t.Error("T2: must not have host network") } // Must NOT have readonly rootfs if hc.ReadonlyRootfs { t.Error("T2: must not have ReadonlyRootfs") } } func TestApplyTierConfig_Tier3_Privileged(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") originalBinds := make([]string, len(hc.Binds)) copy(originalBinds, hc.Binds) cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 3, } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // T3 must be privileged if !hc.Privileged { t.Error("T3: expected Privileged=true") } // T3 must have host PID if hc.PidMode != "host" { t.Errorf("T3: expected PidMode=host, got %q", hc.PidMode) } // T3 must NOT have host network (to avoid port collisions) if hc.NetworkMode == "host" { t.Error("T3: must not have host network (use Docker network for inter-container discovery)") } // Binds should be unchanged (keeps /workspace) if len(hc.Binds) != len(originalBinds) { t.Fatalf("T3: binds should be unchanged, got %v", hc.Binds) } } func TestApplyTierConfig_Tier4_FullHost(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") originalBindCount := len(hc.Binds) cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 4, } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // T4 must be privileged (inherits from T3) if !hc.Privileged { t.Error("T4: expected Privileged=true") } // T4 must have host PID (inherits from T3) if hc.PidMode != "host" { t.Errorf("T4: expected PidMode=host, got %q", hc.PidMode) } // T4 must have host network if hc.NetworkMode != "host" { t.Errorf("T4: expected NetworkMode=host, got %q", hc.NetworkMode) } // T4 should add Docker socket mount to existing binds expectedBindCount := originalBindCount + 1 if len(hc.Binds) != expectedBindCount { t.Fatalf("T4: expected %d binds (original + docker socket), got %d: %v", expectedBindCount, len(hc.Binds), hc.Binds) } // Last bind should be the Docker socket dockerSocket := "/var/run/docker.sock:/var/run/docker.sock" lastBind := hc.Binds[len(hc.Binds)-1] if lastBind != dockerSocket { t.Errorf("T4: expected docker socket bind %q, got %q", dockerSocket, lastBind) } } func TestApplyTierConfig_UnknownTier_DefaultsToT2(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 99, // Unknown tier } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // Unknown tiers should get T2 resource limits as a safe default expectedMemory := int64(512 * 1024 * 1024) if hc.Resources.Memory != expectedMemory { t.Errorf("Unknown tier: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory) } expectedCPU := int64(1_000_000_000) if hc.Resources.NanoCPUs != expectedCPU { t.Errorf("Unknown tier: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs) } // Must NOT be privileged if hc.Privileged { t.Error("Unknown tier: must not be privileged") } } func TestApplyTierConfig_ZeroTier_DefaultsToT2(t *testing.T) { configMount := "ws-abc123-configs:/configs" hc := baseHostConfig("") cfg := WorkspaceConfig{ WorkspaceID: "abc123", Tier: 0, // Unset / zero-value } ApplyTierConfig(hc, cfg, configMount, "ws-abc123") // Zero tier (default int value) should also get T2 resource limits expectedMemory := int64(512 * 1024 * 1024) if hc.Resources.Memory != expectedMemory { t.Errorf("Tier 0: expected Memory=%d, got %d", expectedMemory, hc.Resources.Memory) } if hc.Privileged { t.Error("Tier 0: must not be privileged") } } // TestTierEscalation verifies that lower tiers don't accidentally // get higher-tier privileges. func TestTierEscalation(t *testing.T) { tests := []struct { tier int expectPrivileged bool expectHostNetwork bool expectHostPID bool expectReadonly bool }{ {1, false, false, false, true}, {2, false, false, false, false}, {3, true, false, true, false}, {4, true, true, true, false}, } for _, tt := range tests { t.Run("tier_"+string(rune('0'+tt.tier)), func(t *testing.T) { configMount := "ws-test-configs:/configs" hc := baseHostConfig("") cfg := WorkspaceConfig{ WorkspaceID: "test", Tier: tt.tier, } ApplyTierConfig(hc, cfg, configMount, "ws-test") if hc.Privileged != tt.expectPrivileged { t.Errorf("Tier %d: Privileged=%v, want %v", tt.tier, hc.Privileged, tt.expectPrivileged) } if (hc.NetworkMode == "host") != tt.expectHostNetwork { t.Errorf("Tier %d: NetworkMode=%q, wantHost=%v", tt.tier, hc.NetworkMode, tt.expectHostNetwork) } if (hc.PidMode == "host") != tt.expectHostPID { t.Errorf("Tier %d: PidMode=%q, wantHost=%v", tt.tier, hc.PidMode, tt.expectHostPID) } if hc.ReadonlyRootfs != tt.expectReadonly { t.Errorf("Tier %d: ReadonlyRootfs=%v, want %v", tt.tier, hc.ReadonlyRootfs, tt.expectReadonly) } }) } } // TestContainerName verifies the naming convention. func TestContainerName(t *testing.T) { tests := []struct { id string want string }{ {"short", "ws-short"}, {"exactly12ch", "ws-exactly12ch"}, {"longer-than-twelve-characters", "ws-longer-than-"}, {"abc", "ws-abc"}, } for _, tt := range tests { got := ContainerName(tt.id) if got != tt.want { t.Errorf("ContainerName(%q) = %q, want %q", tt.id, got, tt.want) } } } // TestConfigVolumeName verifies config volume naming. func TestConfigVolumeName(t *testing.T) { tests := []struct { id string want string }{ {"short", "ws-short-configs"}, {"exactly12ch", "ws-exactly12ch-configs"}, {"longer-than-twelve-characters", "ws-longer-than--configs"}, {"abc", "ws-abc-configs"}, } for _, tt := range tests { got := ConfigVolumeName(tt.id) if got != tt.want { t.Errorf("ConfigVolumeName(%q) = %q, want %q", tt.id, got, tt.want) } } } // ---------- buildContainerEnv — #67 MOLECULE_URL injection ---------- func TestBuildContainerEnv_InjectsBothPlatformURLAndMoleculeAIURL(t *testing.T) { cfg := WorkspaceConfig{ WorkspaceID: "ws-abc123", PlatformURL: "http://host.docker.internal:8080", Tier: 2, } env := buildContainerEnv(cfg) wantPairs := map[string]string{ "WORKSPACE_ID": "ws-abc123", "WORKSPACE_CONFIG_PATH": "/configs", "PLATFORM_URL": "http://host.docker.internal:8080", "MOLECULE_URL": "http://host.docker.internal:8080", "TIER": "2", "PLUGINS_DIR": "/plugins", } for k, wantV := range wantPairs { want := k + "=" + wantV found := false for _, e := range env { if e == want { found = true break } } if !found { t.Errorf("expected env to contain %q, got %v", want, env) } } } func TestBuildContainerEnv_MoleculeAIURLAlwaysMatchesPlatformURL(t *testing.T) { // Regression guard: MOLECULE_URL must never drift from PLATFORM_URL — // if someone changes one they must change the other. This test pins // the invariant. See #67. for _, url := range []string{ "http://localhost:8080", "http://host.docker.internal:8080", "http://platform:8080", "https://molecule.example.com", } { cfg := WorkspaceConfig{WorkspaceID: "ws-x", PlatformURL: url, Tier: 1} env := buildContainerEnv(cfg) var pURL, sURL string for _, e := range env { if strings.HasPrefix(e, "PLATFORM_URL=") { pURL = strings.TrimPrefix(e, "PLATFORM_URL=") } if strings.HasPrefix(e, "MOLECULE_URL=") { sURL = strings.TrimPrefix(e, "MOLECULE_URL=") } } if pURL != sURL { t.Errorf("PLATFORM_URL (%q) must match MOLECULE_URL (%q)", pURL, sURL) } if pURL != url { t.Errorf("expected PLATFORM_URL=%q, got %q", url, pURL) } } } func TestBuildContainerEnv_AwarenessOnlyWhenBothSet(t *testing.T) { // Both set → both injected. cfg := WorkspaceConfig{ WorkspaceID: "ws-x", PlatformURL: "http://localhost:8080", AwarenessURL: "http://awareness:9000", AwarenessNamespace: "ns-1", } env := buildContainerEnv(cfg) hasNS := false hasURL := false for _, e := range env { if e == "AWARENESS_NAMESPACE=ns-1" { hasNS = true } if e == "AWARENESS_URL=http://awareness:9000" { hasURL = true } } if !hasNS || !hasURL { t.Errorf("both awareness vars must be present: env=%v", env) } // Only namespace set → neither injected (must be both-or-nothing). cfg.AwarenessURL = "" env2 := buildContainerEnv(cfg) for _, e := range env2 { if strings.HasPrefix(e, "AWARENESS_") { t.Errorf("awareness vars must NOT be injected when URL is missing: got %q", e) } } } func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) { cfg := WorkspaceConfig{ WorkspaceID: "ws-x", PlatformURL: "http://localhost:8080", EnvVars: map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "fake-token-for-test"}, } env := buildContainerEnv(cfg) seen := map[string]string{} for _, e := range env { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { seen[parts[0]] = parts[1] } } if seen["CUSTOM"] != "value" { t.Errorf("CUSTOM env missing, got env=%v", env) } if seen["GITHUB_TOKEN"] != "fake-token-for-test" { t.Errorf("GITHUB_TOKEN env missing, got env=%v", env) } // Built-in defaults still present if seen["MOLECULE_URL"] == "" { t.Errorf("MOLECULE_URL must still be set alongside custom envs") } } // ---------- buildWorkspaceMount — #65 workspace_access ---------- func TestBuildWorkspaceMount_SelectionMatrix(t *testing.T) { cases := []struct { name string path string access string wantSuffix string // suffix of the mount string for partial match wantBind bool // true if bind-mount (starts with path), false if named volume }{ {"empty path + none → named volume", "", "none", ":/workspace", false}, {"empty path + empty access → named volume", "", "", ":/workspace", false}, {"host path + read_only → :ro bind", "/Users/x/repo", "read_only", "/Users/x/repo:/workspace:ro", true}, {"host path + read_write → rw bind", "/Users/x/repo", "read_write", "/Users/x/repo:/workspace", true}, {"host path + none → named volume (opts out of mount)", "/Users/x/repo", "none", ":/workspace", false}, {"host path + empty access → default rw bind", "/Users/x/repo", "", "/Users/x/repo:/workspace", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := WorkspaceConfig{ WorkspaceID: "abc123", WorkspacePath: tc.path, WorkspaceAccess: tc.access, } got := buildWorkspaceMount(cfg) if tc.wantBind { if got != tc.wantSuffix { t.Errorf("want exact %q, got %q", tc.wantSuffix, got) } } else { // Named volume: should NOT start with tc.path, should end in :/workspace if strings.HasPrefix(got, tc.path+":") && tc.path != "" { t.Errorf("expected named volume (not bind), got %q", got) } if !strings.HasSuffix(got, tc.wantSuffix) { t.Errorf("want suffix %q, got %q", tc.wantSuffix, got) } } }) } } func TestValidateWorkspaceAccess(t *testing.T) { cases := []struct { name string access string path string wantErr bool }{ {"none + empty path", "none", "", false}, {"empty access + empty path", "", "", false}, {"read_only + host path", "read_only", "/Users/x/repo", false}, {"read_write + host path", "read_write", "/Users/x/repo", false}, {"read_only + empty path (error)", "read_only", "", true}, {"read_write + empty path (error)", "read_write", "", true}, {"unknown value (error)", "wildcard", "/Users/x/repo", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := ValidateWorkspaceAccess(tc.access, tc.path) if (err != nil) != tc.wantErr { t.Errorf("ValidateWorkspaceAccess(%q, %q) = %v, wantErr %v", tc.access, tc.path, err, tc.wantErr) } }) } } // ---------- isImageNotFoundErr (issue #117) ---------- func TestIsImageNotFoundErr(t *testing.T) { cases := []struct { name string err error want bool }{ {"nil", nil, false}, {"moby no such image", fmtErr(`Error response from daemon: No such image: workspace-template:openclaw`), true}, {"no such image lowercase", fmtErr(`error: no such image: foo:bar`), true}, {"image not found", fmtErr(`Error: image "workspace-template:crewai" not found`), true}, {"generic not found without image", fmtErr(`container not found`), false}, {"unrelated error", fmtErr(`connection refused`), false}, {"permission denied", fmtErr(`permission denied`), false}, } for _, tc := range cases { got := isImageNotFoundErr(tc.err) if got != tc.want { t.Errorf("%s: isImageNotFoundErr(%v) = %v, want %v", tc.name, tc.err, got, tc.want) } } } // fmtErr builds a plain error for table-driven tests without pulling in fmt. type testErr string func (e testErr) Error() string { return string(e) } func fmtErr(s string) error { return testErr(s) } // ---------- runtimeTagFromImage (issue #117) ---------- func TestRuntimeTagFromImage(t *testing.T) { cases := map[string]string{ "workspace-template:openclaw": "openclaw", "workspace-template:claude-code": "claude-code", "workspace-template:base": "base", // Fallbacks for non-standard shapes "myregistry.io/foo:v1.2": "v1.2", "no-colon-at-all": "no-colon-at-all", // Edge: trailing colon — use whole string (tag is empty) "foo:": "foo:", } for in, want := range cases { got := runtimeTagFromImage(in) if got != want { t.Errorf("runtimeTagFromImage(%q) = %q, want %q", in, got, want) } } } // ---------- End-to-end error-message shape ---------- // // Verifies the wrapped error that Start() surfaces when ContainerCreate // hits "no such image" — callers rely on both the human hint and the // original underlying error being preserved (via %w) for errors.Is chains. func TestImageNotFoundErrorIncludesBuildHint(t *testing.T) { // Simulate the exact wrap Start() produces without needing a real // Docker daemon (the live verification path runs via the e2e stage). underlying := testErr(`Error response from daemon: No such image: workspace-template:openclaw`) if !isImageNotFoundErr(underlying) { t.Fatalf("precondition failed: classifier didn't recognise moby's message") } tag := runtimeTagFromImage("workspace-template:openclaw") wrapped := testErr( `docker image "workspace-template:openclaw" not found — run 'bash workspace-template/build-all.sh ` + tag + `' to build it (underlying error: ` + underlying.Error() + `)`, ) s := wrapped.Error() for _, want := range []string{ `"workspace-template:openclaw"`, `bash workspace-template/build-all.sh openclaw`, `No such image: workspace-template:openclaw`, } { if !strings.Contains(s, want) { t.Errorf("wrapped error missing %q, got: %s", want, s) } } }