Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
61031b8
feat(env): add context-scoped environment bindings
skevetter Sep 24, 2026
5119873
feat(workspace): inject attached managed environment variables
skevetter Sep 24, 2026
c96af03
feat(desktop): manage workspace environment attachments
skevetter Sep 24, 2026
e94e101
test(env): cover automatic managed environment injection
skevetter Sep 24, 2026
32c3c4f
fix(env): guard attachment metadata lookup
skevetter Sep 24, 2026
744317f
fix(workspace): reject ambiguous env targets
skevetter Sep 24, 2026
788347d
fix(e2e): configure docker in managed env context
skevetter Sep 24, 2026
27a8657
fix(config): serialize and atomically persist mutations
skevetter Sep 24, 2026
55224b0
fix(env): preserve attachment invariants
skevetter Sep 24, 2026
eac3d6e
fix(workspace): preserve repeated explicit env mappings
skevetter Sep 24, 2026
c837119
fix(desktop): scope env attachments to context
skevetter Sep 24, 2026
a878454
test(desktop): isolate environment attachment renders
skevetter Sep 24, 2026
0f409f3
style: apply Go formatter output
skevetter Sep 24, 2026
2ed87be
fix(config): satisfy atomic writer lint
skevetter Sep 24, 2026
35b0561
docs: clarify managed environment invariants
skevetter Sep 24, 2026
cfe87f3
fix(config): restore empty temporary providers
skevetter Sep 24, 2026
2ed87c7
fix(config): preserve symlink targets during atomic save
skevetter Sep 24, 2026
8448bc3
fix(env): compensate failed value deletion
skevetter Sep 24, 2026
f68b225
fix(secrets): compensate failed secret deletion
skevetter Sep 24, 2026
67ca479
fix(desktop): scope env deletion to displayed context
skevetter Sep 24, 2026
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
120 changes: 120 additions & 0 deletions cmd/env/bind.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if err := config.SaveConfig(devsyConfig); err != nil {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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
}
122 changes: 122 additions & 0 deletions cmd/env/bind_test.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 79 additions & 2 deletions cmd/env/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}
Expand All @@ -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
}
Loading
Loading