diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 8a5525974..7ad698953 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -144,6 +144,7 @@ func (cmd *UpCmd) buildWorkspaceOptions(workspace *provider2.Workspace) provider baseOptions.ID = workspace.ID baseOptions.DevContainerPath = workspace.DevContainerPath baseOptions.DevContainerImage = workspace.DevContainerImage + baseOptions.DevContainerSource = workspace.DevContainerSource baseOptions.IDE = workspace.IDE.Name baseOptions.IDEOptions = nil baseOptions.Source = workspace.Source.String() diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 9f0fcddc1..ad720f8bc 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -108,6 +108,7 @@ func (cmd *UpCmd) resolveParams( ReconfigureProvider: cmd.Reconfigure, DevContainerImage: cmd.DevContainerImage, DevContainerPath: cmd.DevContainerPath, + DevContainerSource: cmd.DevContainerSource, SSHConfigPath: cmd.SSHConfigPath, SSHConfigIncludePath: devsyConfig.ContextOption( config.ContextOptionSSHConfigIncludePath, diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index 8fbe49e22..607480ae2 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -36,8 +36,12 @@ const ( // each supported source in order and falling back to an auto-detected default // when none applies. func (r *runner) getRawConfig(options provider.CLIOptions) (*config.DevContainerConfig, error) { - if options.DevContainerSource != "" { - return r.rawConfigFromSource(options) + source := options.DevContainerSource + if source == "" { + source = r.workspaceConfig.Workspace.DevContainerSource + } + if source != "" { + return r.rawConfigFromSource(source, options) } if conf := r.rawConfigFromWorkspace(); conf != nil { return conf, nil @@ -169,9 +173,10 @@ func workspaceMountFolderWarning(conf *config.DevContainerConfig) string { } func (r *runner) rawConfigFromSource( + source string, options provider.CLIOptions, ) (*config.DevContainerConfig, error) { - spec, err := ParseSourceSpec(options.DevContainerSource) + spec, err := ParseSourceSpec(source) if err != nil { return nil, err } diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 8671d34ec..37dbbcdea 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -467,6 +467,41 @@ func TestGetRawConfig_SourceImageBypassesDiscovery(t *testing.T) { } } +func TestGetRawConfig_PersistedSourceImageBypassesDiscovery(t *testing.T) { + folder := t.TempDir() + seedAmbiguousProfiles(t, folder) + r := newRunnerAt(folder) + // Simulate a restart: the CLI option is absent, but the override was + // persisted on the workspace during the first up. + const image = "python" + r.workspaceConfig.Workspace.DevContainerSource = "image:" + image + + conf, err := r.getRawConfig(provider2.CLIOptions{}) + if err != nil { + t.Fatalf("getRawConfig: %v", err) + } + if conf.Image != image { + t.Errorf("Image = %q, want %q", conf.Image, image) + } + if len(conf.Features) != 0 { + t.Errorf("Features = %v, want none (project features must be ignored)", conf.Features) + } +} + +func TestGetRawConfig_CLISourceOverridesPersisted(t *testing.T) { + folder := t.TempDir() + r := newRunnerAt(folder) + r.workspaceConfig.Workspace.DevContainerSource = "image:persisted" + + conf, err := r.getRawConfig(provider2.CLIOptions{DevContainerSource: "image:cli"}) + if err != nil { + t.Fatalf("getRawConfig: %v", err) + } + if conf.Image != "cli" { + t.Errorf("Image = %q, want %q (CLI option must win over persisted)", conf.Image, "cli") + } +} + func TestGetRawConfig_SourceNoneWithImageBypassesDiscovery(t *testing.T) { folder := t.TempDir() seedAmbiguousProfiles(t, folder) diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 46cebcb30..c1c2b7fc1 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -49,6 +49,11 @@ type Workspace struct { // DevContainerPath is the relative path where the devcontainer.json is located. DevContainerPath string `json:"devContainerPath,omitempty"` + // DevContainerSource is the devcontainer source override (e.g. "image:" + // or "none") that ignores the project's devcontainer.json. It is persisted so + // restarts reuse the same override instead of falling back to discovery. + DevContainerSource string `json:"devContainerSource,omitempty"` + // DevContainerConfig holds the config for the devcontainer.json. DevContainerConfig *devcontainerconfig.DevContainerConfig `json:"devContainerConfig,omitempty"` diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index 5346bf571..db50452cf 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -54,6 +54,7 @@ type ResolveParams struct { ReconfigureProvider bool DevContainerImage string DevContainerPath string + DevContainerSource string SSHConfigPath string SSHConfigIncludePath string Source *providerpkg.WorkspaceSource @@ -131,6 +132,11 @@ func applyDevContainerOverrides(workspace *providerpkg.Workspace, params Resolve workspace.DevContainerPath = params.DevContainerPath changed = true } + if params.DevContainerSource != "" && + workspace.DevContainerSource != params.DevContainerSource { + workspace.DevContainerSource = params.DevContainerSource + changed = true + } if !changed && workspace.Source.Container == "" { return nil diff --git a/pkg/workspace/workspace_test.go b/pkg/workspace/workspace_test.go index 4566e5401..b8042055e 100644 --- a/pkg/workspace/workspace_test.go +++ b/pkg/workspace/workspace_test.go @@ -223,3 +223,19 @@ func TestApplyDevContainerOverrides_SetsImageAndPath(t *testing.T) { assert.Equal(t, testDevContainerImage, loaded.DevContainerImage) assert.Equal(t, testDevContainerPath, loaded.DevContainerPath) } + +func TestApplyDevContainerOverrides_PersistsSource(t *testing.T) { + setupTestPathManager(t) + + ws := &providerpkg.Workspace{ID: "ws-source", Context: testDefaultContext} + const source = "image:ghcr.io/example/image:latest" + err := applyDevContainerOverrides(ws, ResolveParams{DevContainerSource: source}) + require.NoError(t, err) + + assert.Equal(t, source, ws.DevContainerSource) + + // The source override must survive a restart, so it has to be persisted. + loaded, err := providerpkg.LoadWorkspaceConfig(testDefaultContext, ws.ID) + require.NoError(t, err) + assert.Equal(t, source, loaded.DevContainerSource) +}