diff --git a/cmd/root.go b/cmd/root.go index ea698b061..469efb78f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -132,6 +132,8 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewExecCmd(globalFlags)) rootCmd.AddCommand(NewOutdatedCmd(globalFlags)) rootCmd.AddCommand(NewSetUpCmd(globalFlags)) + rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) + rootCmd.AddCommand(NewRunUserCommandsCmdAlias(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/cmd/runusercommands.go b/cmd/runusercommands.go new file mode 100644 index 000000000..de1e77f08 --- /dev/null +++ b/cmd/runusercommands.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/types" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// RunUserCommandsCmd holds the run-user-commands command flags. +type RunUserCommandsCmd struct { + *flags.GlobalFlags + + WorkspaceFolder string + IDLabels []string +} + +// NewRunUserCommandsCmd creates a new run-user-commands command. +func NewRunUserCommandsCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &RunUserCommandsCmd{GlobalFlags: f} + runE := func(cobraCmd *cobra.Command, _ []string) error { + return cmd.Run(cobraCmd.Context()) + } + + runCmd := &cobra.Command{ + Use: "run-user-commands", + Short: "Executes lifecycle commands in a running workspace container", + RunE: runE, + } + + runCmd.Flags(). + StringVar( + &cmd.WorkspaceFolder, + "workspace-folder", + "", + "Path to the workspace folder", + ) + _ = runCmd.MarkFlagRequired("workspace-folder") + runCmd.Flags(). + StringArrayVar( + &cmd.IDLabels, + "id-label", + []string{}, + "Override the default container identification labels (format: key=value, can be specified multiple times)", + ) + + return runCmd +} + +// NewRunUserCommandsCmdAlias creates the hidden camelCase alias for devcontainer CLI compat. +func NewRunUserCommandsCmdAlias(f *flags.GlobalFlags) *cobra.Command { + primary := NewRunUserCommandsCmd(f) + primary.Use = "runUserCommands" + primary.Hidden = true + return primary +} + +type lifecycleExecParams struct { + ctx context.Context + helper *docker.DockerHelper + containerID string + envArgs []string + workdir string +} + +// Run executes the run-user-commands logic. +func (cmd *RunUserCommandsCmd) Run(ctx context.Context) error { + if err := devcconfig.ValidateIDLabels(cmd.IDLabels); err != nil { + return err + } + + params, result, err := cmd.resolveContainer(ctx) + if err != nil { + return err + } + + if err := cmd.runLifecycleHooks(params, result); err != nil { + return err + } + + user := devcconfig.GetRemoteUser(result) + log.Infof("lifecycle commands completed for container %s", params.containerID) + _ = devcconfig.WriteResultJSON(os.Stderr, params.containerID, user, params.workdir) + return nil +} + +func (cmd *RunUserCommandsCmd) resolveContainer( + ctx context.Context, +) (*lifecycleExecParams, *devcconfig.Result, error) { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return nil, nil, err + } + + client, err := workspace2.Get(ctx, workspace2.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{cmd.WorkspaceFolder}, + Owner: cmd.Owner, + }) + if err != nil { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + return nil, nil, fmt.Errorf("resolve workspace: %w", err) + } + + workspaceConfig := client.WorkspaceConfig() + dockerCommand := resolveDockerCommand(workspaceConfig) + + containerDetails, err := findRunningContainer( + ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, + ) + if err != nil { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + return nil, nil, err + } + + result := loadExecResult(workspaceConfig, containerDetails) + if result == nil || result.MergedConfig == nil { + _ = devcconfig.WriteErrorJSON( + os.Stderr, + "no workspace result found; lifecycle commands unavailable", + ) + return nil, nil, fmt.Errorf("no workspace result found; lifecycle commands unavailable") + } + + params := &lifecycleExecParams{ + ctx: ctx, + helper: &docker.DockerHelper{DockerCommand: dockerCommand}, + containerID: containerDetails.ID, + envArgs: buildLifecycleEnvArgs(result), + workdir: resolveExecWorkdir(result, client.Workspace()), + } + return params, result, nil +} + +func (cmd *RunUserCommandsCmd) runLifecycleHooks( + params *lifecycleExecParams, + result *devcconfig.Result, +) error { + hooks := []struct { + name string + cmds []types.LifecycleHook + }{ + {"postCreateCommand", result.MergedConfig.PostCreateCommands}, + {"postStartCommand", result.MergedConfig.PostStartCommands}, + {"postAttachCommand", result.MergedConfig.PostAttachCommands}, + } + + for _, hook := range hooks { + for _, h := range hook.cmds { + if err := execLifecycleHook(params, hook.name, h); err != nil { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + return fmt.Errorf("lifecycle hooks: %s: %w", hook.name, err) + } + } + } + return nil +} + +func execLifecycleHook(params *lifecycleExecParams, name string, hook types.LifecycleHook) error { + if len(hook) == 0 { + return nil + } + + for key, command := range hook { + if len(command) == 0 { + continue + } + log.Infof("running %s: %s %v", name, key, command) + + args := buildDockerExecArgs(params.containerID, params.envArgs, params.workdir, command) + if err := params.helper.Run(params.ctx, args, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("command %q failed: %w", key, err) + } + } + + return nil +} + +func buildLifecycleEnvArgs(result *devcconfig.Result) []string { + if result == nil || result.MergedConfig == nil { + return nil + } + + env := result.MergedConfig.RemoteEnv + if len(env) == 0 { + return nil + } + + keys := make([]string, 0, len(env)) + for k, v := range env { + if v != nil { + keys = append(keys, k) + } + } + sort.Strings(keys) + + args := make([]string, 0, len(keys)*2) + for _, k := range keys { + args = append(args, "-e", k+"="+*env[k]) + } + return args +} diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go new file mode 100644 index 000000000..ba6e7d6ba --- /dev/null +++ b/cmd/runusercommands_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRunUserCommandsCmd_CommandName(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + assert.Equal(t, "run-user-commands", cmd.Use) +} + +func TestNewRunUserCommandsCmdAlias_IsHidden(t *testing.T) { + cmd := NewRunUserCommandsCmdAlias(&flags.GlobalFlags{}) + assert.Equal(t, "runUserCommands", cmd.Use) + assert.True(t, cmd.Hidden, "camelCase alias should be hidden") +} + +func TestNewRunUserCommandsCmdAlias_RegisteredInRoot(t *testing.T) { + rootCmd := BuildRoot() + found := false + for _, sub := range rootCmd.Commands() { + if sub.Use == "runUserCommands" { + found = true + assert.True(t, sub.Hidden) + break + } + } + assert.True(t, found, "runUserCommands alias should be registered in root") +} + +func TestNewRunUserCommandsCmd_WorkspaceFolderRequired(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} + +func TestNewRunUserCommandsCmd_IDLabelFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("id-label") + require.NotNil(t, f) + assert.Equal(t, "stringArray", f.Value.Type()) +} + +func TestBuildLifecycleEnvArgs_Nil(t *testing.T) { + args := buildLifecycleEnvArgs(nil) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_NilMergedConfig(t *testing.T) { + result := &devcconfig.Result{} + args := buildLifecycleEnvArgs(result) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_EmptyEnv(t *testing.T) { + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{}, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_WithValues(t *testing.T) { + val := "bar" + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{ + "FOO": &val, + }, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Equal(t, []string{"-e", "FOO=bar"}, args) +} + +func TestBuildLifecycleEnvArgs_NilValueSkipped(t *testing.T) { + val := "keep" + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{ + "KEEP": &val, + "REMOVE": nil, + }, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Contains(t, args, "-e") + assert.Contains(t, args, "KEEP=keep") + assert.NotContains(t, args, "REMOVE") +} + +func TestRunUserCommandsCmd_RegisteredInRoot(t *testing.T) { + rootCmd := BuildRoot() + found := false + for _, sub := range rootCmd.Commands() { + if sub.Use == "run-user-commands" { + found = true + break + } + } + assert.True(t, found, "run-user-commands should be registered in root") +}