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
10 changes: 10 additions & 0 deletions cmd/workspace/up/up_devcontainer_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ func TestResolveDevContainerSource_Path(t *testing.T) {
assert.Empty(t, cmd.DevContainerSource)
}

func TestResolveDevContainerSource_ExternalPathPassThrough(t *testing.T) {
cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}}
cmd.DevContainerSource = "/abs/external/devcontainer.json"

require.NoError(t, cmd.resolveDevContainerSource())
assert.Equal(t, "/abs/external/devcontainer.json", cmd.DevContainerSource)
assert.Empty(t, cmd.DevContainerPath)
assert.Empty(t, cmd.DevContainerID)
}

func TestResolveDevContainerSource_NoneAndImagePassThrough(t *testing.T) {
for _, spec := range []string{srcNone, "image:python"} {
cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}}
Expand Down
6 changes: 4 additions & 2 deletions cmd/workspace/up/up_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ func (cmd *UpCmd) resolveDevContainerSource() error {
cmd.DevContainerID = spec.ID
cmd.DevContainerSource = ""
case devcontainer.SourcePath:
cmd.DevContainerPath = spec.Path
cmd.DevContainerSource = ""
if !filepath.IsAbs(spec.Path) {
cmd.DevContainerPath = spec.Path
cmd.DevContainerSource = ""
}
case devcontainer.SourceNone, devcontainer.SourceImage:
}
return nil
Expand Down
151 changes: 151 additions & 0 deletions pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,27 @@ import (
"path"
"path/filepath"
"slices"
"strings"

pkgconfig "github.com/devsy-org/devsy/pkg/config"
copypkg "github.com/devsy-org/devsy/pkg/copy"
"github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/devcontainer/crane"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/language"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/random"
)

var (
importedProfileParent = filepath.ToSlash(devcontainerProfileParent)
importedProfileMarker = "." + pkgconfig.BinaryName + "-imported"
)

const (
devcontainerProfileParent = ".devcontainer"
importedProfileName = pkgconfig.BinaryName
)

// getRawConfig resolves the raw devcontainer config for the workspace, trying
Expand Down Expand Up @@ -180,11 +193,149 @@ func (r *runner) rawConfigFromSource(
defaultConfig = language.DefaultConfig(r.localWorkspaceFolder)
}
return r.saveSynthesizedConfig(defaultConfig)
case SourcePath:
return r.importExternalDevContainer(spec.Path)
case SourceID:
return nil, fmt.Errorf("devcontainer id source must be resolved before build")
default:
return nil, fmt.Errorf("unsupported devcontainer source kind %q", spec.Kind)
}
}

func (r *runner) importExternalDevContainer(srcPath string) (*config.DevContainerConfig, error) {
absPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("resolve devcontainer path %s: %w", srcPath, err)
}
srcPath = absPath
if _, err := os.Stat(srcPath); err != nil {
return nil, fmt.Errorf("devcontainer path %s does not exist: %w", srcPath, err)
}

relDir := r.importedProfileRelDir()
destDir := filepath.Join(r.localWorkspaceFolder, filepath.FromSlash(relDir))
if err := copyExternalDevContainer(srcPath, destDir); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(destDir, importedProfileMarker), nil, 0o600); err != nil {
return nil, fmt.Errorf("mark imported devcontainer: %w", err)
}
if err := r.excludeFromGit(relDir); err != nil {
log.Debugf("could not add imported devcontainer to git exclude: %v", err)
}

origin := filepath.Join(destDir, filepath.Base(srcPath))
rawConfig, err := config.ParseDevContainerJSONFile(context.Background(), origin)
if err != nil {
return nil, fmt.Errorf("parse imported devcontainer.json: %w", err)
}
return rawConfig, nil
}

func (r *runner) importedProfileRelDir() string {
base := path.Join(importedProfileParent, importedProfileName)
plain := filepath.Join(r.localWorkspaceFolder, filepath.FromSlash(base))
if isImportedProfileDir(plain) || !dirExists(plain) {
return base
}
return base + "_" + random.String(6)
}

func copyExternalDevContainer(srcPath, destDir string) error {
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("clear imported devcontainer dir: %w", err)
}
if isSelfContainedDevContainer(srcPath) {
if err := copypkg.Directory(filepath.Dir(srcPath), destDir); err != nil {
return fmt.Errorf("copy devcontainer folder: %w", err)
}
return nil
}
if err := copypkg.CreateIfNotExists(destDir, 0o755); err != nil {
return fmt.Errorf("create imported devcontainer dir: %w", err)
}
dest := filepath.Join(destDir, filepath.Base(srcPath))
if err := copypkg.File(srcPath, dest, 0o644); err != nil {
return fmt.Errorf("copy devcontainer file: %w", err)
}
return nil
}

func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}

