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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/agent/container/deferred_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type DeferredHooksCmd struct {
Prebuild bool
DotfilesRepo string
DotfilesScript string
SecretsEnv []string
}

// NewDeferredHooksCmd creates a new command.
Expand All @@ -44,6 +45,8 @@ func NewDeferredHooksCmd(flags *flags.GlobalFlags) *cobra.Command {
StringVar(&cmd.DotfilesRepo, "dotfiles-repo", "", "Dotfiles repository URL")
deferredCmd.Flags().
StringVar(&cmd.DotfilesScript, "dotfiles-script", "", "Dotfiles install script path")
deferredCmd.Flags().
StringSliceVar(&cmd.SecretsEnv, "secrets-env", []string{}, "Secrets to inject into lifecycle commands (KEY=VALUE)")
_ = deferredCmd.MarkFlagRequired("setup-info")
return deferredCmd
}
Expand All @@ -65,7 +68,7 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error {
Repository: cmd.DotfilesRepo,
InstallScript: cmd.DotfilesScript,
RemoteUser: config.GetRemoteUser(setupInfo),
})
}, cmd.SecretsEnv)
if err != nil {
log.Errorf("deferred hooks setup failed: %v", err)
return nil
Expand Down
7 changes: 5 additions & 2 deletions cmd/agent/container/post_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import (
// PostAttachCmd runs postAttachCommand hooks as a detached background process.
type PostAttachCmd struct {
*flags.GlobalFlags
SetupInfo string
SetupInfo string
SecretsEnv []string
}

// NewPostAttachCmd creates a new command.
Expand All @@ -34,6 +35,8 @@ func NewPostAttachCmd(flags *flags.GlobalFlags) *cobra.Command {
},
}
postAttachCmd.Flags().StringVar(&cmd.SetupInfo, "setup-info", "", "The container setup info")
postAttachCmd.Flags().
StringSliceVar(&cmd.SecretsEnv, "secrets-env", []string{}, "Secrets to inject into lifecycle commands (KEY=VALUE)")
_ = postAttachCmd.MarkFlagRequired("setup-info")
return postAttachCmd
}
Expand All @@ -51,7 +54,7 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error {
}

