molecule-core/platform/internal/provisioner/provisioner_test.go
Hongming Wang f7683e3adf fix(provisioner): stop rogue config-missing restart loop (#17)
Resolves #17.

Part A: scripts/cleanup-rogue-workspaces.sh deletes workspaces whose id
or name starts with known test placeholder prefixes (aaaaaaaa-, etc.)
and force-removes the paired Docker container. Documented in
tests/README.md.

Part B: add a pre-flight check in provisionWorkspace() — when neither a
template path nor in-memory configFiles supplies config.yaml, probe the
existing named volume via a throwaway alpine container. If the volume
lacks config.yaml, mark the workspace status='failed' with a clear
last_sample_error instead of handing it to Docker's unless-stopped
restart policy (which otherwise loops forever on FileNotFoundError).

New pure helper provisioner.ValidateConfigSource + unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:32:58 -07:00

665 lines
20 KiB
Go

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)
}
}
}