// isSelfContainedDevContainer reports whether srcPath lives in a dedicated
// devcontainer directory whose sibling assets (Dockerfile, features, compose)
// belong with the config and must be copied alongside it. To avoid pulling in
// arbitrary trees, this is limited to the spec's own layout: the config's
// parent directory is ".devcontainer", or a profile directory nested directly
// under ".devcontainer" (".devcontainer/<name>/devcontainer.json"). A config
// that sits anywhere else (e.g. a project root) is treated as a bare file.
func isSelfContainedDevContainer(srcPath string) bool {
dir := filepath.Dir(srcPath)
if filepath.Base(dir) == devcontainerProfileParent {
return true
}
return filepath.Base(filepath.Dir(dir)) == devcontainerProfileParent
}

func isImportedProfileDir(dir string) bool {
_, err := os.Stat(filepath.Join(dir, importedProfileMarker))
return err == nil
}

func (r *runner) excludeFromGit(relPath string) error {
excludePath := filepath.Join(r.localWorkspaceFolder, ".git", "info", "exclude")
if _, err := os.Stat(filepath.Dir(excludePath)); err != nil {
return nil // nothing to do
}
// #nosec G304 -- path is under the workspace .git dir
existing, err := os.ReadFile(excludePath)
if err != nil && !os.IsNotExist(err) {
return err
}
entry := "/" + filepath.ToSlash(relPath)
for line := range strings.SplitSeq(string(existing), "\n") {
if strings.TrimSpace(line) == entry {
return nil // already excluded
}
}
// #nosec G304 -- path is under the workspace .git dir
f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
_, err = fmt.Fprintf(f, "%s\n", entry)
return err
}

func CleanupImportedDevContainers(workspaceFolder string) error {
parent := filepath.Join(workspaceFolder, filepath.FromSlash(importedProfileParent))
entries, err := os.ReadDir(parent)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(parent, e.Name())
if !isImportedProfileDir(dir) {
continue
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove imported devcontainer %s: %w", dir, err)
}
}
return nil
}

func (r *runner) saveSynthesizedConfig(
c *config.DevContainerConfig,
) (*config.DevContainerConfig, error) {
Expand Down
16 changes: 13 additions & 3 deletions pkg/devcontainer/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func (r *runner) Delete(ctx context.Context, options DeleteOptions) error {
return fmt.Errorf("find dev container: %w", err)
}
defer r.cleanupDeliveryVolume(ctx)
defer r.cleanupImportedDevContainer()
if containerDetails == nil {
return nil
}
Expand Down Expand Up @@ -42,11 +43,20 @@ func (r *runner) Delete(ctx context.Context, options DeleteOptions) error {
return nil
}

// cleanupDeliveryVolume removes the devsy-managed volumes created for this
// workspace. Best-effort: failures are logged, not returned.
func (r *runner) cleanupDeliveryVolume(ctx context.Context) {
if err := r.newAgentDelivery().Cleanup(ctx, r.id); err != nil {
log.Debugf("best-effort delivery volume cleanup: %v", err)
log.Debugf("delivery volume cleanup: %v", err)
}
}

func (r *runner) cleanupImportedDevContainer() {
if r.workspaceConfig == nil ||
r.workspaceConfig.Workspace == nil ||
r.workspaceConfig.Workspace.Source.LocalFolder == "" {
return
}
if err := CleanupImportedDevContainers(r.localWorkspaceFolder); err != nil {
log.Debugf("imported devcontainer cleanup: %v", err)
}
}

Expand Down
34 changes: 34 additions & 0 deletions pkg/devcontainer/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"testing"

"github.com/devsy-org/devsy/pkg/devcontainer/config"
Expand Down Expand Up @@ -175,3 +176,36 @@ func TestCleanupDeliveryVolume_DoesNotPanic(t *testing.T) {

r.cleanupDeliveryVolume(context.Background())
}

func TestDelete_RemovesImportedDevContainer(t *testing.T) {
ws := t.TempDir()
external := filepath.Join(t.TempDir(), "devcontainer.json")
writeFile(t, external, `{"image":"alpine"}`)

r := newTestRunner(&mockDriver{findResult: nil})
r.localWorkspaceFolder = ws
r.workspaceConfig.Workspace = &provider.Workspace{
Source: provider.WorkspaceSource{LocalFolder: ws},
}

if _, err := r.importExternalDevContainer(external); err != nil {
t.Fatalf("import failed: %v", err)
}
if !dirExists(importedProfilePath(ws)) {
t.Fatal("expected imported profile before delete")
}

if err := r.Delete(context.Background(), DeleteOptions{}); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if dirExists(importedProfilePath(ws)) {
t.Error("imported profile should be removed after Delete")
}
}

func TestDelete_NonLocalSource_KeepsNothingToClean(t *testing.T) {
r := newTestRunner(&mockDriver{findResult: nil})
if err := r.Delete(context.Background(), DeleteOptions{}); err != nil {
t.Fatalf("Delete failed: %v", err)
}
}
Loading
Loading