Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/workspace/up/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion cmd/workspace/up/up_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<ref>" (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")
Expand Down
46 changes: 46 additions & 0 deletions pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
91 changes: 91 additions & 0 deletions pkg/devcontainer/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package devcontainer

import (
"os"
"path/filepath"
"testing"

"github.com/devsy-org/devsy/pkg/devcontainer/config"
Expand Down Expand Up @@ -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)
}
}
45 changes: 45 additions & 0 deletions pkg/devcontainer/source.go
Original file line number Diff line number Diff line change
@@ -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:<ref>\"",
value,
)
}
}
47 changes: 47 additions & 0 deletions pkg/devcontainer/source_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions pkg/provider/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading