From 78a6155b1f560ac1352c8efff6e9e82613650a55 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 21 Jul 2026 21:08:09 -0500 Subject: [PATCH] feat(up): add --devcontainer to ignore the project's devcontainer Adds a --devcontainer source selector to `workspace up` so a user can bypass the project's devcontainer.json entirely, instead of only overriding its image field via --devcontainer-image. Supported specs: --devcontainer none ignore the project config; use --devcontainer-image or language detection --devcontainer image: use only that image When a source spec is set, config resolution skips project discovery (so multiple .devcontainer profiles no longer error) and synthesizes an image-only config with no features, lifecycle hooks, or mounts from the project. The synthesized config is written to a devsy-namespaced filename (.devcontainer.devsy.json) so it never overwrites a user's own root .devcontainer.json, which is a spec-valid config location. This is phase 1 of a source/overlay/modifier axis model; id: and path specs plus aliasing of --devcontainer-path/--devcontainer-id/ --fallback-image are planned follow-ups. --- cmd/workspace/up/up.go | 5 ++ cmd/workspace/up/up_flags.go | 7 ++- pkg/devcontainer/config.go | 46 +++++++++++++++++ pkg/devcontainer/config_test.go | 91 +++++++++++++++++++++++++++++++++ pkg/devcontainer/source.go | 45 ++++++++++++++++ pkg/devcontainer/source_test.go | 47 +++++++++++++++++ pkg/provider/workspace.go | 1 + 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 pkg/devcontainer/source.go create mode 100644 pkg/devcontainer/source_test.go diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 97f2b8fa90..0421148d70 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/opener" @@ -85,6 +86,10 @@ func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) err cmd.Context) } + if _, err := devcontainer.ParseSourceSpec(cmd.DevContainerSource); err != nil { + return err + } + args := []string{opts.Source} client, err := cmd.prepareClient(ctx, devsyConfig, args) if err != nil { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index bea0242d74..c0d293ed85 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -57,9 +57,14 @@ func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { } func (cmd *UpCmd) registerBuildFlags(upCmd *cobra.Command) { + upCmd.Flags(). + StringVar(&cmd.DevContainerSource, "devcontainer", "", + "Override where the devcontainer config comes from, ignoring the project's: "+ + `"none" (ignore it; use --devcontainer-image or language detection) or `+ + `"image:" (use only that image)`) upCmd.Flags(). StringVar(&cmd.DevContainerImage, "devcontainer-image", "", - "The container image to use, this will override the devcontainer.json value in the project") + "The container image to use, this overrides the image in the resolved devcontainer config") upCmd.Flags(). StringVar(&cmd.DevContainerPath, "devcontainer-path", "", "The path to the devcontainer.json relative to the project") upCmd.Flags().StringVar(&cmd.DevContainerPath, "config", "", "Alias for --devcontainer-path") diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index f67e585967..4c22230e3e 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -22,6 +22,9 @@ import ( // 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) + } if conf := r.rawConfigFromWorkspace(); conf != nil { return conf, nil } @@ -151,6 +154,49 @@ func workspaceMountFolderWarning(conf *config.DevContainerConfig) string { return "" } +func (r *runner) rawConfigFromSource( + options provider.CLIOptions, +) (*config.DevContainerConfig, error) { + spec, err := ParseSourceSpec(options.DevContainerSource) + if err != nil { + return nil, err + } + + switch spec.Kind { + case SourceImage: + log.Infof("ignoring project devcontainer, using image %s", spec.Image) + return r.saveSynthesizedConfig(&config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: spec.Image}, + }) + case SourceNone: + log.Infof("ignoring project devcontainer") + defaultConfig := &config.DevContainerConfig{} + if options.FallbackImage != "" { + log.Infof("Using fallback image %s", options.FallbackImage) + defaultConfig.ImageContainer = config.ImageContainer{Image: options.FallbackImage} + } else { + log.Infof("Try detecting project programming language") + defaultConfig = language.DefaultConfig(r.localWorkspaceFolder) + } + return r.saveSynthesizedConfig(defaultConfig) + default: + return nil, fmt.Errorf("unsupported devcontainer source kind %q", spec.Kind) + } +} + +func (r *runner) saveSynthesizedConfig( + c *config.DevContainerConfig, +) (*config.DevContainerConfig, error) { + c.Origin = path.Join( + filepath.ToSlash(r.localWorkspaceFolder), + ".devcontainer."+pkgconfig.BinaryName+".json", + ) + if err := config.SaveDevContainerJSON(c); err != nil { + return nil, fmt.Errorf("write synthesized devcontainer.json: %w", err) + } + return c, nil +} + func (r *runner) getDefaultConfig( options provider.CLIOptions, ) (*config.DevContainerConfig, error) { diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 36c7b94a27..a84b0bf834 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -2,6 +2,7 @@ package devcontainer import ( "os" + "path/filepath" "testing" "github.com/devsy-org/devsy/pkg/devcontainer/config" @@ -422,3 +423,93 @@ func TestWorkspaceMountFolderWarning(t *testing.T) { }) } } + +func newRunnerAt(folder string) *runner { + return &runner{ + id: "test-id", + localWorkspaceFolder: folder, + workspaceConfig: &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{ID: "test-workspace"}, + }, + } +} + +func seedAmbiguousProfiles(t *testing.T, folder string) { + t.Helper() + for _, id := range []string{"claude", "default"} { + dir := filepath.Join(folder, ".devcontainer", id) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + body := `{"image":"repo/image","features":{"ghcr.io/x/y:1":{}}}` + file := filepath.Join(dir, "devcontainer.json") + if err := os.WriteFile(file, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestGetRawConfig_SourceImageBypassesDiscovery(t *testing.T) { + folder := t.TempDir() + seedAmbiguousProfiles(t, folder) + r := newRunnerAt(folder) + + const image = "python" + conf, err := r.getRawConfig(provider2.CLIOptions{DevContainerSource: "image:" + image}) + 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_SourceNoneWithImageBypassesDiscovery(t *testing.T) { + folder := t.TempDir() + seedAmbiguousProfiles(t, folder) + r := newRunnerAt(folder) + + conf, err := r.getRawConfig(provider2.CLIOptions{ + DevContainerSource: string(SourceNone), + FallbackImage: "ubuntu", + }) + if err != nil { + t.Fatalf("getRawConfig: %v", err) + } + if len(conf.Features) != 0 { + t.Errorf("Features = %v, want none", conf.Features) + } +} + +func TestGetRawConfig_InvalidSource(t *testing.T) { + r := newRunnerAt(t.TempDir()) + if _, err := r.getRawConfig(provider2.CLIOptions{DevContainerSource: "bogus"}); err == nil { + t.Fatal("expected error for invalid source spec") + } +} + +func TestGetRawConfig_SourcePreservesRootDevcontainerJSON(t *testing.T) { + folder := t.TempDir() + rootConfig := filepath.Join(folder, ".devcontainer.json") + original := []byte(`{"image":"user/original","features":{"ghcr.io/x/y:1":{}}}`) + if err := os.WriteFile(rootConfig, original, 0o600); err != nil { + t.Fatal(err) + } + r := newRunnerAt(folder) + + opts := provider2.CLIOptions{DevContainerSource: "image:python"} + if _, err := r.getRawConfig(opts); err != nil { + t.Fatalf("getRawConfig: %v", err) + } + + got, err := os.ReadFile(rootConfig) //nolint:gosec // G304 — test temp file + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Errorf("root .devcontainer.json was modified:\n got: %s\n want: %s", got, original) + } +} diff --git a/pkg/devcontainer/source.go b/pkg/devcontainer/source.go new file mode 100644 index 0000000000..d227fa9e10 --- /dev/null +++ b/pkg/devcontainer/source.go @@ -0,0 +1,45 @@ +package devcontainer + +import ( + "fmt" + "strings" +) + +type SourceSpec struct { + Kind SourceKind + Image string +} + +type SourceKind string + +const ( + SourceNone SourceKind = "none" + SourceImage SourceKind = "image" +) + +const sourceImagePrefix = "image:" + +func ParseSourceSpec(value string) (*SourceSpec, error) { + value = strings.TrimSpace(value) + switch { + case value == "": + return nil, nil + case value == string(SourceNone): + return &SourceSpec{Kind: SourceNone}, nil + case strings.HasPrefix(value, sourceImagePrefix): + image := strings.TrimSpace(strings.TrimPrefix(value, sourceImagePrefix)) + if image == "" { + return nil, fmt.Errorf( + "--devcontainer %q: missing image reference after %q", + value, + sourceImagePrefix, + ) + } + return &SourceSpec{Kind: SourceImage, Image: image}, nil + default: + return nil, fmt.Errorf( + "--devcontainer %q: unsupported value, expected \"none\" or \"image:\"", + value, + ) + } +} diff --git a/pkg/devcontainer/source_test.go b/pkg/devcontainer/source_test.go new file mode 100644 index 0000000000..682a764724 --- /dev/null +++ b/pkg/devcontainer/source_test.go @@ -0,0 +1,47 @@ +package devcontainer + +import ( + "testing" + + "gotest.tools/assert" +) + +const testImage = "python" + +func TestParseSourceSpec(t *testing.T) { + cases := []struct { + in string + wantKind SourceKind + wantImage string + wantNil bool + wantErr bool + }{ + {in: "", wantNil: true}, + {in: " ", wantNil: true}, + {in: string(SourceNone), wantKind: SourceNone}, + {in: sourceImagePrefix + testImage, wantKind: SourceImage, wantImage: testImage}, + { + in: "image:mcr.microsoft.com/devcontainers/python:3", + wantKind: SourceImage, + wantImage: "mcr.microsoft.com/devcontainers/python:3", + }, + {in: "image: python ", wantKind: SourceImage, wantImage: "python"}, + {in: "image:", wantErr: true}, + {in: "id:default", wantErr: true}, + {in: "bogus", wantErr: true}, + } + for _, c := range cases { + spec, err := ParseSourceSpec(c.in) + if c.wantErr { + assert.Assert(t, err != nil, "input %q should error", c.in) + continue + } + assert.NilError(t, err, "input %q", c.in) + if c.wantNil { + assert.Assert(t, spec == nil, "input %q should yield nil spec", c.in) + continue + } + assert.Equal(t, c.wantKind, spec.Kind, "input %q kind", c.in) + assert.Equal(t, c.wantImage, spec.Image, "input %q image", c.in) + } +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 7f8c769f71..4e0a132fa0 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -215,6 +215,7 @@ type CLIOptions struct { DevContainerImage string `json:"devContainerImage,omitempty"` DevContainerPath string `json:"devContainerPath,omitempty"` DevContainerID string `json:"devContainerID,omitempty"` + DevContainerSource string `json:"devContainerSource,omitempty"` WorkspaceEnv []string `json:"workspaceEnv,omitempty"` WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"` SecretsEnv []string `json:"secretsEnv,omitempty"`