Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
277 changes: 277 additions & 0 deletions cmd/ci/ci.go
Original file line number Diff line number Diff line change
@@ -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] -- <cmd> [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:<ref>" (use only that image), `+
`"id:<name>" (a named .devcontainer/<name> 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, " "))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging raw command arguments.

CI commands may include tokens or passwords as arguments; logging the full joined command exposes them in persistent build logs. Log only the executable or emit full arguments at an explicitly sensitive debug level.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ci/ci.go` at line 194, Update the command logging around the devcontainer
execution to avoid exposing raw arguments: log only the executable from
cmd.RunCmd, or move the full command to an explicitly sensitive debug-level log.
Preserve the existing execution behavior and the informational log context.

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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
Loading
Loading