log.Debugf("running postAttachCommand hooks")
if err := setup.RunPostAttachHooks(ctx, setupInfo); err != nil {
if err := setup.RunPostAttachHooks(ctx, setupInfo, cmd.SecretsEnv); err != nil {
log.Errorf("postAttachCommand failed: %v", err)
}

Expand Down
42 changes: 30 additions & 12 deletions cmd/agent/container/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ func (cmd *SetupContainerCmd) finalizeSetup(sctx *setupContext) error {
cfg := &setup.ContainerSetupConfig{
SetupInfo: sctx.setupInfo,
ExtraWorkspaceEnv: sctx.workspaceInfo.CLIOptions.WorkspaceEnv,
SecretsEnv: sctx.workspaceInfo.CLIOptions.SecretsEnv,
ChownProjects: cmd.ChownWorkspace,
PlatformOptions: &sctx.workspaceInfo.CLIOptions.Platform,
TunnelClient: sctx.tunnelClient,
Expand Down Expand Up @@ -238,6 +239,7 @@ func (cmd *SetupContainerCmd) setupPostAttach(
if !deferred.Empty() {
err = cmd.startDeferredHooks(
resolvedSetupInfo, cmd.DotfilesRepo, cmd.DotfilesScript,
sctx.workspaceInfo.CLIOptions.SecretsEnv,
)
if err != nil {
log.Errorf("failed to start deferred lifecycle hooks: %v", err)
Expand All @@ -262,16 +264,22 @@ func compressSetupInfo(setupInfo *config.Result) (string, error) {
}

func (cmd *SetupContainerCmd) startDeferredHooks(
setupInfo, dotfilesRepo, dotfilesScript string,
setupInfo, dotfilesRepo, dotfilesScript string, secretsEnv []string,
) error {
return command.StartBackgroundOnce("devsy.deferred-hooks", func() (*exec.Cmd, error) {
log.Debugf("starting deferred lifecycle hooks as background process")
return buildDeferredHooksCmd(setupInfo, cmd.Prebuild, dotfilesRepo, dotfilesScript)
return buildDeferredHooksCmd(
setupInfo,
cmd.Prebuild,
dotfilesRepo,
dotfilesScript,
secretsEnv,
)
})
}

func buildDeferredHooksCmd(
setupInfo string, prebuild bool, dotfilesRepo, dotfilesScript string,
setupInfo string, prebuild bool, dotfilesRepo, dotfilesScript string, secretsEnv []string,
) (*exec.Cmd, error) {
binaryPath, err := os.Executable()
if err != nil {
Expand All @@ -291,6 +299,9 @@ func buildDeferredHooksCmd(
if dotfilesScript != "" {
args = append(args, "--dotfiles-script", dotfilesScript)
}
if len(secretsEnv) > 0 {
args = append(args, "--secrets-env", strings.Join(secretsEnv, ","))
}

return &exec.Cmd{
Path: binaryPath,
Expand Down Expand Up @@ -472,15 +483,22 @@ func (cmd *SetupContainerCmd) startPostAttachHooks(sctx *setupContext) error {
return nil, err
}

//nolint:gosec // binaryPath is from os.Executable(), not user input
return exec.Command(
binaryPath,
"agent",
"container",
"post-attach",
"--setup-info",
cmd.SetupInfo,
), nil
args := []string{
"agent", "container", "post-attach",
"--setup-info", cmd.SetupInfo,
}
if len(sctx.workspaceInfo.CLIOptions.SecretsEnv) > 0 {
args = append(
args,
"--secrets-env",
strings.Join(sctx.workspaceInfo.CLIOptions.SecretsEnv, ","),
)
}

return &exec.Cmd{
Path: binaryPath,
Args: append([]string{binaryPath}, args...),
}, nil
})
}

Expand Down
15 changes: 15 additions & 0 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/devsy-org/devsy/pkg/log"
options2 "github.com/devsy-org/devsy/pkg/options"
provider2 "github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/secrets"
devssh "github.com/devsy-org/devsy/pkg/ssh"
"github.com/devsy-org/devsy/pkg/telemetry"
"github.com/devsy-org/devsy/pkg/util"
Expand All @@ -47,6 +48,7 @@ type UpCmd struct {
Reconfigure bool

SSHConfigPath string
SecretsFile string

DotfilesSource string
DotfilesScript string
Expand Down Expand Up @@ -274,6 +276,9 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) {
StringSliceVar(&cmd.WorkspaceEnvFile, "workspace-env-file", []string{},
"The path to files containing a list of extra env variables to put into the workspace, "+
"e.g. MY_ENV_VAR=MY_VALUE")
upCmd.Flags().
StringVar(&cmd.SecretsFile, "secrets-file", "",
"Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands")
upCmd.Flags().
StringArrayVar(&cmd.InitEnv, "init-env", []string{},
"Extra env variables to inject during the initialization of the workspace, e.g. MY_ENV_VAR=MY_VALUE")
Expand Down Expand Up @@ -802,6 +807,16 @@ func (cmd *UpCmd) prepareClient(
return nil, err
}

if cmd.SecretsFile != "" {
parsed, err := secrets.ParseSecretsFile(cmd.SecretsFile)
if err != nil {
return nil, err
}
for k, v := range parsed {
cmd.SecretsEnv = append(cmd.SecretsEnv, k+"="+v)
}
}

cmd.WorkspaceEnv = options2.InheritFromEnvironment(
cmd.WorkspaceEnv,
inheritedEnvironmentVariables,
Expand Down
32 changes: 32 additions & 0 deletions e2e/tests/up/provider_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,38 @@ var _ = ginkgo.Describe(
gomega.Expect(hasCustomMount).To(gomega.BeTrue())
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("secrets-file injects env into lifecycle commands", func(ctx context.Context) {
tempDir, err := setupWorkspace(
"tests/up/testdata/docker-secrets-file",
dtc.initialDir,
dtc.f,
)
framework.ExpectNoError(err)

secretsDir, err := framework.CreateTempDir()
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) })

secretsFile := filepath.Join(secretsDir, "secrets.env")
err = os.WriteFile(
secretsFile,
[]byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"),
0o600,
)
framework.ExpectNoError(err)

err = dtc.f.DevsyUp(ctx, tempDir, "--secrets-file", secretsFile)
framework.ExpectNoError(err)

out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/secret-check.out")
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("test-value-12345"))

out, err = dtc.execSSH(ctx, tempDir, "cat /tmp/another-secret-check.out")
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("second-secret-42"))
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("multi devcontainer selection", func(ctx context.Context) {
tempDir, err := setupWorkspace(
"tests/up/testdata/docker-multi-devcontainer",
Expand Down
4 changes: 4 additions & 0 deletions e2e/tests/up/testdata/docker-secrets-file/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"image": "ghcr.io/devsy-org/test-images/go:1",
"postCreateCommand": "echo -n $MY_SECRET > /tmp/secret-check.out && echo -n $ANOTHER_SECRET > /tmp/another-secret-check.out"
}
19 changes: 18 additions & 1 deletion pkg/devcontainer/setup/lifecyclehooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ type lifecycleEnv struct {
remoteEnv map[string]string
}

// mergeSecretsEnv merges KEY=VALUE pairs from the --secrets-file flag into the
// lifecycle env map. Existing keys are NOT overridden (config takes precedence).
func mergeSecretsEnv(env map[string]string, secretsEnv []string) {
for _, entry := range secretsEnv {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
if _, exists := env[key]; !exists {
env[key] = value
}
}
}

func resolveLifecycleEnv(
ctx context.Context,
setupInfo *config.Result,
Expand Down Expand Up @@ -222,8 +236,10 @@ func RunPreAttachHooks(
setupInfo *config.Result,
prebuild bool,
dotfiles DotfilesConfig,
secretsEnv []string,
) (DeferredHooks, error) {
env := resolveLifecycleEnv(ctx, setupInfo)
mergeSecretsEnv(env.remoteEnv, secretsEnv)
all := preAttachPhaseParams(setupInfo, env, prebuild)

// Insert the dotfiles phase between postCreate and postStart.
Expand Down Expand Up @@ -372,8 +388,9 @@ func (d DeferredHooks) Run() error {

// RunPostAttachHooks runs postAttachCommand only.
// These run after the IDE has been opened and can be long-running.
func RunPostAttachHooks(ctx context.Context, setupInfo *config.Result) error {
func RunPostAttachHooks(ctx context.Context, setupInfo *config.Result, secretsEnv []string) error {
env := resolveLifecycleEnv(ctx, setupInfo)
mergeSecretsEnv(env.remoteEnv, secretsEnv)

return runHook(hookRunParams{
commands: setupInfo.MergedConfig.PostAttachCommands,
Expand Down
41 changes: 38 additions & 3 deletions pkg/devcontainer/setup/lifecyclehooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ func (s *LifecycleHookTestSuite) TestLifecycleHooksNoOpWithEmptyConfig() {
}

// Both functions should return nil with empty config (no commands to run)
deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{})
deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil)
assert.NoError(s.T(), err)
assert.True(s.T(), deferred.Empty())

err = RunPostAttachHooks(ctx, result)
err = RunPostAttachHooks(ctx, result, nil)
assert.NoError(s.T(), err)
}

Expand Down Expand Up @@ -252,7 +252,7 @@ func (s *LifecycleHookTestSuite) TestPrebuildIgnoresWaitFor() {
}

// In prebuild mode, no deferred hooks are returned regardless of waitFor.
deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{})
deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil)
assert.NoError(s.T(), err)
assert.True(s.T(), deferred.Empty())
}
Expand Down Expand Up @@ -643,6 +643,41 @@ func (s *LifecycleHookTestSuite) TestWaitForEmptyPhaseLogsWarning() {
`waitFor phase "updateContentCommand" has no commands configured`)
}

func (s *LifecycleHookTestSuite) TestMergeSecretsEnv() {
env := map[string]string{"EXISTING": "keep"}

mergeSecretsEnv(env, []string{"SECRET_KEY=secret_val", "OTHER=data"})

assert.Equal(s.T(), "keep", env["EXISTING"])
assert.Equal(s.T(), "secret_val", env["SECRET_KEY"])
assert.Equal(s.T(), "data", env["OTHER"])
}

func (s *LifecycleHookTestSuite) TestMergeSecretsEnvDoesNotOverride() {
env := map[string]string{"MY_VAR": "original"}

mergeSecretsEnv(env, []string{"MY_VAR=overridden"})

assert.Equal(s.T(), "original", env["MY_VAR"])
}

func (s *LifecycleHookTestSuite) TestMergeSecretsEnvNil() {
env := map[string]string{"KEY": "val"}

mergeSecretsEnv(env, nil)

assert.Equal(s.T(), "val", env["KEY"])
assert.Len(s.T(), env, 1)
}

func (s *LifecycleHookTestSuite) TestMergeSecretsEnvValueWithEquals() {
env := map[string]string{}

mergeSecretsEnv(env, []string{"CONN=host=db port=5432"})

assert.Equal(s.T(), "host=db port=5432", env["CONN"])
}

func TestLifecycleHookTestSuite(t *testing.T) {
suite.Run(t, new(LifecycleHookTestSuite))
}
11 changes: 9 additions & 2 deletions pkg/devcontainer/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type DotfilesConfig struct {
type ContainerSetupConfig struct {
SetupInfo *config.Result
ExtraWorkspaceEnv []string
SecretsEnv []string
ChownProjects bool
Prebuild bool
PlatformOptions *devsy.PlatformOptions
Expand Down Expand Up @@ -70,7 +71,13 @@ func SetupContainerPreAttach(
setupOptionalFeatures(ctx, cfg)

log.Debugf("running pre-attach lifecycle hooks")
deferred, err := RunPreAttachHooks(ctx, cfg.SetupInfo, cfg.Prebuild, cfg.Dotfiles)
deferred, err := RunPreAttachHooks(
ctx,
cfg.SetupInfo,
cfg.Prebuild,
cfg.Dotfiles,
cfg.SecretsEnv,
)
if err != nil {
return DeferredHooks{}, fmt.Errorf("lifecycle hooks pre-attach: %w", err)
}
Expand All @@ -83,7 +90,7 @@ func SetupContainerPreAttach(
// Called after the IDE has been opened.
func SetupContainerPostAttach(ctx context.Context, cfg *ContainerSetupConfig) error {
log.Debugf("running post-attach lifecycle hooks")
if err := RunPostAttachHooks(ctx, cfg.SetupInfo); err != nil {
if err := RunPostAttachHooks(ctx, cfg.SetupInfo, cfg.SecretsEnv); err != nil {
return fmt.Errorf("lifecycle hooks post-attach: %w", err)
}

Expand Down
1 change: 1 addition & 0 deletions pkg/provider/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ type CLIOptions struct {
DevContainerID string `json:"devContainerID,omitempty"`
WorkspaceEnv []string `json:"workspaceEnv,omitempty"`
WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"`
SecretsEnv []string `json:"secretsEnv,omitempty"`
InitEnv []string `json:"initEnv,omitempty"`
Recreate bool `json:"recreate,omitempty"`
Prebuild bool `json:"prebuild,omitempty"`
Expand Down
Loading
Loading