diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index fbaff0e0c..055b1a5cd 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -265,6 +265,12 @@ jobs: install-kind: false requires-secret: false + - label: ci + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + - label: features runner: ubuntu-latest free-disk-space: false diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go new file mode 100644 index 000000000..c1aa9ebed --- /dev/null +++ b/cmd/ci/ci.go @@ -0,0 +1,277 @@ +package ci + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + workspacecmd "github.com/devsy-org/devsy/cmd/workspace" + "github.com/devsy-org/devsy/cmd/workspace/up" + "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +const ( + teardownTimeout = 2 * time.Minute + runCmdFlag = "run-cmd" + keepFlag = "keep" +) + +// CICmd holds the ci command flags. +type CICmd struct { + *flags.GlobalFlags + provider.CLIOptions + + ProviderOptions []string + RunCmd []string + RunCmdString string + RemoteEnv []string + Keep bool + Machine string + SecretsFile string + FeatureSecretsFile string +} + +// NewCICmd creates a new ci command. +func NewCICmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &CICmd{GlobalFlags: flags} + ciCmd := &cobra.Command{ + Use: "ci [flags] [workspace-path|workspace-name] -- [args...]", + Short: "Build a devcontainer, run a command inside it, then tear it down", + Long: `Builds an ephemeral devcontainer, runs the given command inside it, and +deletes the workspace afterwards. A non-zero exit from that command propagates as the +exit code of "devsy ci".`, + RunE: func(cobraCmd *cobra.Command, args []string) error { + source, runCmd, err := splitArgs(cobraCmd, args, cmd.RunCmdString) + if err != nil { + return err + } + cmd.RunCmd = runCmd + return cmd.execute(cobraCmd.Context(), source) + }, + } + + cmd.registerFlags(ciCmd) + return ciCmd +} + +func (cmd *CICmd) registerFlags(ciCmd *cobra.Command) { + cliflags.Add(ciCmd, + cliflags.String(&cmd.RunCmdString, runCmdFlag, "", + "Shell command to run inside the container, executed via sh -c "+ + "(alternative to specifying an argv after --)"), + cliflags.StringSlice(&cmd.RemoteEnv, names.RemoteEnv, nil, + "Environment variables to set in the container at run time (KEY=VALUE, repeatable)"), + cliflags.Bool(&cmd.Keep, keepFlag, false, + "Keep the workspace after running instead of tearing it down (useful for debugging)"), + cliflags.String(&cmd.DevContainerSource, names.DevContainer, "", + "Select the devcontainer config source, overriding project discovery: "+ + `"none" (ignore the project config), "image:" (use only that image), `+ + `"id:" (a named .devcontainer/ profile), or a path to a devcontainer.json`), + cliflags.StringSlice(&cmd.ProviderOptions, names.ProviderOption, nil, + "Provider option in the form KEY=VALUE"), + cliflags.String(&cmd.Machine, names.Machine, "", + "The machine to use for this workspace. The machine needs to exist beforehand"), + cliflags.Bool(&cmd.NoCache, names.NoCache, false, + "Do not use the build cache when building the image"), + cliflags.String(&cmd.RunPlatform, names.Platform, "", + "Run the container under a specific platform via emulation (e.g. linux/amd64)"), + cliflags.Value(&cmd.GitCloneStrategy, names.GitCloneStrategy, + "The git clone strategy Devsy uses to checkout git based workspaces "+ + "(full, blobless, treeless or shallow)"), + cliflags.Bool(&cmd.GitCloneRecursiveSubmodules, names.GitRecurseSubmodules, false, + "Clone submodules recursively"), + cliflags.StringArray(&cmd.CacheFrom, names.CacheFrom, nil, + "Cache sources for the build (e.g., myregistry.io/cache:latest). "+ + "Reuse a pre-built image to warm the build"), + cliflags.StringArray(&cmd.WorkspaceEnv, names.WorkspaceEnv, nil, + "Env variables available at build/lifecycle time (KEY=VALUE, repeatable)"), + cliflags.StringSlice(&cmd.WorkspaceEnvFile, names.WorkspaceEnvFile, nil, + "Path to files containing a list of extra env variables to put into the workspace"), + cliflags.StringArray( + &cmd.InitEnv, + names.InitEnv, + nil, + "Extra env variables to inject during workspace initialization (KEY=VALUE, repeatable)", + ), + cliflags.String( + &cmd.SecretsFile, + names.SecretsFile, + "", + "Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands", + ), + cliflags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", + "Path to a JSON file containing secret values for features, format: "+ + `{"featureId": {"optionName": "value"}}`), + ) + cliflags.RegisterDevContainerModifierFlags(ciCmd.Flags(), cliflags.DevContainerModifierFlags{ + Features: &cmd.AdditionalFeatures, + }) +} + +func splitArgs( + cobraCmd *cobra.Command, args []string, runCmdString string, +) (string, []string, error) { + dash := cobraCmd.ArgsLenAtDash() + + var sourceArgs, cmdArgs []string + if dash >= 0 { + sourceArgs = args[:dash] + cmdArgs = args[dash:] + } else { + sourceArgs = args + } + + if len(sourceArgs) > 1 { + return "", nil, fmt.Errorf("expected at most one workspace source, got %d", len(sourceArgs)) + } + + if len(cmdArgs) == 0 { + if runCmdString != "" { + cmdArgs = []string{"sh", "-c", runCmdString} + } + } else if runCmdString != "" { + return "", nil, fmt.Errorf("specify the command after -- or via --run-cmd, not both") + } + if len(cmdArgs) == 0 { + return "", nil, fmt.Errorf("a command to run is required after -- or via --run-cmd") + } + + source := "" + if len(sourceArgs) == 1 { + source = sourceArgs[0] + } + return source, cmdArgs, nil +} + +func (cmd *CICmd) execute(ctx context.Context, source string) (err error) { + if err := cmd.validateEnv(); err != nil { + return err + } + if err := devcontainer.ResolveSourceSpec(&cmd.CLIOptions); err != nil { + return err + } + + // Convert SIGINT/SIGTERM (e.g. a cancelled CI job) into context + // cancellation so the deferred teardown still runs instead of leaking the + // workspace. + ctx, cancel := up.WithSignals(ctx) + defer cancel() + + devsyConfig, cfgErr := config.LoadConfig(cmd.Context, cmd.Provider) + if cfgErr != nil { + return cfgErr + } + if devsyConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == config.BoolTrue { + cmd.StrictHostKeyChecking = true + } + + workspaceClient, cleanup, resolveErr := cmd.resolveWorkspace(ctx, devsyConfig, source) + if resolveErr != nil { + return resolveErr + } + // cleanup tears down the workspace; join error with the result. + defer func() { err = errors.Join(err, cleanup()) }() + + if _, ok := workspaceClient.(client.WorkspaceClient); !ok { + return fmt.Errorf("ci is currently not supported for proxy providers") + } + + if _, err := up.RunHeadless(ctx, workspaceClient, up.HeadlessOptions{ + GlobalFlags: cmd.GlobalFlags, + DevsyConfig: devsyConfig, + CLIOptions: cmd.CLIOptions, + ProviderOptions: cmd.ProviderOptions, + SecretsFile: cmd.SecretsFile, + FeatureSecretsFile: cmd.FeatureSecretsFile, + }); err != nil { + return fmt.Errorf("start devcontainer: %w", err) + } + + log.Infof("running command in devcontainer: %s", strings.Join(cmd.RunCmd, " ")) + return cmd.runInContainer(ctx, workspaceClient) +} + +// resolveWorkspace resolves (creating if needed) the workspace to run in. +func (cmd *CICmd) resolveWorkspace( + ctx context.Context, + devsyConfig *config.Config, + source string, +) (client.BaseWorkspaceClient, func() error, error) { + var args []string + if source != "" { + args = []string{source} + } + exists := workspace2.Exists(ctx, devsyConfig, args, "", cmd.Owner) + + sshConfigPath, cleanupSSH, err := workspacecmd.NewTempSSHConfig() + if err != nil { + return nil, nil, err + } + + workspaceClient, err := workspace2.Resolve(ctx, devsyConfig, workspace2.ResolveParams{ + Args: args, + DesiredMachine: cmd.Machine, + ProviderUserOptions: cmd.ProviderOptions, + DevContainerImage: cmd.DevContainerImage, + DevContainerPath: cmd.DevContainerPath, + SSHConfigPath: sshConfigPath, + UID: cmd.UID, + Owner: cmd.Owner, + }) + if err != nil { + cleanupSSH() + return nil, nil, err + } + + teardown := exists == "" && !cmd.Keep + cleanup := func() error { + defer cleanupSSH() + if teardown { + return cmd.teardown(workspaceClient) + } + return nil + } + return workspaceClient, cleanup, nil +} + +// runInContainer runs the command inside the started container. +func (cmd *CICmd) runInContainer(ctx context.Context, c client.BaseWorkspaceClient) error { + execCmd := &workspacecmd.ExecCmd{ + GlobalFlags: cmd.GlobalFlags, + WorkspaceName: c.Workspace(), + RemoteEnv: cmd.RemoteEnv, + } + return execCmd.Run(ctx, cmd.RunCmd) +} + +func (cmd *CICmd) teardown(c client.BaseWorkspaceClient) error { + log.Infof("tearing down workspace") + ctx, cancel := context.WithTimeout(context.Background(), teardownTimeout) + defer cancel() + if err := c.Delete(ctx, client.DeleteOptions{Force: true}); err != nil { + return fmt.Errorf("delete workspace: %w", err) + } + return nil +} + +func (cmd *CICmd) validateEnv() error { + for _, env := range cmd.RemoteEnv { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid %s value %q: must be KEY=VALUE format", + names.Flag(names.RemoteEnv), env) + } + } + return nil +} diff --git a/cmd/ci/ci_test.go b/cmd/ci/ci_test.go new file mode 100644 index 000000000..c2d04d960 --- /dev/null +++ b/cmd/ci/ci_test.go @@ -0,0 +1,111 @@ +package ci + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + cmdMake = "make" + cmdTest = "test" + cmdEcho = "echo" +) + +func TestValidateEnv(t *testing.T) { + tests := []struct { + name string + env []string + wantErr bool + }{ + {name: "valid", env: []string{"FOO=bar", "BAZ=qux=extra"}, wantErr: false}, + {name: "empty slice", env: nil, wantErr: false}, + {name: "missing equals", env: []string{"INVALID"}, wantErr: true}, + {name: "empty key", env: []string{"=value"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &CICmd{GlobalFlags: &flags.GlobalFlags{}, RemoteEnv: tt.env} + err := cmd.validateEnv() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "must be KEY=VALUE format") + } else { + assert.NoError(t, err) + } + }) + } +} + +func runSplit(t *testing.T, args []string, runCmdString string) (string, []string, error) { + t.Helper() + var ( + gotSource string + gotCmd []string + gotErr error + ) + cobraCmd := &cobra.Command{ + Use: "ci", + RunE: func(c *cobra.Command, a []string) error { + gotSource, gotCmd, gotErr = splitArgs(c, a, runCmdString) + return nil + }, + } + cobraCmd.SetArgs(args) + require.NoError(t, cobraCmd.Execute()) + return gotSource, gotCmd, gotErr +} + +func TestSplitArgs_CommandAfterDash(t *testing.T) { + source, cmd, err := runSplit(t, []string{"--", cmdMake, cmdTest}, "") + require.NoError(t, err) + assert.Empty(t, source) + assert.Equal(t, []string{cmdMake, cmdTest}, cmd) +} + +func TestSplitArgs_SourceAndCommand(t *testing.T) { + source, cmd, err := runSplit(t, []string{"my-ws", "--", cmdEcho, "hi"}, "") + require.NoError(t, err) + assert.Equal(t, "my-ws", source) + assert.Equal(t, []string{cmdEcho, "hi"}, cmd) +} + +func TestSplitArgs_RunCmdWrapsInShell(t *testing.T) { + source, cmd, err := runSplit(t, nil, "make ci") + require.NoError(t, err) + assert.Empty(t, source) + assert.Equal(t, []string{"sh", "-c", "make ci"}, cmd) +} + +func TestSplitArgs_MissingCommand(t *testing.T) { + _, _, err := runSplit(t, []string{"my-ws"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "a command to run is required") +} + +func TestSplitArgs_DashAndRunCmdConflict(t *testing.T) { + _, _, err := runSplit(t, []string{"--", cmdMake, cmdTest}, "make ci") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestSplitArgs_TooManySources(t *testing.T) { + _, _, err := runSplit(t, []string{"ws-one", "ws-two", "--", cmdEcho}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "at most one workspace source") +} + +func TestNewCICmd_Flags(t *testing.T) { + ciCmd := NewCICmd(&flags.GlobalFlags{}) + for _, name := range []string{ + "run-cmd", "remote-env", "keep", "devcontainer", "no-cache", "platform", + "cache-from", "workspace-env", "workspace-env-file", "init-env", + "secrets-file", "feature-secrets-file", "features", + } { + assert.NotNil(t, ciCmd.Flags().Lookup(name), "expected flag %q to be registered", name) + } + assert.Equal(t, "false", ciCmd.Flags().Lookup("keep").DefValue) +} diff --git a/cmd/root.go b/cmd/root.go index 29ac60e98..e29691ec0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "strings" + "github.com/devsy-org/devsy/cmd/ci" "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" "github.com/devsy-org/devsy/cmd/context" @@ -340,5 +341,8 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) templateCmd := template.NewTemplateCmd(globalFlags) templateCmd.GroupID = groupDevcontainer rootCmd.AddCommand(templateCmd) + ciCmd := ci.NewCICmd(globalFlags) + ciCmd.GroupID = groupDevcontainer + rootCmd.AddCommand(ciCmd) rootCmd.AddCommand(cmdinternal.NewInternalCmd(globalFlags)) } diff --git a/cmd/workspace/build.go b/cmd/workspace/build.go index b59802212..4dc227634 100644 --- a/cmd/workspace/build.go +++ b/cmd/workspace/build.go @@ -138,7 +138,7 @@ func (cmd *BuildCmd) execute(ctx context.Context, args []string) error { } exists := workspace2.Exists(ctx, devsyConfig, args, "", cmd.Owner) - sshConfigPath, cleanup, err := newTempSSHConfig() + sshConfigPath, cleanup, err := NewTempSSHConfig() if err != nil { return err } @@ -226,13 +226,17 @@ func (cmd *BuildCmd) cleanupTempWorkspace( } } -// newTempSSHConfig creates a temporary ssh config file and returns its path and a cleanup func. -func newTempSSHConfig() (string, func(), error) { +// NewTempSSHConfig creates a temporary ssh config file and returns its path and a cleanup func. +func NewTempSSHConfig() (string, func(), error) { f, err := os.CreateTemp("", config.BinaryName+"ssh.config") if err != nil { return "", nil, err } path := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", nil, err + } return path, func() { _ = os.Remove(path) }, nil } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 3f8b7d44e..9d010bad5 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -67,6 +67,53 @@ type Options struct { DevcontainerPath string // path to devcontainer.json, relative to project } +type HeadlessOptions struct { + GlobalFlags *flags.GlobalFlags + DevsyConfig *config.Config + CLIOptions provider2.CLIOptions + ProviderOptions []string + SecretsFile string + FeatureSecretsFile string +} + +func RunHeadless( + ctx context.Context, + client client2.BaseWorkspaceClient, + opts HeadlessOptions, +) (*config2.Result, error) { + gCopy := *opts.GlobalFlags + cmd := &UpCmd{ + GlobalFlags: &gCopy, + CLIOptions: opts.CLIOptions, + ProviderOptions: opts.ProviderOptions, + SecretsFile: opts.SecretsFile, + FeatureSecretsFile: opts.FeatureSecretsFile, + ConfigureSSH: false, + IDELaunch: opener.LaunchSkip, + Out: io.Discard, + } + cmd.IDE = string(config.IDENone) + mountGitRootDefault := true + cmd.MountWorkspaceGitRoot = &mountGitRootDefault + + if err := cmd.validate(); err != nil { + return nil, err + } + if err := cmd.prepareSecrets(); err != nil { + return nil, err + } + cmd.prepareWorkspace(client) + + wctx, err := cmd.executeDevsyUp(ctx, opts.DevsyConfig, client) + if err != nil { + return nil, err + } + if wctx == nil { + return nil, fmt.Errorf("up returned no workspace context") + } + return wctx.result, nil +} + // RunFromOptions runs the up logic without cobra. Callers own ctx cancellation; // WithSignals is intentionally skipped. func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) error { diff --git a/cmd/workspace/up/up_devcontainer_source_test.go b/cmd/workspace/up/up_devcontainer_source_test.go deleted file mode 100644 index 41abb6791..000000000 --- a/cmd/workspace/up/up_devcontainer_source_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package up - -import ( - "testing" - - "github.com/devsy-org/devsy/cmd/flags" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const srcNone = "none" - -func TestResolveDevContainerSource_ID(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "id:default" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, "default", cmd.DevContainerID) - assert.Empty(t, cmd.DevContainerSource) -} - -func TestResolveDevContainerSource_Path(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = ".devcontainer/custom.json" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, ".devcontainer/custom.json", cmd.DevContainerPath) - assert.Empty(t, cmd.DevContainerSource) -} - -func TestResolveDevContainerSource_ExternalPathPassThrough(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "/abs/external/devcontainer.json" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, "/abs/external/devcontainer.json", cmd.DevContainerSource) - assert.Empty(t, cmd.DevContainerPath) - assert.Empty(t, cmd.DevContainerID) -} - -func TestResolveDevContainerSource_NoneAndImagePassThrough(t *testing.T) { - for _, spec := range []string{srcNone, "image:python"} { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = spec - - require.NoError(t, cmd.resolveDevContainerSource()) - // none/image specs stay for agent-side handling. - assert.Equal(t, spec, cmd.DevContainerSource) - assert.Empty(t, cmd.DevContainerID) - assert.Empty(t, cmd.DevContainerPath) - } -} - -func TestResolveDevContainerSource_Invalid(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "image:" - - require.Error(t, cmd.resolveDevContainerSource()) -} diff --git a/cmd/workspace/up/up_validate.go b/cmd/workspace/up/up_validate.go index 88795ab34..9e5e75f99 100644 --- a/cmd/workspace/up/up_validate.go +++ b/cmd/workspace/up/up_validate.go @@ -21,7 +21,7 @@ const ( ) func (cmd *UpCmd) validate() error { - if err := cmd.resolveDevContainerSource(); err != nil { + if err := devcontainer.ResolveSourceSpec(&cmd.CLIOptions); err != nil { return err } if err := validatePodmanFlags(cmd); err != nil { @@ -48,28 +48,6 @@ func (cmd *UpCmd) validate() error { return validateRemoteUserUID(cmd.UpdateRemoteUserUIDDefault) } -func (cmd *UpCmd) resolveDevContainerSource() error { - spec, err := devcontainer.ParseSourceSpec(cmd.DevContainerSource) - if err != nil { - return err - } - if spec == nil { - return nil - } - switch spec.Kind { - case devcontainer.SourceID: - cmd.DevContainerID = spec.ID - cmd.DevContainerSource = "" - case devcontainer.SourcePath: - if !filepath.IsAbs(spec.Path) { - cmd.DevContainerPath = spec.Path - cmd.DevContainerSource = "" - } - case devcontainer.SourceNone, devcontainer.SourceImage: - } - return nil -} - func (cmd *UpCmd) resolveExtraDevContainerPath() error { if cmd.ExtraDevContainerPath == "" { return nil diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 8585ee664..4a91fa3b4 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/e2e/framework" // Register tests. _ "github.com/devsy-org/devsy/e2e/tests/build" + _ "github.com/devsy-org/devsy/e2e/tests/ci" _ "github.com/devsy-org/devsy/e2e/tests/configapply" _ "github.com/devsy-org/devsy/e2e/tests/configread" _ "github.com/devsy-org/devsy/e2e/tests/context" diff --git a/e2e/tests/ci/ci.go b/e2e/tests/ci/ci.go new file mode 100644 index 000000000..aababba78 --- /dev/null +++ b/e2e/tests/ci/ci.go @@ -0,0 +1,167 @@ +package ci + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ciCommand = "ci" + +func expectWorkspaceGone(ctx context.Context, f *framework.Framework, name string) { + list, err := f.DevsyListParsed(ctx) + framework.ExpectNoError(err) + wantID := workspace.ToID(name) + for _, w := range list { + gomega.Expect(w.ID).ToNot(gomega.Equal(wantID), + "workspace %s should be torn down", wantID) + } +} + +var _ = ginkgo.Describe("devsy ci test suite", ginkgo.Label("ci"), ginkgo.Ordered, func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("should build, run a command, and tear the workspace down", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--", "sh", "-c", "echo -n hello-ci", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("hello-ci")) + + expectWorkspaceGone(ctx, f, tempDir) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should propagate a non-zero exit code from the run command", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + _, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--", "sh", "-c", "exit 3", + }) + var exitErr *exec.ExitError + gomega.Expect(errors.As(err, &exitErr)).To(gomega.BeTrue(), + "expected an exec.ExitError, got %v", err) + gomega.Expect(exitErr.ExitCode()).To(gomega.Equal(3)) + + expectWorkspaceGone(ctx, f, tempDir) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should retain the workspace when --keep is passed", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, "--keep", + "--", "sh", "-c", "echo -n kept", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("kept")) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass --remote-env to the run command", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--remote-env", "MY_CI_VAR=ci_value", + "--", "sh", "-c", "echo -n $MY_CI_VAR", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("ci_value")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass --workspace-env into the container", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--workspace-env", "WS_CI_VAR=ws_value", + "--", "cat", "/etc/envfile.json", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring(`"WS_CI_VAR":"ws_value"`)) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should accept the run command via --run-cmd", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--run-cmd", "echo -n from-run-cmd", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("from-run-cmd")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should inject secrets from --secrets-file into lifecycle commands", + func(ctx context.Context) { + tempDir, f := setupCIFrom(initialDir, "tests/ci/testdata/secrets") + + secretsFile := filepath.Join(tempDir, "ci.secrets") + err := os.WriteFile(secretsFile, []byte("MY_SECRET=s3cr3t\n"), 0o600) + framework.ExpectNoError(err) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--secrets-file", secretsFile, + "--", "cat", "/tmp/ci-secret.txt", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("s3cr3t")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should accept --cache-from and complete the run", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--cache-from", "ghcr.io/devsy-org/test-images/base:ubuntu", + "--", "sh", "-c", "echo -n cached-run", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("cached-run")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) + +func setupCI(initialDir string) (string, *framework.Framework) { + return setupCIFrom(initialDir, "tests/ci/testdata/ci") +} + +func setupCIFrom(initialDir, testdataPath string) (string, *framework.Framework) { + tempDir, err := framework.CopyToTempDir(testdataPath) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + return tempDir, f +} diff --git a/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json b/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json new file mode 100644 index 000000000..ac422024e --- /dev/null +++ b/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "CI Test", + "image": "ghcr.io/devsy-org/test-images/base:ubuntu" +} diff --git a/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json b/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f916bdaa9 --- /dev/null +++ b/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "CI Secrets Test", + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "postCreateCommand": { + "capture-secret": "printf '%s' \"$MY_SECRET\" > /tmp/ci-secret.txt" + } +} diff --git a/pkg/devcontainer/source.go b/pkg/devcontainer/source.go index 4f2efdbf1..7d09b6422 100644 --- a/pkg/devcontainer/source.go +++ b/pkg/devcontainer/source.go @@ -2,9 +2,11 @@ package devcontainer import ( "fmt" + "path/filepath" "strings" "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/provider" ) type SourceSpec struct { @@ -61,3 +63,27 @@ func ParseSourceSpec(value string) (*SourceSpec, error) { return &SourceSpec{Kind: SourcePath, Path: value}, nil } } + +func ResolveSourceSpec(opts *provider.CLIOptions) error { + spec, err := ParseSourceSpec(opts.DevContainerSource) + if err != nil { + return err + } + if spec == nil { + return nil + } + switch spec.Kind { + case SourceID: + opts.DevContainerID = spec.ID + opts.DevContainerSource = "" + case SourcePath: + // An in-repo relative path is a config path; an external absolute + // path is left in the source so the runner imports it. + if !filepath.IsAbs(spec.Path) { + opts.DevContainerPath = spec.Path + opts.DevContainerSource = "" + } + case SourceNone, SourceImage: + } + return nil +} diff --git a/pkg/devcontainer/source_test.go b/pkg/devcontainer/source_test.go index 87baecba5..8a91b942f 100644 --- a/pkg/devcontainer/source_test.go +++ b/pkg/devcontainer/source_test.go @@ -3,10 +3,16 @@ package devcontainer import ( "testing" + "github.com/devsy-org/devsy/pkg/provider" "gotest.tools/assert" ) -const testImage = "python" +const ( + testImage = "python" + testID = "default" + testPath = ".devcontainer/custom.json" + testImgSrc = "image:python" +) func TestParseSourceSpec(t *testing.T) { cases := []struct { @@ -28,12 +34,12 @@ func TestParseSourceSpec(t *testing.T) { wantImage: "mcr.microsoft.com/devcontainers/python:3", }, {in: "image: python ", wantKind: SourceImage, wantImage: "python"}, - {in: "id:default", wantKind: SourceID, wantID: "default"}, + {in: sourceIDPrefix + testID, wantKind: SourceID, wantID: testID}, {in: "id: claude ", wantKind: SourceID, wantID: "claude"}, { - in: ".devcontainer/custom.json", + in: testPath, wantKind: SourcePath, - wantPath: ".devcontainer/custom.json", + wantPath: testPath, }, {in: "/abs/path.json", wantKind: SourcePath, wantPath: "/abs/path.json"}, {in: "image:", wantErr: true}, @@ -56,3 +62,35 @@ func TestParseSourceSpec(t *testing.T) { assert.Equal(t, c.wantPath, spec.Path, "input %q path", c.in) } } + +func TestResolveSourceSpec(t *testing.T) { + cases := []struct { + in string + wantID string + wantPath string + wantImage string + wantSrc string + wantErr bool + }{ + {in: ""}, + {in: sourceIDPrefix + testID, wantID: testID}, + {in: testPath, wantPath: testPath}, + {in: "/abs/external/devcontainer.json", wantSrc: "/abs/external/devcontainer.json"}, + {in: string(SourceNone), wantSrc: string(SourceNone)}, + {in: testImgSrc, wantSrc: testImgSrc}, + {in: "image:", wantErr: true}, + } + for _, c := range cases { + opts := &provider.CLIOptions{DevContainerSource: c.in} + err := ResolveSourceSpec(opts) + if c.wantErr { + assert.Assert(t, err != nil, "input %q should error", c.in) + continue + } + assert.NilError(t, err, "input %q", c.in) + assert.Equal(t, c.wantID, opts.DevContainerID, "input %q id", c.in) + assert.Equal(t, c.wantPath, opts.DevContainerPath, "input %q path", c.in) + assert.Equal(t, c.wantImage, opts.DevContainerImage, "input %q image", c.in) + assert.Equal(t, c.wantSrc, opts.DevContainerSource, "input %q source", c.in) + } +} diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx new file mode 100644 index 000000000..641d5a3c5 --- /dev/null +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx @@ -0,0 +1,108 @@ +--- +title: Continuous Integration +sidebar_label: Continuous Integration +--- + +## Continuous Integration + +`devsy ci` builds an ephemeral devcontainer, runs a command inside it, and tears +the workspace down afterwards. It lets you verify that your `devcontainer.json` +still builds. + +### How it works + +1. Resolves an workspace from the current directory (or a given source). +2. Builds and starts the devcontainer (equivalent to `devsy up`, without + launching an IDE or touching your SSH config). +3. Runs the given command inside the running container. +4. Deletes the workspace — always, even when the command fails — unless + `--keep` is passed. + +A non-zero exit from the command propagates as the exit code of `devsy ci`, so a +failing test or build fails the CI job. + +### Usage + +```sh +devsy ci [flags] [workspace-path|workspace-name] -- [args...] +``` + +Run the test suite inside the devcontainer for the current project: + +```sh +devsy ci -- make test +``` + +Target a specific project folder and pass environment variables: + +```sh +devsy ci ./service -- npm test +devsy ci --remote-env CI=true --remote-env NODE_ENV=test -- npm test +``` + +Select a specific devcontainer config (e.g. a named `.devcontainer/` profile): + +```sh +devsy ci --devcontainer id:ci -- make test +``` + +Keep the workspace around for debugging when the command fails: + +```sh +devsy ci --keep -- ./run-integration-tests.sh +``` + +### Common flags + +| Flag | Description | +| --- | --- | +| `--run-cmd` | Shell command run inside the container via `sh -c` (alternative to an explicit argv after `--`). | +| `--remote-env KEY=VALUE` | Set an environment variable in the container at run time. Repeatable. | +| `--keep` | Keep the workspace instead of tearing it down. | +| `--devcontainer` | Select the devcontainer config source: `none`, `image:`, `id:`, or a path to a `devcontainer.json`. | +| `--no-cache` | Build without using the cache. | +| `--cache-from` | Reuse a pre-built image as a build cache source. Repeatable. | +| `--platform` | Run the container under a specific platform via emulation (e.g. `linux/amd64`). | +| `--workspace-env KEY=VALUE` | Env variables available at build and lifecycle time (not just at run time). Repeatable. | +| `--workspace-env-file` | File(s) of `KEY=VALUE` build/lifecycle env variables. | +| `--init-env KEY=VALUE` | Env variables injected during workspace initialization. Repeatable. | +| `--secrets-file` | Dotenv-style file of secrets injected into lifecycle commands. | +| `--feature-secrets-file` | JSON file of secret values for features. | + +### Pre-building and pushing images + +`devsy ci` focuses on running a command. To pre-build and publish a devcontainer +image for reuse as a build cache, use `devsy build`: + +```sh +devsy build --repository ghcr.io/my-org/my-devcontainer --tag latest --push +``` + +Downstream CI jobs can then reference that image via `--cache-from` to speed up +builds. + +### GitHub Actions + +Install the CLI, configure the docker provider, then run `devsy ci`: + +```yaml +name: Devcontainer CI +on: + push: + branches: [main] + pull_request: + +jobs: + devcontainer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Devsy + run: | + curl -fsSL https://github.com/devsy-org/devsy/releases/latest/download/devsy-linux-amd64 -o /usr/local/bin/devsy + chmod +x /usr/local/bin/devsy + - name: Run CI in devcontainer + run: | + devsy provider add docker && devsy provider use docker + devsy ci -- make test +``` diff --git a/sites/docs-devsy-sh/sidebars.js b/sites/docs-devsy-sh/sidebars.js index a39a69540..a38b7028f 100644 --- a/sites/docs-devsy-sh/sidebars.js +++ b/sites/docs-devsy-sh/sidebars.js @@ -57,6 +57,10 @@ module.exports = { type: "doc", id: "developing-in-workspaces/prebuild-a-workspace", }, + { + type: "doc", + id: "developing-in-workspaces/continuous-integration", + }, { type: "doc", id: "developing-in-workspaces/dotfiles-in-a-workspace",