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
101 changes: 87 additions & 14 deletions backend/internal/worker/ci_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,23 @@ type StreamingCommandRunner func(ctx context.Context, workdir string, env []stri
// Implements the built-in actions/checkout; injectable for tests.
type CheckoutFunc func(ctx context.Context, gitPath, ref, dest string) error

// ContainerActionFunc runs a `uses: docker://<image>` container action with the
// workspace mounted and the given env (INPUT_* action inputs plus step env),
// returning its combined output. Injectable for tests.
type ContainerActionFunc func(ctx context.Context, image, workdir string, env []string) ([]byte, error)

type CIWorker struct {
db *sql.DB
decrypt SecretDecrypter
runStep CommandRunner
runStepStream StreamingCommandRunner
checkout CheckoutFunc
stepWait time.Duration
logRepo domainrepo.IJobLogRepository
jobRepo domainrepo.IWorkflowJobRepository
logPublisher *queue.JobLogPublisher
sandbox sandbox
db *sql.DB
decrypt SecretDecrypter
runStep CommandRunner
runStepStream StreamingCommandRunner
checkout CheckoutFunc
containerAction ContainerActionFunc
stepWait time.Duration
logRepo domainrepo.IJobLogRepository
jobRepo domainrepo.IWorkflowJobRepository
logPublisher *queue.JobLogPublisher
sandbox sandbox
}

func NewCIWorker(db *sql.DB) *CIWorker {
Expand All @@ -144,6 +150,7 @@ func NewCIWorker(db *sql.DB) *CIWorker {
w.runStep = w.defaultCommandRunner
w.runStepStream = w.defaultStreamingCommandRunner
w.checkout = defaultCheckout
w.containerAction = defaultContainerAction
return w
}

Expand All @@ -153,6 +160,52 @@ func (w *CIWorker) WithCheckout(fn CheckoutFunc) *CIWorker {
return w
}

// WithContainerAction overrides the docker:// action runner (used in tests).
func (w *CIWorker) WithContainerAction(fn ContainerActionFunc) *CIWorker {
w.containerAction = fn
return w
}

// defaultContainerAction runs a container action image with the workspace bind
// mounted, isolated from the network, passing INPUT_*/step env via -e.
func defaultContainerAction(ctx context.Context, image, workdir string, env []string) ([]byte, error) {
args := []string{"run", "--rm", "--network", "none", "-v", workdir + ":/github/workspace", "-w", "/github/workspace"}
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, image)
return exec.CommandContext(ctx, "docker", args...).CombinedOutput()
}

// actionInputEnv converts a step's `with:` inputs into GitHub-style INPUT_*
// environment variables (uppercased, non-alphanumerics to underscores),
// interpolating any ${{ }} expressions with the step context.
func actionInputEnv(with map[string]string, evalCtx *workflow.EvalContext) []string {
env := make([]string, 0, len(with))
for k, v := range with {
if strings.Contains(v, "${{") {
if iv, err := workflow.InterpolateString(v, evalCtx); err == nil {
v = iv
}
}
env = append(env, "INPUT_"+inputKeyToEnv(k)+"="+v)
}
sort.Strings(env)
return env
}

func inputKeyToEnv(k string) string {
var b strings.Builder
for _, r := range strings.ToUpper(k) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}

// defaultCheckout clones the bare repository into dest and checks out ref using
// the git binary.
func defaultCheckout(ctx context.Context, gitPath, ref, dest string) error {
Expand Down Expand Up @@ -638,8 +691,10 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool {
continue
}

stepEnv := buildStepEnv([]map[string]string{mergedEnv}, s.secretEnv)

if step.Uses != "" {
if w.runUsesStep(ctx, s, step, i, instWorkdir) {
if w.runUsesStep(ctx, s, step, i, instWorkdir, evalCtx, stepEnv) {
jobFailed = true
}
continue
Expand All @@ -649,7 +704,6 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool {
}

script, _ := workflow.InterpolateString(step.Run, evalCtx)
stepEnv := buildStepEnv([]map[string]string{mergedEnv}, s.secretEnv)
stepCtx, cancel := context.WithTimeout(ctx, w.stepWait)

if s.useStreaming {
Expand Down Expand Up @@ -704,7 +758,7 @@ func (w *CIWorker) runJobSteps(ctx context.Context, s runJobSpec) bool {
// the workspace from the run's commit; any other action is logged as
// unsupported and skipped (not failed), so workflows that reference marketplace
// actions still make progress. Returns true only if a supported action failed.
func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.IRStep, i int, workdir string) bool {
func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.IRStep, i int, workdir string, evalCtx *workflow.EvalContext, stepEnv []string) bool {
if isCheckoutAction(step.Uses) {
ref := s.payload.HeadSHA
if ref == "" {
Expand All @@ -717,7 +771,26 @@ func (w *CIWorker) runUsesStep(ctx context.Context, s runJobSpec, step workflow.
}
return false
}
w.emitUsesLine(ctx, s, i, fmt.Sprintf("Skipping unsupported action %q (only actions/checkout is built in)", step.Uses))

// Container actions (uses: docker://<image>) are self-contained: run the
// image with the workspace mounted and `with:` inputs passed as INPUT_*.
if step.UsesRef != nil && step.UsesRef.Kind == "docker" && step.UsesRef.Image != "" {
w.emitUsesLine(ctx, s, i, "Run "+step.Uses)
env := append(append([]string{}, stepEnv...), actionInputEnv(step.With, evalCtx)...)
out, err := w.containerAction(ctx, step.UsesRef.Image, workdir, env)
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if line != "" {
w.emitUsesLine(ctx, s, i, maskSecrets(line, s.secretValues))
}
}
if err != nil {
w.emitUsesLine(ctx, s, i, "action failed: "+maskSecrets(err.Error(), s.secretValues))
return true
}
return false
}

w.emitUsesLine(ctx, s, i, fmt.Sprintf("Skipping unsupported action %q (built-in: actions/checkout and docker:// container actions)", step.Uses))
return false
}

Expand Down
79 changes: 79 additions & 0 deletions backend/internal/worker/ci_worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,85 @@ jobs:
}
}

func TestCIDockerContainerActionRuns(t *testing.T) {
db := newCITestDB(t)
ctx := context.Background()
orgID, repoID, runID := "org-dk", "repo-dk", "run-dk"
mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro)
mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets")
mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued)

var gotImage string
var gotEnv []string
worker := NewCIWorker(db).
WithContainerAction(func(_ context.Context, image, _ string, env []string) ([]byte, error) {
gotImage = image
gotEnv = env
return []byte("container ran\n"), nil
})

payload, _ := json.Marshal(CIRunPayload{
WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID,
WorkflowYAML: []byte(`name: CI
on: push
jobs:
build:
steps:
- uses: docker://alpine:3
with:
greeting: hello
my-name: world
`),
})
if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil {
t.Fatalf("HandleCIRun: %v", err)
}
if gotImage != "alpine:3" {
t.Errorf("container image = %q, want alpine:3", gotImage)
}
joined := strings.Join(gotEnv, "\n")
if !strings.Contains(joined, "INPUT_GREETING=hello") {
t.Errorf("expected INPUT_GREETING=hello in env; got %v", gotEnv)
}
if !strings.Contains(joined, "INPUT_MY_NAME=world") {
t.Errorf("expected INPUT_MY_NAME=world (dash normalized) in env; got %v", gotEnv)
}
}

func TestCIDockerContainerActionFailureFailsJob(t *testing.T) {
db := newCITestDB(t)
ctx := context.Background()
orgID, repoID, runID := "org-dkf", "repo-dkf", "run-dkf"
mustExec(t, db, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, orgID, "acme", "Acme", planTierPro)
mustExec(t, db, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, repoID, orgID, "widgets")
mustExec(t, db, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, runID, orgID, repoID, "ci.yml", ciStatusQueued)

worker := NewCIWorker(db).
WithContainerAction(func(_ context.Context, _, _ string, _ []string) ([]byte, error) {
return []byte("boom\n"), errors.New("exit status 1")
})
payload, _ := json.Marshal(CIRunPayload{
WorkflowRunID: runID, RepositoryID: repoID, OrganizationID: orgID,
WorkflowYAML: []byte(`name: CI
on: push
jobs:
build:
steps:
- uses: docker://alpine:3
`),
})
if err := worker.HandleCIRun(ctx, asynq.NewTask(TypeCIRun, payload)); err != nil {
t.Fatalf("HandleCIRun: %v", err)
}
var conclusion sql.NullString
if err := db.QueryRowContext(ctx, `SELECT conclusion FROM workflow_runs WHERE id = ?`, runID).Scan(&conclusion); err != nil {
t.Fatalf("query: %v", err)
}
if got := strings.SplitN(conclusion.String, "\n", 2)[0]; got != ciConclusionFailure {
t.Errorf("conclusion = %q, want failure (container action errored)", got)
}
}

func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
Expand Down
Loading