Skip to content
Merged
17 changes: 17 additions & 0 deletions cmd/context/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package context
import (
"context"
"fmt"
"os"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/config"
Expand Down Expand Up @@ -72,6 +73,22 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error {
return fmt.Errorf("save config: %w", err)
}

return removeContextDir(context)
}

// removeContextDir removes the on-disk directory tree for a deleted context
// (workspaces/, contents/, machines/, providers/, pro_instances/, locks/).
// This runs last, after config.yaml is already saved without the context, so
// a failure here leaves the context correctly unregistered even though some
// disk cleanup may need a manual retry.
func removeContextDir(contextName string) error {
dir, err := config.DefaultPathManager().ContextDir(contextName)
if err != nil {
return err
}
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete context dir: %w", err)
}
return nil
}

Expand Down
51 changes: 51 additions & 0 deletions cmd/context/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package context

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

"github.com/devsy-org/devsy/cmd/flags"
pkgconfig "github.com/devsy-org/devsy/pkg/config"
)

func TestDeleteCmd_RemovesContextDirFromDisk(t *testing.T) {
pkgconfig.ResetPathManager()
t.Cleanup(pkgconfig.ResetPathManager)
home := t.TempDir()
t.Setenv(pkgconfig.EnvHome, home)
t.Setenv(pkgconfig.EnvConfig, filepath.Join(home, "config.yaml"))

// Seed a config.yaml with a second, non-default context so delete is legal.
cfg := &pkgconfig.Config{
DefaultContext: pkgconfig.DefaultContext,
Contexts: map[string]*pkgconfig.ContextConfig{
pkgconfig.DefaultContext: {},
"staging": {},
},
}
if err := pkgconfig.SaveConfig(cfg); err != nil {
t.Fatal(err)
}

contextDir, err := pkgconfig.DefaultPathManager().ContextDir("staging")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(
filepath.Join(contextDir, "workspaces", "some-workspace"),
0o750,
); err != nil {
t.Fatal(err)
}

cmd := &DeleteCmd{GlobalFlags: &flags.GlobalFlags{}}
if err := cmd.Run(context.Background(), "staging"); err != nil {
t.Fatal(err)
}

if _, err := os.Stat(contextDir); !os.IsNotExist(err) {
t.Fatalf("expected context dir %s to be removed, stat err = %v", contextDir, err)
}
}
13 changes: 13 additions & 0 deletions cmd/machine/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/client"
"github.com/devsy-org/devsy/pkg/client/clientimplementation"
"github.com/devsy-org/devsy/pkg/config"
cliflags "github.com/devsy-org/devsy/pkg/flags"
"github.com/devsy-org/devsy/pkg/flags/names"
Expand Down Expand Up @@ -89,5 +90,17 @@ func (cmd *DeleteCmd) Run(ctx context.Context, args []string) error {
return err
}

return removeMachineDirIfPresent(devsyConfig.DefaultContext, machineClient.Machine())
}

// removeMachineDirIfPresent removes the on-disk machine directory after a
// successful remote delete. provider delete (cmd/provider/delete.go) and
// pro logout (cmd/pro/logout.go) already remove their directories the same
// way after their remote/config cleanup succeeds; the standalone machine
// delete path never did.
func removeMachineDirIfPresent(context, machineID string) error {
if err := clientimplementation.DeleteMachineFolder(context, machineID); err != nil {
return fmt.Errorf("delete machine dir: %w", err)
}
return nil
}
37 changes: 37 additions & 0 deletions cmd/machine/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package machine

import (
"os"
"testing"

pkgconfig "github.com/devsy-org/devsy/pkg/config"
)

