diff --git a/cmd/env/bind.go b/cmd/env/bind.go new file mode 100644 index 0000000000..ac543ec2fa --- /dev/null +++ b/cmd/env/bind.go @@ -0,0 +1,120 @@ +package env + +import ( + "context" + "fmt" + "slices" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +type AttachCmd struct{ *flags.GlobalFlags } + +func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &AttachCmd{GlobalFlags: flags} + return &cobra.Command{ + Use: "attach NAME", + Short: "Bind a non-sensitive environment variable to the active context", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + return cmd.Run(c.Context(), args[0]) + }, + } +} + +func (cmd *AttachCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + if err := verifyAttachableEnv(devsyConfig, contextName, name); err != nil { + return err + } + if slices.Contains(ctxConfig.EnvVars, name) { + log.Infof("environment variable %q already attached to context %q", name, contextName) + return nil + } + ctxConfig.EnvVars = append(ctxConfig.EnvVars, name) + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("environment variable %q attached to context %q", name, contextName) + return nil +} + +func verifyAttachableEnv(devsyConfig *config.Config, contextName, name string) error { + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return fmt.Errorf("cannot attach environment variable %q: %w", name, err) + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret\"", name) + } + return nil +} + +type DetachCmd struct{ *flags.GlobalFlags } + +func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DetachCmd{GlobalFlags: flags} + return &cobra.Command{ + Use: "detach NAME", + Short: "Unbind an environment variable from the active context", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + return cmd.Run(c.Context(), args[0]) + }, + } +} + +func (cmd *DetachCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + idx := slices.Index(ctxConfig.EnvVars, name) + if idx < 0 { + log.Infof("environment variable %q is not attached to context %q", name, contextName) + return nil + } + ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("environment variable %q detached from context %q", name, contextName) + return nil +} diff --git a/cmd/env/bind_test.go b/cmd/env/bind_test.go new file mode 100644 index 0000000000..8bd9fed239 --- /dev/null +++ b/cmd/env/bind_test.go @@ -0,0 +1,122 @@ +package env + +import ( + "context" + "sync" + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +func setupEnvCommandTest(t *testing.T) *flags.GlobalFlags { + t.Helper() + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) + t.Setenv(config.EnvHome, t.TempDir()) + t.Setenv("DEVSY_SECRETS_BACKEND", "file") + t.Setenv("DEVSY_SECRETS_PASSPHRASE", "test-passphrase") + require.NoError(t, config.SaveConfig(&config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{config.DefaultContext: {}}, + })) + return &flags.GlobalFlags{} +} + +func TestAttachAndDetachEnvironmentVariable(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := &SetCmd{GlobalFlags: globalFlags, Value: "debug"} + require.NoError(t, set.Run(context.Background(), "LOG_LEVEL")) + + attach := &AttachCmd{GlobalFlags: globalFlags} + require.NoError(t, attach.Run(context.Background(), "LOG_LEVEL")) + require.NoError(t, attach.Run(context.Background(), "LOG_LEVEL")) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{"LOG_LEVEL"}, cfg.Current().EnvVars) + + detach := &DetachCmd{GlobalFlags: globalFlags} + require.NoError(t, detach.Run(context.Background(), "LOG_LEVEL")) + require.NoError(t, detach.Run(context.Background(), "LOG_LEVEL")) + cfg, err = config.LoadConfig("", "") + require.NoError(t, err) + require.Empty(t, cfg.Current().EnvVars) +} + +func TestAttachRejectsSecret(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + store, err := secrets.NewStoreForConfig(mustLoadConfig(t)) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "API_TOKEN", "secret", secrets.KindSecret)) + err = (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), "API_TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "is a secret") +} + +func TestDeleteUnbindsEnvironmentVariable(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := &SetCmd{GlobalFlags: globalFlags, Value: "debug"} + require.NoError(t, set.Run(context.Background(), "LOG_LEVEL")) + require.NoError( + t, + (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), "LOG_LEVEL"), + ) + require.NoError( + t, + (&DeleteCmd{GlobalFlags: globalFlags}).Run(context.Background(), "LOG_LEVEL"), + ) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.Empty(t, cfg.Current().EnvVars) +} + +func TestSetRejectsConvertingAttachedSecretToEnvironment(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + cfg := mustLoadConfig(t) + store, err := secrets.NewStoreForConfig(cfg) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "TOKEN", "secret", secrets.KindSecret)) + cfg.Current().Secrets = []string{"TOKEN"} + require.NoError(t, config.SaveConfig(cfg)) + err = (&SetCmd{GlobalFlags: globalFlags, Value: "plain"}).Run(context.Background(), "TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "attached as a secret") +} + +func TestConcurrentAttachmentsPreserveBothChanges(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := func(name string) { + require.NoError( + t, + (&SetCmd{GlobalFlags: globalFlags, Value: name}).Run(context.Background(), name), + ) + } + set("FIRST") + set("SECOND") + + var wg sync.WaitGroup + for _, name := range []string{"FIRST", "SECOND"} { + wg.Add(1) + go func(name string) { + defer wg.Done() + require.NoError( + t, + (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), name), + ) + }(name) + } + wg.Wait() + + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.ElementsMatch(t, []string{"FIRST", "SECOND"}, cfg.Current().EnvVars) +} + +func mustLoadConfig(t *testing.T) *config.Config { + t.Helper() + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + return cfg +} diff --git a/cmd/env/delete.go b/cmd/env/delete.go index 14bb9d348d..9e89114354 100644 --- a/cmd/env/delete.go +++ b/cmd/env/delete.go @@ -2,10 +2,14 @@ package env import ( "context" + "errors" "fmt" + "slices" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -28,7 +32,20 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { } func (cmd *DeleteCmd) Run(_ context.Context, name string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) + if err := secrets.ValidateName(name); err != nil { + return err + } + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) if err != nil { return err } @@ -39,9 +56,69 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { if meta.Sensitive() { return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) } - if err := store.Delete(contextName, name); err != nil { + if err := deleteEnvironmentValue(deleteEnvRequest{ + config: devsyConfig, + store: store, + context: contextName, + name: name, + save: config.SaveConfig, + }); err != nil { return err } log.Infof("env var %q deleted from context %q", name, contextName) return nil } + +type deleteEnvRequest struct { + config *config.Config + store secrets.Store + context string + name string + save func(*config.Config) error +} + +type removedEnvBinding struct { + attached bool + index int +} + +func deleteEnvironmentValue(request deleteEnvRequest) error { + devsyConfig := request.config + store := request.store + contextName := request.context + name := request.name + saveConfig := request.save + var binding removedEnvBinding + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + if idx := slices.Index(ctxConfig.EnvVars, name); idx >= 0 { + binding = removedEnvBinding{attached: true, index: idx} + ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) + if err := saveConfig(devsyConfig); err != nil { + return err + } + } + } + + deleteErr := store.Delete(contextName, name) + if deleteErr == nil || !binding.attached { + return deleteErr + } + + if _, err := store.Get(contextName, name); err != nil { + return deleteErr + } + + ctxConfig := devsyConfig.Contexts[contextName] + ctxConfig.EnvVars = append(ctxConfig.EnvVars, "") + copy(ctxConfig.EnvVars[binding.index+1:], ctxConfig.EnvVars[binding.index:]) + ctxConfig.EnvVars[binding.index] = name + if rollbackErr := saveConfig(devsyConfig); rollbackErr != nil { + rollbackMessage := "restore environment variable attachment after failed delete: %w" + return errors.Join( + deleteErr, + fmt.Errorf(rollbackMessage, rollbackErr), + ) + } + + return deleteErr +} diff --git a/cmd/env/delete_test.go b/cmd/env/delete_test.go new file mode 100644 index 0000000000..7109c928bf --- /dev/null +++ b/cmd/env/delete_test.go @@ -0,0 +1,127 @@ +package env + +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +type deleteTestStore struct { + value string + deleteErr error + getErr error +} + +const ( + deleteEnvName = "FOO" + deleteTestValue = "delete-value" +) + +func (s *deleteTestStore) Set(string, string, string, secrets.Kind) error { return nil } +func (s *deleteTestStore) Get(string, string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + return s.value, nil +} + +func (s *deleteTestStore) Meta(string, string) (secrets.SecretMeta, error) { + return secrets.SecretMeta{}, nil +} + +func (s *deleteTestStore) List(string) ([]secrets.SecretMeta, error) { return nil, nil } +func (s *deleteTestStore) Delete(string, string) error { return s.deleteErr } + +func TestDeleteEnvironmentValueRestoresAttachedBindingWhenDeleteFails(t *testing.T) { + cfg := deleteTestConfig([]string{"DELETE_FIRST", deleteEnvName, "DELETE_LAST"}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Equal(t, []string{"DELETE_FIRST", deleteEnvName, "DELETE_LAST"}, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueLeavesBindingRemovedWhenValueUnavailable(t *testing.T) { + cfg := deleteTestConfig([]string{deleteEnvName}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{deleteErr: deleteErr, getErr: secrets.ErrSecretNotFound} + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Empty(t, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueJoinsRollbackFailure(t *testing.T) { + cfg := deleteTestConfig([]string{deleteEnvName}) + deleteErr := errors.New("delete failed") + rollbackErr := errors.New("rollback failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + saves := 0 + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + saves++ + if saves == 2 { + return rollbackErr + } + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.ErrorIs(t, err, rollbackErr) + require.Equal(t, []string{deleteEnvName}, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueDoesNotSaveForUnattachedValue(t *testing.T) { + cfg := deleteTestConfig(nil) + saves := 0 + store := &deleteTestStore{value: deleteTestValue} + + require.NoError(t, deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + saves++ + return nil + }, + })) + require.Zero(t, saves) +} + +func deleteTestConfig(envVars []string) *config.Config { + return &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{ + config.DefaultContext: {EnvVars: envVars}, + }, + } +} diff --git a/cmd/env/env.go b/cmd/env/env.go index 7d6389bb96..4bd7b20a0a 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -19,6 +19,8 @@ use "devsy secret" for sensitive values.`, envCmd.AddCommand(NewListCmd(globalFlags)) envCmd.AddCommand(NewGetCmd(globalFlags)) envCmd.AddCommand(NewDeleteCmd(globalFlags)) + envCmd.AddCommand(NewAttachCmd(globalFlags)) + envCmd.AddCommand(NewDetachCmd(globalFlags)) return envCmd } diff --git a/cmd/env/list.go b/cmd/env/list.go index 5ff41d23ee..e41a613feb 100644 --- a/cmd/env/list.go +++ b/cmd/env/list.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "slices" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -30,12 +33,19 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { } type envEntry struct { - Name string `json:"name"` - Value string `json:"value"` + Name string `json:"name"` + Value string `json:"value"` + Context string `json:"context"` + Attached bool `json:"attached"` } func (cmd *ListCmd) Run(_ context.Context) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) if err != nil { return err } @@ -43,13 +53,25 @@ func (cmd *ListCmd) Run(_ context.Context) error { if err != nil { return err } + var attachedNames []string + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + attachedNames = ctxConfig.EnvVars + } entries := make([]envEntry, 0, len(metas)) for _, m := range metas { if m.Sensitive() { continue } - entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) + entries = append( + entries, + envEntry{ + Name: m.Name, + Value: m.Value, + Context: contextName, + Attached: slices.Contains(attachedNames, m.Name), + }, + ) } mode, err := output.ResolveMode(cmd.ResultFormat) @@ -76,7 +98,7 @@ func renderJSON(entries []envEntry) error { func renderPlain(entries []envEntry) { rows := make([][]string, 0, len(entries)) for _, e := range entries { - rows = append(rows, []string{e.Name, e.Value}) + rows = append(rows, []string{e.Name, e.Value, e.Context, fmt.Sprint(e.Attached)}) } - table.Print([]string{"Name", "Value"}, rows) + table.Print([]string{"Name", "Value", "Context", "Attached"}, rows) } diff --git a/cmd/env/set.go b/cmd/env/set.go index 05c2ffbee4..bfc99a5926 100644 --- a/cmd/env/set.go +++ b/cmd/env/set.go @@ -2,10 +2,13 @@ package env import ( "context" + "errors" "fmt" + "slices" "strings" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -50,10 +53,31 @@ func (cmd *SetCmd) Run(_ context.Context, arg string) error { return err } - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() if err != nil { return err } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if meta, metaErr := store.Meta(contextName, name); metaErr == nil { + ctxConfig := devsyConfig.Contexts[contextName] + if meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.Secrets, name) { + return fmt.Errorf( + "%q is attached as a secret; detach it before converting it to an environment variable", + name, + ) + } + } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { + return metaErr + } if err := store.Set(contextName, name, value, secrets.KindEnv); err != nil { return err } diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index 09e8127760..81a8f08146 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -75,7 +75,12 @@ func (cmd *AttachCmd) Run(ctx context.Context, name string) error { } canonical := ref.String() - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } @@ -127,7 +132,12 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error { return err } canonical := ref.String() - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index 3f11b88029..f60f37c668 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -2,12 +2,14 @@ package secrets import ( "context" + "errors" "fmt" "slices" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -33,7 +35,17 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { } func (cmd *DeleteCmd) Run(_ context.Context, name string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := devsysecrets.NewStoreForConfig(devsyConfig) if err != nil { return err } @@ -46,10 +58,13 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) } - if err := store.Delete(contextName, name); err != nil { - return err - } - if err := unbindFromContext(cmd.GlobalFlags, contextName, name); err != nil { + if err := deleteSecretValue(deleteSecretRequest{ + config: devsyConfig, + store: store, + context: contextName, + name: name, + save: config.SaveConfig, + }); err != nil { return err } @@ -57,22 +72,55 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { return nil } -// unbindFromContext removes a deleted secret from its context's attached list so -// a stale binding is not left pointing at a now-missing secret. -func unbindFromContext(globalFlags *flags.GlobalFlags, contextName, name string) error { - devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) - if err != nil { - return err +type deleteSecretRequest struct { + config *config.Config + store devsysecrets.Store + context string + name string + save func(*config.Config) error +} + +type removedSecretBinding struct { + attached bool + index int +} + +func deleteSecretValue(request deleteSecretRequest) error { + devsyConfig := request.config + store := request.store + contextName := request.context + name := request.name + saveConfig := request.save + var binding removedSecretBinding + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + if idx := slices.Index(ctxConfig.Secrets, name); idx >= 0 { + binding = removedSecretBinding{attached: true, index: idx} + ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) + if err := saveConfig(devsyConfig); err != nil { + return err + } + } } - ctxConfig := devsyConfig.Contexts[contextName] - if ctxConfig == nil { - return nil + + deleteErr := store.Delete(contextName, name) + if deleteErr == nil || !binding.attached { + return deleteErr + } + + if _, err := store.Get(contextName, name); err != nil { + return deleteErr } - idx := slices.Index(ctxConfig.Secrets, name) - if idx < 0 { - return nil + + ctxConfig := devsyConfig.Contexts[contextName] + ctxConfig.Secrets = append(ctxConfig.Secrets, "") + copy(ctxConfig.Secrets[binding.index+1:], ctxConfig.Secrets[binding.index:]) + ctxConfig.Secrets[binding.index] = name + if rollbackErr := saveConfig(devsyConfig); rollbackErr != nil { + return errors.Join( + deleteErr, + fmt.Errorf("restore secret attachment after failed delete: %w", rollbackErr), + ) } - ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) - return config.SaveConfig(devsyConfig) + return deleteErr } diff --git a/cmd/secrets/delete_test.go b/cmd/secrets/delete_test.go new file mode 100644 index 0000000000..9361d2caa7 --- /dev/null +++ b/cmd/secrets/delete_test.go @@ -0,0 +1,131 @@ +package secrets + +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +type deleteTestStore struct { + value string + deleteErr error + getErr error +} + +const ( + deleteSecretName = "DELETE_TOKEN" + deleteTestValue = "delete-value" +) + +func (s *deleteTestStore) Set(string, string, string, devsysecrets.Kind) error { return nil } +func (s *deleteTestStore) Get(string, string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + return s.value, nil +} + +func (s *deleteTestStore) Meta(string, string) (devsysecrets.SecretMeta, error) { + return devsysecrets.SecretMeta{}, nil +} + +func (s *deleteTestStore) List(string) ([]devsysecrets.SecretMeta, error) { return nil, nil } +func (s *deleteTestStore) Delete(string, string) error { return s.deleteErr } + +func TestDeleteSecretValueRestoresAttachedBindingWhenDeleteFails(t *testing.T) { + cfg := deleteTestConfig([]string{"DELETE_FIRST", deleteSecretName, "DELETE_LAST"}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Equal( + t, + []string{"DELETE_FIRST", deleteSecretName, "DELETE_LAST"}, + cfg.Current().Secrets, + ) +} + +func TestDeleteSecretValueLeavesBindingRemovedWhenValueUnavailable(t *testing.T) { + cfg := deleteTestConfig([]string{deleteSecretName}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{deleteErr: deleteErr, getErr: devsysecrets.ErrSecretNotFound} + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Empty(t, cfg.Current().Secrets) +} + +func TestDeleteSecretValueJoinsRollbackFailure(t *testing.T) { + cfg := deleteTestConfig([]string{deleteSecretName}) + deleteErr := errors.New("delete failed") + rollbackErr := errors.New("rollback failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + saves := 0 + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + saves++ + if saves == 2 { + return rollbackErr + } + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.ErrorIs(t, err, rollbackErr) + require.Equal(t, []string{deleteSecretName}, cfg.Current().Secrets) +} + +func TestDeleteSecretValueDoesNotSaveForUnattachedValue(t *testing.T) { + cfg := deleteTestConfig(nil) + saves := 0 + store := &deleteTestStore{value: deleteTestValue} + + require.NoError(t, deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + saves++ + return nil + }, + })) + require.Zero(t, saves) +} + +func deleteTestConfig(secretsList []string) *config.Config { + return &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{ + config.DefaultContext: {Secrets: secretsList}, + }, + } +} diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index c1f4d1bc0c..04c2c248b9 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -2,12 +2,15 @@ package secrets import ( "context" + "errors" "fmt" "io" "os" + "slices" "strings" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -65,10 +68,31 @@ func (cmd *SetCmd) Run(_ context.Context, name string) error { return err } - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() if err != nil { return err } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if meta, metaErr := store.Meta(contextName, name); metaErr == nil { + ctxConfig := devsyConfig.Contexts[contextName] + if !meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.EnvVars, name) { + return fmt.Errorf( + "%q is attached as an environment variable; detach it before converting it to a secret", + name, + ) + } + } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { + return metaErr + } if err := store.Set(contextName, name, value, secrets.KindSecret); err != nil { return err diff --git a/cmd/secrets/set_internal_test.go b/cmd/secrets/set_internal_test.go index 087f4452a9..8fd72515c0 100644 --- a/cmd/secrets/set_internal_test.go +++ b/cmd/secrets/set_internal_test.go @@ -1,9 +1,15 @@ package secrets import ( + "context" "os" "strings" "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" ) func withStdin(t *testing.T, input string, fn func()) { @@ -25,6 +31,36 @@ func withStdin(t *testing.T, input string, fn func()) { _ = r.Close() } +func TestSetRejectsConvertingAttachedEnvironmentToSecret(t *testing.T) { + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) + t.Setenv(config.EnvHome, t.TempDir()) + t.Setenv("DEVSY_SECRETS_BACKEND", "file") + t.Setenv("DEVSY_SECRETS_PASSPHRASE", "test-passphrase") + require.NoError(t, config.SaveConfig(&config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{config.DefaultContext: { + EnvVars: []string{"TOKEN"}, + }}, + })) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + store, err := devsysecrets.NewStoreForConfig(cfg) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "TOKEN", "plain", devsysecrets.KindEnv)) + + err = (&SetCmd{ + GlobalFlags: &flags.GlobalFlags{}, + Value: "secret", + valueSet: true, + }).Run(context.Background(), "TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "attached as an environment variable") + meta, err := store.Meta(config.DefaultContext, "TOKEN") + require.NoError(t, err) + require.Equal(t, devsysecrets.KindEnv, meta.Kind) +} + func TestResolveValue_Stdin(t *testing.T) { cmd := &SetCmd{Stdin: true} var got string diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index ada71fdeea..75172a464c 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -27,6 +27,13 @@ func testConfig(bound ...string) *config.Config { } } +func testEnvConfig(bound ...string) *config.Config { + return &config.Config{ + DefaultContext: "default", + Contexts: map[string]*config.ContextConfig{"default": {EnvVars: bound}}, + } +} + func localRef(name string) secretspkg.SecretRef { return secretspkg.SecretRef{ Type: secretspkg.LocalSourceName, @@ -129,6 +136,86 @@ func TestCollectSecretRequests_DuplicateMountTargetRejected(t *testing.T) { assert.Contains(t, err.Error(), "both mount to target") } +func TestCollectEnvVarRequests_ContextAttachment(t *testing.T) { + got, err := collectEnvVarRequests(nil, testEnvConfig("ZED", "ALPHA")) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("ALPHA"), target: "ALPHA"}, + {ref: localRef("ZED"), target: "ZED"}, + }, got) +} + +func TestCollectEnvVarRequests_ExplicitOverridesAttachment(t *testing.T) { + got, err := collectEnvVarRequests([]string{"LOG_LEVEL=APP_LOG"}, testEnvConfig("LOG_LEVEL")) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{{ref: localRef("LOG_LEVEL"), target: "APP_LOG"}}, got) +} + +func TestCollectEnvVarRequests_PreservesRepeatedExplicitSource(t *testing.T) { + got, err := collectEnvVarRequests( + []string{"LOG_LEVEL=FIRST", "LOG_LEVEL=SECOND"}, + testEnvConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("LOG_LEVEL"), target: "FIRST"}, + {ref: localRef("LOG_LEVEL"), target: "SECOND"}, + }, got) +} + +func TestCollectEnvVarRequests_ExplicitRepeatedSourceSuppressesImplicit(t *testing.T) { + got, err := collectEnvVarRequests( + []string{"LOG_LEVEL=FIRST", "LOG_LEVEL=SECOND"}, + testEnvConfig("LOG_LEVEL"), + ) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("LOG_LEVEL"), target: "FIRST"}, + {ref: localRef("LOG_LEVEL"), target: "SECOND"}, + }, got) +} + +func TestCollectEnvVarRequests_DuplicateTargetRejected(t *testing.T) { + requests, err := collectEnvVarRequests( + []string{"FOO=APP_MODE", "BAR=APP_MODE"}, + testEnvConfig(), + ) + if err == nil { + err = checkDuplicateEnvTargets(requests) + } + require.Error(t, err) + assert.Contains(t, err.Error(), `both target "APP_MODE"`) +} + +func TestCollectEnvVarRequests_InvalidAttachedReference(t *testing.T) { + _, err := collectEnvVarRequests(nil, testEnvConfig("sops:project/FOO")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must use the local Devsy store") +} + +func TestCollectEnvVarRequests_RejectsEqualsInTarget(t *testing.T) { + _, err := collectEnvVarRequests([]string{"FOO=APP=MODE"}, testEnvConfig()) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain =") +} + +func TestApplyEnvVars_ContextAttachedNonSensitive(t *testing.T) { + cmd := &UpCmd{} + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("local", "local", fixedSource{ + values: map[string]string{"LOG_LEVEL": "debug"}, sensitive: false, + })) + + err := cmd.applyEnvVars(context.Background(), testEnvConfig("LOG_LEVEL"), resolver) + require.NoError(t, err) + assert.Equal(t, []string{"LOG_LEVEL=debug"}, cmd.WorkspaceEnv) +} + +func TestHasStoredValues_AttachedEnvVars(t *testing.T) { + cmd := &UpCmd{} + assert.True(t, cmd.hasStoredValues(nil, testEnvConfig("LOG_LEVEL"))) +} + type fixedSource struct { values map[string]string sensitive bool @@ -146,7 +233,7 @@ func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { values: map[string]string{secretAPIKey: "plaintext"}, sensitive: true, })) - err := cmd.applyEnvVars(context.Background(), resolver) + err := cmd.applyEnvVars(context.Background(), testConfig(), resolver) require.Error(t, err) assert.Contains(t, err.Error(), "is a secret and cannot be passed with --env") assert.Empty(t, cmd.WorkspaceEnv, "no value must reach the ps-visible WorkspaceEnv") @@ -160,7 +247,7 @@ func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { values: map[string]string{"LOG_LEVEL": "debug"}, sensitive: false, })) - err := cmd.applyEnvVars(context.Background(), resolver) + err := cmd.applyEnvVars(context.Background(), testConfig(), resolver) require.NoError(t, err) assert.Equal(t, []string{"DEBUG_TARGET=debug"}, cmd.WorkspaceEnv) } diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 6da5f81c50..cda32f8896 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -301,7 +301,7 @@ func (cmd *UpCmd) resolveStoredSecrets( if err != nil { return err } - if !cmd.hasStoredValues(requests) { + if !cmd.hasStoredValues(requests, devsyConfig) { return nil } @@ -316,14 +316,19 @@ func (cmd *UpCmd) resolveStoredSecrets( if err := cmd.applyLifecycleSecrets(ctx, requests, resolver); err != nil { return err } - if err := cmd.applyEnvVars(ctx, resolver); err != nil { + if err := cmd.applyEnvVars(ctx, devsyConfig, resolver); err != nil { return err } return cmd.applyBuildSecrets(ctx, resolver) } -func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { - return len(requests) > 0 || len(cmd.EnvVars) > 0 || len(cmd.BuildSecretNames) > 0 +func (cmd *UpCmd) hasStoredValues(requests []secretRequest, devsyConfig *config.Config) bool { + attachedEnvCount := 0 + if current := devsyConfig.Current(); current != nil { + attachedEnvCount = len(current.EnvVars) + } + return len(requests) > 0 || len(cmd.EnvVars) > 0 || attachedEnvCount > 0 || + len(cmd.BuildSecretNames) > 0 } func (cmd *UpCmd) applyLifecycleSecrets( @@ -345,55 +350,138 @@ func (cmd *UpCmd) applyLifecycleSecrets( return nil } -// applyEnvVars resolves --env entries from the local store. External -// sensitive sources intentionally use --secret instead: WorkspaceEnv rides in -// the setup argv and is process-list visible. -func (cmd *UpCmd) applyEnvVars(ctx context.Context, resolver *secrets.Resolver) error { - for _, entry := range cmd.EnvVars { - envVar, err := resolveEnvVarEntry(ctx, resolver, entry) +type envVarRequest struct { + ref secrets.SecretRef + target string +} + +func collectEnvVarRequests(flags []string, devsyConfig *config.Config) ([]envVarRequest, error) { + explicit := make([]envVarRequest, 0, len(flags)) + explicitRefs := map[string]struct{}{} + for _, entry := range flags { + req, err := parseEnvVarRequest(entry) if err != nil { - return err + return nil, err + } + explicit = append(explicit, req) + explicitRefs[req.ref.String()] = struct{}{} + } + implicit := make([]envVarRequest, 0) + if current := devsyConfig.Current(); current != nil { + for _, name := range current.EnvVars { + ref, err := secrets.ParseRef(name) + if err != nil { + return nil, fmt.Errorf("invalid attached environment variable %q: %w", name, err) + } + if ref.Source != secrets.LocalSourceName { + return nil, fmt.Errorf( + "attached environment variable %q must use the local Devsy store", + name, + ) + } + if _, overridden := explicitRefs[ref.String()]; overridden { + continue + } + implicit = append(implicit, envVarRequest{ref: ref, target: ref.Name}) } - cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, envVar) } - return nil + sort.Slice( + implicit, + func(i, j int) bool { return implicit[i].ref.String() < implicit[j].ref.String() }, + ) + return append(implicit, explicit...), nil } -func resolveEnvVarEntry( - ctx context.Context, - resolver *secrets.Resolver, - entry string, -) (string, error) { - name, explicitTarget, hasTarget := strings.Cut(entry, "=") - if hasTarget && explicitTarget == "" { - return "", fmt.Errorf("invalid --env %q: target after %q= must not be empty", entry, name) +func parseEnvVarRequest(entry string) (envVarRequest, error) { + name, target, hasTarget := strings.Cut(entry, "=") + if hasTarget && target == "" { + return envVarRequest{}, fmt.Errorf( + "invalid --env %q: target after %q= must not be empty", + entry, + name, + ) + } + if hasTarget && strings.Contains(target, "=") { + return envVarRequest{}, fmt.Errorf( + "invalid --env %q: target %q must not contain =", + entry, + target, + ) } ref, err := secrets.ParseRef(name) if err != nil { - return "", err - } - target := ref.Name - if hasTarget { - target = explicitTarget + return envVarRequest{}, err } if ref.Source != secrets.LocalSourceName { - return "", fmt.Errorf( + return envVarRequest{}, fmt.Errorf( "--env only accepts Devsy-managed values; use --secret %s instead", name, ) } - resolved, err := resolver.Resolve(ctx, ref) + if !hasTarget { + target = ref.Name + } + return envVarRequest{ref: ref, target: target}, nil +} + +func checkDuplicateEnvTargets(requests []envVarRequest) error { + targets := map[string]secrets.SecretRef{} + for _, req := range requests { + if previous, ok := targets[req.target]; ok && previous.String() != req.ref.String() { + return fmt.Errorf( + "environment variables %q and %q both target %q", + previous.Name, + req.ref.Name, + req.target, + ) + } + targets[req.target] = req.ref + } + return nil +} + +// applyEnvVars resolves attached and explicit entries from the local store. External +// sensitive sources intentionally use --secret instead: WorkspaceEnv rides in +// the setup argv and is process-list visible. +func (cmd *UpCmd) applyEnvVars( + ctx context.Context, + devsyConfig *config.Config, + resolver *secrets.Resolver, +) error { + requests, err := collectEnvVarRequests(cmd.EnvVars, devsyConfig) + if err != nil { + return err + } + if err := checkDuplicateEnvTargets(requests); err != nil { + return err + } + for _, req := range requests { + envVar, err := resolveEnvVarRequest(ctx, resolver, req) + if err != nil { + return err + } + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, envVar) + } + return nil +} + +func resolveEnvVarRequest( + ctx context.Context, + resolver *secrets.Resolver, + req envVarRequest, +) (string, error) { + resolved, err := resolver.Resolve(ctx, req.ref) if err != nil { return "", err } if resolved.Sensitive { return "", fmt.Errorf( "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", - name, - name, + req.ref.Name, + req.ref.Name, ) } - return target + "=" + resolved.Value, nil + return req.target + "=" + resolved.Value, nil } func (cmd *UpCmd) applyBuildSecrets(ctx context.Context, resolver *secrets.Resolver) error { diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index dfeda0a99a..d1b49ef840 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -99,6 +99,52 @@ describe("provider job lifecycle over IPC", () => { vi.clearAllMocks() }) + it("forwards managed environment attachment intent to the CLI", async () => { + const { cli } = setup() + await invoke("env_attach", { name: "LOG_LEVEL", context: "staging" }) + await invoke("env_detach", { name: "LOG_LEVEL", context: "staging" }) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "attach", + "LOG_LEVEL", + ]) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "detach", + "LOG_LEVEL", + ]) + }) + + it("deletes managed environment values in the displayed context", async () => { + const { cli } = setup() + await invoke("env_delete", { name: "LOG_LEVEL", context: "staging" }) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "delete", + "LOG_LEVEL", + ]) + }) + + it("rejects managed environment deletion without a context", async () => { + const { cli } = setup() + const result = (await invoke("env_delete", { + name: "LOG_LEVEL", + context: "", + })) as { ok: boolean; message: string; cliError?: unknown } + expect(result).toEqual({ + ok: false, + message: "context is required", + cliError: undefined, + }) + expect(cli.runRaw).not.toHaveBeenCalled() + }) + it("clears the job when init succeeds", async () => { const { providerJobs } = setup(() => ({ lines: [statusLine("running_init"), statusLine("ready")], diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 9c92432fa9..d88e436639 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,6 +53,8 @@ interface SecretEntry { interface EnvEntry { name: string value: string + context: string + attached: boolean } function dockerArch(nodeArch: string): string { @@ -1134,10 +1136,40 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) - ipcMain.handle("env_delete", async (_event, args: { name: string }) => { - trackEvent("env_delete") + ipcMain.handle( + "env_delete", + async (_event, args: { name: string; context: string }) => { + trackEvent("env_delete") + try { + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "delete", args.name]) + return { ok: true } as const + } catch (err) { + const cliError = (err as { cliError?: CLIError }).cliError + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message, cliError } as const + } + }, + ) + + ipcMain.handle("env_attach", async (_event, args: { name: string; context: string }) => { + trackEvent("env_attach") + try { + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "attach", args.name]) + return { ok: true } as const + } catch (err) { + const cliError = (err as { cliError?: CLIError }).cliError + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message, cliError } as const + } + }) + + ipcMain.handle("env_detach", async (_event, args: { name: string; context: string }) => { + trackEvent("env_detach") try { - await cli.runRaw(["env", "delete", args.name]) + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "detach", args.name]) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index a8b5e9aa16..3fd4e0c64c 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -7,6 +7,9 @@ import { auditRecent, contextUse, devsyVersion, + envDelete, + envAttach, + envDetach, machineCreate, machineDelete, machineStatus, @@ -142,6 +145,35 @@ describe("IPC commands", () => { }) }) + describe("managed environment commands", () => { + it("envAttach sends the variable name and context", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envAttach("LOG_LEVEL", "staging") + expect(mockInvoke).toHaveBeenCalledWith("env_attach", { + name: "LOG_LEVEL", + context: "staging", + }) + }) + + it("envDetach sends the variable name and context", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envDetach("LOG_LEVEL", "staging") + expect(mockInvoke).toHaveBeenCalledWith("env_detach", { + name: "LOG_LEVEL", + context: "staging", + }) + }) + + it("envDelete sends the variable name and context", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envDelete("LOG_LEVEL", "staging") + expect(mockInvoke).toHaveBeenCalledWith("env_delete", { + name: "LOG_LEVEL", + context: "staging", + }) + }) + }) + describe("provider commands", () => { it("providerAdd passes name and source", async () => { await providerAdd("docker", "https://example.com/provider") diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 24cd60ff41..8b6aa0f522 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -337,8 +337,18 @@ export async function envSet(name: string, value: string): Promise { unwrapEnvelope(await invoke("env_set", { name, value })) } -export async function envDelete(name: string): Promise { - unwrapEnvelope(await invoke("env_delete", { name })) +export async function envDelete(name: string, context: string): Promise { + unwrapEnvelope( + await invoke("env_delete", { name, context }), + ) +} + +export async function envAttach(name: string, context: string): Promise { + unwrapEnvelope(await invoke("env_attach", { name, context })) +} + +export async function envDetach(name: string, context: string): Promise { + unwrapEnvelope(await invoke("env_detach", { name, context })) } // Audit commands diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 5f1341a750..3632667a9c 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -161,6 +161,8 @@ export interface Secret { export interface EnvVar { name: string value: string + context: string + attached: boolean } export interface CommandProgress { diff --git a/desktop/src/renderer/src/pages/EnvPage.svelte b/desktop/src/renderer/src/pages/EnvPage.svelte index 8a2fccfe18..186aba0610 100644 --- a/desktop/src/renderer/src/pages/EnvPage.svelte +++ b/desktop/src/renderer/src/pages/EnvPage.svelte @@ -3,13 +3,15 @@ import { Braces, Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/svelte" import { Button } from "$lib/components/ui/button/index.js" import { Input } from "$lib/components/ui/input/index.js" import { Label } from "$lib/components/ui/label/index.js" +import { Switch } from "$lib/components/ui/switch/index.js" import * as Dialog from "$lib/components/ui/dialog/index.js" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import CardSkeleton from "$lib/components/ui/skeleton/CardSkeleton.svelte" import { envVars, envLoading, refreshEnv } from "$lib/stores/env.js" -import { envSet, envDelete } from "$lib/ipc/commands.js" +import { envSet, envDelete, envAttach, envDetach } from "$lib/ipc/commands.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import type { EnvVar } from "$lib/types/index.js" const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ @@ -20,8 +22,11 @@ let saving = $state(false) let revealed = $state>({}) let confirmDeleteOpen = $state(false) -let pendingDelete = $state("") +type PendingEnvDelete = { name: string; context: string } +let pendingDelete = $state(null) let deleting = $state(false) +let updatingAttachment = $state>({}) +let attachmentErrors = $state>({}) let searchTerm = $state("") let filteredEnvVars = $derived.by(() => { @@ -51,7 +56,9 @@ async function handleCreate() { try { await envSet(name, newValue) } catch (err) { - toasts.error(`Failed to save environment variable: ${extractErrorMessage(err)}`) + toasts.error( + `Failed to save environment variable: ${extractErrorMessage(err)}`, + ) saving = false return } @@ -65,28 +72,48 @@ async function handleCreate() { await refreshEnv().catch(() => {}) } -function requestDelete(e: Event, name: string) { +function requestDelete(e: Event, envVar: EnvVar) { e.stopPropagation() - pendingDelete = name + pendingDelete = { name: envVar.name, context: envVar.context } confirmDeleteOpen = true } async function confirmDelete() { - const name = pendingDelete + const target = pendingDelete + if (!target) return deleting = true try { - await envDelete(name) + await envDelete(target.name, target.context) } catch (err) { - toasts.error(`Failed to delete environment variable: ${extractErrorMessage(err)}`) + toasts.error( + `Failed to delete environment variable: ${extractErrorMessage(err)}`, + ) deleting = false return } confirmDeleteOpen = false - toasts.success(`Environment variable "${name}" deleted`) + pendingDelete = null + toasts.success(`Environment variable "${target.name}" deleted`) deleting = false await refreshEnv().catch(() => {}) } +async function setAttached(envVar: EnvVar, attached: boolean) { + const key = `${envVar.context}\x00${envVar.name}` + if (updatingAttachment[key]) return + updatingAttachment = { ...updatingAttachment, [key]: true } + attachmentErrors = { ...attachmentErrors, [key]: "" } + try { + if (attached) await envAttach(envVar.name, envVar.context) + else await envDetach(envVar.name, envVar.context) + await refreshEnv() + } catch (err) { + attachmentErrors = { ...attachmentErrors, [key]: extractErrorMessage(err) } + } finally { + updatingAttachment = { ...updatingAttachment, [key]: false } + } +} + function toggleReveal(name: string) { revealed = { ...revealed, [name]: !revealed[name] } } @@ -110,7 +137,7 @@ function toggleReveal(name: string) { {nameExists ? "Update Environment Variable" : "Add Environment Variable"} - Stored in plaintext in your Devsy config and injected into workspaces. + Stored in plaintext in your current Devsy context. Enable "Inject into workspaces" to add it automatically when workspaces start.
{ e.preventDefault(); handleCreate() }} class="space-y-4"> @@ -183,7 +210,7 @@ function toggleReveal(name: string) { {:else}
- {#each filteredEnvVars as envVar (envVar.name)} + {#each filteredEnvVars as envVar (`${envVar.context}\x00${envVar.name}`)}
@@ -203,7 +230,7 @@ function toggleReveal(name: string) { {/if} -
@@ -211,6 +238,23 @@ function toggleReveal(name: string) {

{revealed[envVar.name] ? envVar.value : "••••••••"}

+
+
+

Inject into workspaces

+

Context: {envVar.context}. Applies when a workspace starts or is recreated.

+
+ setAttached(envVar, checked)} + /> +
+ {#if attachmentErrors[`${envVar.context}\x00${envVar.name}`]} +

+ Failed to update attachment: {attachmentErrors[`${envVar.context}\x00${envVar.name}`]} +

+ {/if}
{/each}
@@ -221,7 +265,7 @@ function toggleReveal(name: string) { ({ + envAttach: vi.fn().mockResolvedValue(undefined), + envDetach: vi.fn().mockResolvedValue(undefined), + envDelete: vi.fn().mockResolvedValue(undefined), + refreshEnv: vi.fn().mockResolvedValue(undefined), +})) +vi.mock("$lib/ipc/commands.js", () => ({ + envAttach: mocks.envAttach, + envDelete: mocks.envDelete, + envDetach: mocks.envDetach, + envSet: vi.fn(), +})) +vi.mock("$lib/stores/env.js", async () => { + const { writable } = await import("svelte/store") + return { + envLoading: writable(false), + envVars: writable([ + { name: "ATTACHED", value: "one", context: "default", attached: true }, + { name: "DETACHED", value: "two", context: "default", attached: false }, + { name: "STAGING_ONLY", value: "three", context: "staging", attached: false }, + ]), + refreshEnv: mocks.refreshEnv, + } +}) + +import EnvPage from "./EnvPage.svelte" + +describe("EnvPage managed environment attachments", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + cleanup() + }) + + it("renders attachment state and sends attach/detach intent", async () => { + render(EnvPage) + + expect( + screen + .getByRole("switch", { name: "Inject ATTACHED into workspaces" }) + .getAttribute("aria-checked"), + ).toBe("true") + const detached = screen.getByRole("switch", { + name: "Inject DETACHED into workspaces", + }) + expect(detached.getAttribute("aria-checked")).toBe("false") + + await fireEvent.click(detached) + await waitFor(() => + expect(mocks.envAttach).toHaveBeenCalledWith("DETACHED", "default"), + ) + expect(mocks.refreshEnv).toHaveBeenCalled() + + await fireEvent.click( + screen.getByRole("switch", { name: "Inject ATTACHED into workspaces" }), + ) + await waitFor(() => + expect(mocks.envDetach).toHaveBeenCalledWith("ATTACHED", "default"), + ) + }) + + it("uses the context displayed on a stale row", async () => { + render(EnvPage) + + await fireEvent.click( + screen.getByRole("switch", { name: "Inject STAGING_ONLY into workspaces" }), + ) + await waitFor(() => + expect(mocks.envAttach).toHaveBeenCalledWith("STAGING_ONLY", "staging"), + ) + }) + + it("deletes using the context displayed on a stale row", async () => { + render(EnvPage) + + await fireEvent.click( + screen.getByRole("button", { + name: "Delete environment variable STAGING_ONLY", + }), + ) + await fireEvent.click(screen.getByRole("button", { name: /^Delete$/ })) + + await waitFor(() => + expect(mocks.envDelete).toHaveBeenCalledWith("STAGING_ONLY", "staging"), + ) + }) +}) diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index 89f85f715c..b2ec878c5f 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -21,6 +21,7 @@ import ( const ( secretCmd = "secret" + envCmd = "env" cmdSSH = "ssh" flagCommand = "--command" sshProbeTimeout = 20 * time.Second @@ -54,7 +55,7 @@ func (dtc *dockerTestContext) storeSecret(ctx context.Context, name, value strin } func (dtc *dockerTestContext) storeEnv(ctx context.Context, name, value string) { - _, err := dtc.f.ExecCommandOutput(ctx, []string{"env", "set", name, "--value", value}) + _, err := dtc.f.ExecCommandOutput(ctx, []string{envCmd, "set", name, "--value", value}) framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _, _ = dtc.f.ExecCommandOutput(ctx, []string{"env", "delete", name}) diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 346b71a5f9..aee7fedb06 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -781,6 +781,53 @@ var _ = ginkgo.Describe( ginkgo.SpecTimeout(framework.TimeoutShort()), ) + ginkgo.It( + "context-attached managed env var injects without --env and is removed after detach", + func(ctx context.Context) { + useFileSecretsBackend() + contextName := fmt.Sprintf("managed-env-%d", time.Now().UnixNano()) + framework.ExpectNoError(dtc.f.DevsyContextCreate(ctx, contextName)) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = dtc.f.DevsyContextUse(cleanupCtx, "default") + _ = dtc.f.DevsyContextDelete(cleanupCtx, contextName) + }) + framework.ExpectNoError(dtc.f.DevsyContextUse(ctx, contextName)) + framework.ExpectNoError( + dtc.f.DevsyProviderAdd( + ctx, + "docker", + "-o", + "DOCKER_PATH=docker", + ), + ) + framework.ExpectNoError(dtc.f.DevsyProviderUse(ctx, "docker")) + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-env-attached", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + dtc.storeEnv(ctx, "ATTACHED_ENV", "expected-value") + _, err = dtc.f.ExecCommandOutput(ctx, []string{envCmd, "attach", "ATTACHED_ENV"}) + framework.ExpectNoError(err) + + // Intentionally no --env argument: the context binding is the source. + framework.ExpectNoError(dtc.f.DevsyUp(ctx, tempDir)) + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/attached-env-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("expected-value")) + + _, err = dtc.f.ExecCommandOutput(ctx, []string{envCmd, "detach", "ATTACHED_ENV"}) + framework.ExpectNoError(err) + framework.ExpectNoError(dtc.f.DevsyUpRecreate(ctx, tempDir)) + out, err = dtc.execSSH(ctx, tempDir, "cat /tmp/attached-env-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.BeEmpty()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + ginkgo.It("multi devcontainer selection", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-multi-devcontainer", diff --git a/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json new file mode 100644 index 0000000000..3e393d847b --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "printf '%s' \"${ATTACHED_ENV-}\" > /tmp/attached-env-check.out" +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d5dc7f0daf..1798f52681 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/types" + "github.com/gofrs/flock" "sigs.k8s.io/yaml" ) @@ -50,8 +51,16 @@ type ContextConfig struct { // Secrets are names of stored secrets bound to this context, injected on `up`. Names only, no values. Secrets []string `json:"secrets,omitempty"` + // EnvVars are names of stored non-sensitive environment variables bound to + // this context, injected on `up`. Names only, no values. + EnvVars []string `json:"envVars,omitempty"` + // OriginalProvider is the original default provider OriginalProvider string `json:"-"` + + // OriginalProviderSet records that a transient provider override was applied, + // including when the original provider was empty. + OriginalProviderSet bool `json:"-"` } type ContextOption struct { @@ -225,6 +234,7 @@ func CloneConfig(config *Config) *Config { ctx.IDEs = map[string]*IDEConfig{} } ctx.OriginalProvider = config.Contexts[ctxName].OriginalProvider + ctx.OriginalProviderSet = config.Contexts[ctxName].OriginalProviderSet } ret.Origin = config.Origin ret.OriginalContext = config.OriginalContext @@ -300,6 +310,7 @@ func normalizeConfig(config *Config, contextOverride, providerOverride string) { ensureContextMaps(ctx) if providerOverride != "" { ctx.OriginalProvider = ctx.DefaultProvider + ctx.OriginalProviderSet = true ctx.DefaultProvider = providerOverride } } @@ -317,18 +328,22 @@ func ensureContextMaps(ctx *ContextConfig) { } func SaveConfig(config *Config) error { - configOrigin, err := GetConfigPath() + configOrigin, err := getConfigMutationPath() if err != nil { return err } config = CloneConfig(config) + selectedContext := config.DefaultContext + if selected := config.Contexts[selectedContext]; selected != nil && + selected.OriginalProviderSet { + selected.DefaultProvider = selected.OriginalProvider + selected.OriginalProvider = "" + selected.OriginalProviderSet = false + } if config.OriginalContext != "" { config.DefaultContext = config.OriginalContext } - if config.Contexts[config.DefaultContext].OriginalProvider != "" { - config.Contexts[config.DefaultContext].DefaultProvider = config.Contexts[config.DefaultContext].OriginalProvider - } out, err := yaml.Marshal(config) if err != nil { @@ -340,11 +355,101 @@ func SaveConfig(config *Config) error { return err } - err = os.WriteFile(configOrigin, out, 0o600) + return writeConfigAtomic(configOrigin, out) +} + +// LockConfig serializes read/modify/write mutations to config.yaml across +// processes. Callers must acquire it before loading the config and hold it +// until every related persistent operation is complete. +func LockConfig() (func(), error) { + configPath, err := getConfigMutationPath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + return nil, err + } + lock := flock.New(configPath + ".lock") + if err := lock.Lock(); err != nil { + return nil, fmt.Errorf("lock config %q: %w", configPath+".lock", err) + } + return func() { _ = lock.Unlock() }, nil +} + +// getConfigMutationPath resolves the configured config file to the path that +// will actually be replaced by an atomic save. A dangling configured symlink +// must fail rather than being replaced by a regular file. +func getConfigMutationPath() (string, error) { + configPath, err := GetConfigPath() + if err != nil { + return "", err + } + + info, err := os.Lstat(configPath) + switch { + case err == nil && info.Mode()&os.ModeSymlink != 0: + resolved, resolveErr := filepath.EvalSymlinks(configPath) + if resolveErr != nil { + return "", fmt.Errorf("resolve config symlink %q: %w", configPath, resolveErr) + } + return resolved, nil + case err == nil: + return configPath, nil + case os.IsNotExist(err): + return configPath, nil + default: + return "", err + } +} + +func writeConfigAtomic(configOrigin string, data []byte) error { + dir := filepath.Dir(configOrigin) + tmpName, err := createTempConfig(dir, filepath.Base(configOrigin), data) if err != nil { return err } + return replaceConfig(tmpName, configOrigin, dir) +} +func createTempConfig(dir, base string, data []byte) (string, error) { + tmp, err := os.CreateTemp(dir, base+".tmp-*") + if err != nil { + return "", err + } + tmpName := tmp.Name() + success := false + defer func() { + _ = tmp.Close() + if !success { + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return "", err + } + if _, err := tmp.Write(data); err != nil { + return "", err + } + if err := tmp.Sync(); err != nil { + return "", err + } + if err := tmp.Close(); err != nil { + return "", err + } + success = true + return tmpName, nil +} + +func replaceConfig(tmpName, configOrigin, dir string) error { + defer func() { _ = os.Remove(tmpName) }() + if err := os.Rename(tmpName, configOrigin); err != nil { + return err + } + // #nosec G304 -- dir is derived from the managed config path. + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7e1daed567..bce77abfff 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,6 +1,14 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const testEnvName = "LOG_LEVEL" func TestLoadConfig_StampsCurrentSchemaVersion(t *testing.T) { ResetPathManager() @@ -65,3 +73,169 @@ func TestLoadConfig_StampsMissingSchemaVersionOnExistingConfig(t *testing.T) { ) } } + +func TestContextConfigEnvVarsRoundTrip(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{ + DefaultContext: {EnvVars: []string{testEnvName}}, + }, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if len(got.Current().EnvVars) != 1 || got.Current().EnvVars[0] != "LOG_LEVEL" { + t.Fatalf("EnvVars = %#v, want [LOG_LEVEL]", got.Current().EnvVars) + } +} + +func TestSaveConfigRestoresTemporaryContextAndProviderOverrides(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{ + DefaultContext: {DefaultProvider: "docker"}, + "staging": {DefaultProvider: "kubernetes"}, + }, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + + loaded, err := LoadConfig("staging", "ssh") + if err != nil { + t.Fatal(err) + } + loaded.Current().EnvVars = []string{testEnvName} + if err := SaveConfig(loaded); err != nil { + t.Fatal(err) + } + + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if got.DefaultContext != DefaultContext { + t.Fatalf("DefaultContext = %q, want %q", got.DefaultContext, DefaultContext) + } + if got.Contexts["staging"].DefaultProvider != "kubernetes" { + t.Fatalf("staging provider = %q, want kubernetes", got.Contexts["staging"].DefaultProvider) + } + if got.Contexts["staging"].EnvVars[0] != testEnvName { + t.Fatalf("staging EnvVars = %#v", got.Contexts["staging"].EnvVars) + } +} + +func TestSaveConfigRestoresEmptyTemporaryProviderOverride(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {}}, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + + loaded, err := LoadConfig("", "ssh") + if err != nil { + t.Fatal(err) + } + if err := SaveConfig(loaded); err != nil { + t.Fatal(err) + } + + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if got.Current().DefaultProvider != "" { + t.Fatalf("provider = %q, want empty", got.Current().DefaultProvider) + } +} + +func TestSaveConfigPreservesAbsoluteConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + targetDir := filepath.Join(root, "target") + linkDir := filepath.Join(root, "link") + require.NoError(t, os.MkdirAll(targetDir, 0o700)) + require.NoError(t, os.MkdirAll(linkDir, 0o700)) + targetPath := filepath.Join(targetDir, ConfigFile) + linkPath := filepath.Join(linkDir, ConfigFile) + require.NoError(t, os.WriteFile(targetPath, []byte("{}\n"), 0o600)) + require.NoError(t, os.Symlink(targetPath, linkPath)) + t.Setenv(EnvConfig, linkPath) + + require.NoError(t, SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {EnvVars: []string{testEnvName}}}, + })) + + info, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + got, err := LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{testEnvName}, got.Current().EnvVars) +} + +func TestSaveConfigPreservesRelativeConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + configDir := filepath.Join(root, "config") + sharedDir := filepath.Join(root, "shared") + require.NoError(t, os.MkdirAll(configDir, 0o700)) + require.NoError(t, os.MkdirAll(sharedDir, 0o700)) + targetPath := filepath.Join(sharedDir, ConfigFile) + linkPath := filepath.Join(configDir, ConfigFile) + require.NoError(t, os.WriteFile(targetPath, []byte("{}\n"), 0o600)) + const linkTarget = "../shared/config.yaml" + require.NoError(t, os.Symlink(linkTarget, linkPath)) + t.Setenv(EnvConfig, linkPath) + + require.NoError(t, SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {EnvVars: []string{testEnvName}}}, + })) + + info, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + gotTarget, err := os.Readlink(linkPath) + require.NoError(t, err) + require.Equal(t, linkTarget, gotTarget) + got, err := LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{testEnvName}, got.Current().EnvVars) +} + +func TestSaveConfigDoesNotReplaceDanglingConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + linkPath := filepath.Join(root, ConfigFile) + require.NoError(t, os.Symlink("missing.yaml", linkPath)) + t.Setenv(EnvConfig, linkPath) + + err := SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {}}, + }) + require.Error(t, err) + info, statErr := os.Lstat(linkPath) + require.NoError(t, statErr) + require.NotZero(t, info.Mode()&os.ModeSymlink) +} diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 93b08a3c2a..ad6fdd21fb 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -350,3 +350,31 @@ devsy workspace up ... --env LOG_LEVEL --env REGION=AWS_REGION Use `devsy secret` or an external secret source for sensitive values. `--env` is deliberately restricted to non-sensitive Devsy-managed values because that path is not the protected secret-delivery channel. + +Attach a stored variable to the active context to inject it automatically when +workspaces start or are recreated: + +```shell +devsy env attach LOG_LEVEL +devsy env list +devsy env detach LOG_LEVEL +``` + +Attachments are context-scoped and contain only the variable name. The Desktop +Environment Variables page shows and changes the same attachment state. Managed +environment variables are non-sensitive plaintext values; use `devsy secret` +for secrets. + +An explicit `devsy workspace up --env LOG_LEVEL` still works for one invocation. +An explicit target, such as `--env LOG_LEVEL=APP_LOG_LEVEL`, overrides the +automatic attachment for that reference and injects the stored value as +`APP_LOG_LEVEL`. Attaching or detaching does not mutate an already-running +workspace; apply changes through the normal workspace start or recreate +lifecycle. + +An attached environment variable must be detached before converting the same +stored name into a secret. Likewise, a locally attached secret must be +detached before converting it into an environment variable. This keeps the +non-sensitive environment path separate from protected secret delivery. If a +stored value is deleted, Devsy removes its context attachment as part of the +same operation.