func TestDeleteCmd_RemovesMachineDirFromDisk(t *testing.T) {
pkgconfig.ResetPathManager()
t.Cleanup(pkgconfig.ResetPathManager)
home := t.TempDir()
t.Setenv(pkgconfig.EnvHome, home)

machineDir, err := pkgconfig.DefaultPathManager().
MachineDir(pkgconfig.DefaultContext, "test-machine")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(machineDir, 0o750); err != nil {
t.Fatal(err)
}

// NOTE: this test documents the expected end state. Wiring a full
// DeleteCmd.Run() call requires a fake machine provider/client; if that
// scaffolding is too heavy for this package, downgrade this to a direct
// test of a small extracted helper (see Step 3) instead of the full
// cobra command path.
if err := removeMachineDirIfPresent(pkgconfig.DefaultContext, "test-machine"); err != nil {
t.Fatal(err)
}

if _, err := os.Stat(machineDir); !os.IsNotExist(err) {
t.Fatalf("expected machine dir %s to be removed, stat err = %v", machineDir, err)
}
}
4 changes: 3 additions & 1 deletion pkg/agent/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ func findDir(agentDir string, validate func(path string) bool) string {

func candidateAgentDirs() []string {
var dirs []string
if home, _ := util.UserHomeDir(); home != "" {
if home := os.Getenv(config.EnvHome); home != "" {
dirs = append(dirs, filepath.Join(home, "agent"))
} else if home, _ := util.UserHomeDir(); home != "" {
dirs = append(dirs, filepath.Join(home, config.ConfigDirName, "agent"))
}
if root, _ := command.GetHome("root"); root != "" {
Expand Down
38 changes: 38 additions & 0 deletions pkg/agent/workspace_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package agent

import (
"path/filepath"
"testing"

"github.com/devsy-org/devsy/pkg/config"
provider2 "github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/types"
"github.com/devsy-org/devsy/pkg/util"
)

const explicitAgentDir = "/some/dir"
Expand Down Expand Up @@ -90,6 +93,41 @@ func TestIsHostAgentInvocation(t *testing.T) {
}
}

func TestCandidateAgentDirs_HonorsDevsyHomeOverride(t *testing.T) {
home := t.TempDir()
t.Setenv(config.EnvHome, home)

dirs := candidateAgentDirs()

want := filepath.Join(home, "agent")
found := false
for _, d := range dirs {
if d == want {
found = true
}
// The raw-OS-home candidate must NOT appear when DEVSY_HOME is set.
// This guards candidateAgentDirs' own EnvHome handling directly: both
// production callers (FindAgentHomeDir/PrepareAgentHomeDir) go through
// findDir, which already short-circuits on DEVSY_HOME before reaching
// this function, so this test hardens against a future caller that
// skips findDir rather than a currently-reachable bug.
realHome, _ := util.UserHomeDir()
if realHome != "" && realHome != home {
unwanted := filepath.Join(realHome, config.ConfigDirName, "agent")
if d == unwanted {
t.Fatalf(
"candidateAgentDirs() included raw-home candidate %q while DEVSY_HOME=%q was set",
unwanted,
home,
)
}
}
}
if !found {
t.Fatalf("candidateAgentDirs() = %v, want it to include %q", dirs, want)
}
}

func TestIsHostAgentInvocation_IgnoresDevsyHome(t *testing.T) {
t.Setenv("DEVSY_HOME", "/custom/devsy/home")
withContainerDetector(t, func() bool { return false })
Expand Down
19 changes: 19 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,22 @@ import (
"sigs.k8s.io/yaml"
)

// CurrentSchemaVersion is the on-disk layout version this build of devsy
// understands. LoadConfig stamps it onto every config it reads (fresh or
// pre-existing) so a future layout change (e.g. finishing the agent/host
// tree unification started in this cleanup) has a documented baseline to
// detect and migrate from, the same way pkg/snapshot.Manifest.SchemaVersion
// already versions snapshots. SaveConfig does not stamp independently — it
// persists whatever value is already on the in-memory Config.
const CurrentSchemaVersion = 1

type Config struct {
// SchemaVersion is the DEVSY_HOME on-disk layout version. Absent/zero on
// configs written before this field existed; LoadConfig treats that the
// same as CurrentSchemaVersion (see normalizeConfig) since no versioned
// layout change has shipped yet.
SchemaVersion int `json:"schemaVersion,omitempty" yaml:"schemaVersion,omitempty"`

// DefaultContext is the default context to use. Defaults to "default"
DefaultContext string `json:"defaultContext,omitempty"`

Expand Down Expand Up @@ -261,6 +276,7 @@ func newMissingConfig(configOrigin, contextOverride, providerOverride string) *C
}

return &Config{
SchemaVersion: CurrentSchemaVersion,
DefaultContext: context,
Contexts: map[string]*ContextConfig{
context: {
Expand All @@ -275,6 +291,9 @@ func newMissingConfig(configOrigin, contextOverride, providerOverride string) *C
}

func normalizeConfig(config *Config, contextOverride, providerOverride string) {
if config.SchemaVersion == 0 {
config.SchemaVersion = CurrentSchemaVersion
}
if contextOverride != "" {
config.OriginalContext = config.DefaultContext
config.DefaultContext = contextOverride
Expand Down
67 changes: 67 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package config

import "testing"

func TestLoadConfig_StampsCurrentSchemaVersion(t *testing.T) {
ResetPathManager()
t.Cleanup(ResetPathManager)
home := t.TempDir()
t.Setenv(EnvHome, home)

cfg, err := LoadConfig("", "")
if err != nil {
t.Fatal(err)
}
if cfg.SchemaVersion != CurrentSchemaVersion {
t.Fatalf(
"SchemaVersion = %d, want %d (a freshly created config must be stamped)",
cfg.SchemaVersion,
CurrentSchemaVersion,
)
}

if err := SaveConfig(cfg); err != nil {
t.Fatal(err)
}

reloaded, err := LoadConfig("", "")
if err != nil {
t.Fatal(err)
}
if reloaded.SchemaVersion != CurrentSchemaVersion {
t.Fatalf(
"reloaded SchemaVersion = %d, want %d",
reloaded.SchemaVersion,
CurrentSchemaVersion,
)
}
}

func TestLoadConfig_StampsMissingSchemaVersionOnExistingConfig(t *testing.T) {
ResetPathManager()
t.Cleanup(ResetPathManager)
home := t.TempDir()
t.Setenv(EnvHome, home)

// Simulate a pre-existing config.yaml written before this field existed.
if err := SaveConfig(
&Config{
DefaultContext: DefaultContext,
Contexts: map[string]*ContextConfig{DefaultContext: {}},
},
); err != nil {
t.Fatal(err)
}

cfg, err := LoadConfig("", "")
if err != nil {
t.Fatal(err)
}
if cfg.SchemaVersion != CurrentSchemaVersion {
t.Fatalf(
"SchemaVersion = %d, want %d (an unstamped config loaded today must be treated as current)",
cfg.SchemaVersion,
CurrentSchemaVersion,
)
}
}
22 changes: 22 additions & 0 deletions pkg/config/pathmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ func homeOverrideDir(sub ...string) (string, bool, error) {
// PathManager centralises all filesystem path computation for the Devsy CLI.
// Per-OS implementations supply the five top-level directory methods; every
// sub-path is derived from those by the shared basePathManager.
//
// Canonical layout (all paths relative to the values these methods return —
// on the host CLI side, never construct a DEVSY_HOME-relative path outside
// this file; see pkg/provider/version_cache.go's history for why that rule
// exists. pkg/agent/workspace.go is a deliberate exception: it reimplements
// this same layout for agent-side code running inside the container/machine,
// where this package's PathManager singleton isn't available):
//
// ConfigDir()/DataDir() (same directory today; both may be $DEVSY_HOME)
// ├── config.yaml (ConfigFilePath, stamped with Config.SchemaVersion)
// └── contexts/<context>/ (ContextDir)
// ├── workspaces/<id>/{agent,logs} (WorkspaceDir, WorkspaceAgentDir, WorkspaceLogDir)
// ├── contents/<id> (WorkspaceContentsDir/WorkspaceContentDir — parent never removed)
// ├── machines/<id> (MachineDir)
// ├── providers/<name>/{binaries,daemon} (ProviderDir)
// ├── pro_instances/<id> (ProInstanceDir)
// └── locks/ (LocksDir)
// CacheDir() (default $HOME/.cache/devsy, or $DEVSY_HOME/cache when set)
// ├── agents/, providers/, features/<hash>/, platform/, keys/
// StateDir() (default under DataDir()/state)
// RuntimeDir() (default under DataDir()/run on darwin,
// OS temp dir on linux; see pathmanager_darwin.go / pathmanager_linux.go)
type PathManager interface {
// Top-level XDG category directories.
ConfigDir() (string, error)
Expand Down
8 changes: 2 additions & 6 deletions pkg/provider/version_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,11 @@ type ProviderVersionCacheEntry struct {
type ProviderVersionCache map[string]ProviderVersionCacheEntry

func providerVersionCachePath() (string, error) {
// Check for DEVSY_HOME override (primarily used in tests).
if home := os.Getenv(config.EnvHome); home != "" {
return filepath.Join(home, "cache", "provider-versions.json"), nil
}
dir, err := config.GetConfigDir()
dir, err := config.DefaultPathManager().CacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "cache", "provider-versions.json"), nil
return filepath.Join(dir, "provider-versions.json"), nil
}

func LoadProviderVersionCache() (ProviderVersionCache, error) {
Expand Down
Loading
Loading