completed phase 2b

This commit is contained in:
2026-05-11 20:10:45 +02:00
parent 83d96d0a1e
commit 4002a3b84d
20 changed files with 1566 additions and 50 deletions
+271
View File
@@ -0,0 +1,271 @@
package ci
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// JobContext holds everything needed to execute a single pipeline job.
type JobContext struct {
Run models.PipelineRun
Job models.PipelineJob
Steps []models.PipelineStep
Repo models.Repository
}
// ExecuteJob runs all steps of a job inside isolated Docker containers,
// streams log output to NATS and the DB, then publishes job.completed or job.failed.
func ExecuteJob(ctx context.Context, db *xorm.Engine, bus events.EventBus, jc JobContext, workspaceRoot string) {
now := time.Now().UTC()
jc.Job.Status = "running"
jc.Job.StartedAt = &now
db.ID(jc.Job.ID).Cols("status", "started_at").Update(&jc.Job) //nolint:errcheck
// Extract repo snapshot into a workspace directory.
workDir, err := extractWorkspace(jc.Repo.DiskPath, jc.Run.TriggerSHA, workspaceRoot, jc.Run.ID)
if err != nil {
failJob(db, bus, jc, fmt.Sprintf("workspace setup failed: %v", err))
return
}
defer os.RemoveAll(workDir)
image := jc.Job.Image
if image == "" {
image = "ubuntu:22.04"
}
// Pull image once per job (non-fatal if pull fails and image exists locally).
pullCmd := exec.CommandContext(ctx, "docker", "pull", image)
pullCmd.Run() //nolint:errcheck
for i := range jc.Steps {
step := &jc.Steps[i]
if step.UsesAction == "checkout" {
// Built-in checkout: workspace is already set up by extractWorkspace.
markStep(db, step, "succeeded", 0)
continue
}
if step.RunCmd == "" {
markStep(db, step, "skipped", 0)
continue
}
exitCode, err := runStep(ctx, db, bus, jc.Run.ID, jc.Job.ID, step, image, workDir)
if err != nil || exitCode != 0 {
if exitCode == 0 {
exitCode = 1
}
markStep(db, step, "failed", exitCode)
failJob(db, bus, jc, fmt.Sprintf("step %q exited %d", step.Name, exitCode))
return
}
markStep(db, step, "succeeded", 0)
}
fin := time.Now().UTC()
jc.Job.Status = "succeeded"
jc.Job.FinishedAt = &fin
db.ID(jc.Job.ID).Cols("status", "finished_at").Update(&jc.Job) //nolint:errcheck
bus.Publish(events.SubjectJobCompleted, events.JobEvent{ //nolint:errcheck
RunID: jc.Run.ID, JobID: jc.Job.ID, Status: "succeeded", At: fin,
})
}
// runStep runs a single shell-command step inside a Docker container.
func runStep(ctx context.Context, db *xorm.Engine, bus events.EventBus,
runID, jobID int64, step *models.PipelineStep, image, workDir string) (int, error) {
now := time.Now().UTC()
step.Status = "running"
step.StartedAt = &now
db.ID(step.ID).Cols("status", "started_at").Update(step) //nolint:errcheck
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", workDir+":/workspace",
"-w", "/workspace",
"--network=none", // no network by default; Phase 2C will add network scopes
image,
"/bin/sh", "-ec", step.RunCmd,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return 1, err
}
cmd.Stderr = cmd.Stdout // merge stderr into stdout
if err := cmd.Start(); err != nil {
return 1, fmt.Errorf("docker run: %w", err)
}
chunk := 0
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text() + "\n"
writeLogChunk(db, bus, runID, jobID, step.ID, chunk, line)
chunk++
}
exitCode := 0
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
return exitCode, nil
}
// extractWorkspace uses git-archive to export the repo at a given SHA into a
// temporary directory under workspaceRoot.
func extractWorkspace(repoPath, sha, workspaceRoot string, runID int64) (string, error) {
dir := filepath.Join(workspaceRoot, fmt.Sprintf("run-%d", runID))
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
archive := exec.Command("git", "archive", "--format=tar", sha)
archive.Dir = repoPath
archive.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
tar := exec.Command("tar", "-x", "-C", dir)
tar.Stdin, _ = archive.StdoutPipe()
if err := archive.Start(); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("git archive: %w", err)
}
if err := tar.Start(); err != nil {
archive.Process.Kill() //nolint:errcheck
os.RemoveAll(dir)
return "", fmt.Errorf("tar: %w", err)
}
archiveErr := archive.Wait()
tarErr := tar.Wait()
if archiveErr != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("git archive wait: %w", archiveErr)
}
if tarErr != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("tar wait: %w", tarErr)
}
return dir, nil
}
func writeLogChunk(db *xorm.Engine, bus events.EventBus, runID, jobID, stepID int64, idx int, content string) {
entry := &models.PipelineStepLog{
StepID: stepID,
ChunkIndex: idx,
Content: content,
}
db.Insert(entry) //nolint:errcheck
bus.Publish(events.SubjectPipelineLog, events.LogChunkEvent{ //nolint:errcheck
RunID: runID, JobID: jobID, StepID: stepID, ChunkIndex: idx, Content: content,
})
}
func markStep(db *xorm.Engine, step *models.PipelineStep, status string, exitCode int) {
now := time.Now().UTC()
step.Status = status
step.ExitCode = exitCode
step.FinishedAt = &now
db.ID(step.ID).Cols("status", "exit_code", "finished_at").Update(step) //nolint:errcheck
}
func failJob(db *xorm.Engine, bus events.EventBus, jc JobContext, reason string) {
now := time.Now().UTC()
jc.Job.Status = "failed"
jc.Job.FinishedAt = &now
db.ID(jc.Job.ID).Cols("status", "finished_at").Update(&jc.Job) //nolint:errcheck
// Write the failure reason as a synthetic log line.
var lastStep models.PipelineStep
if found, _ := db.Where("job_id = ?", jc.Job.ID).Desc("seq").Get(&lastStep); found {
writeLogChunk(db, bus, jc.Run.ID, jc.Job.ID, lastStep.ID, 0,
"\n[ForgeBucket] Job failed: "+reason+"\n")
}
bus.Publish(events.SubjectJobFailed, events.JobEvent{ //nolint:errcheck
RunID: jc.Run.ID, JobID: jc.Job.ID, Status: "failed", At: now,
})
}
// workspaceDir returns the scratch directory root for CI job workspaces.
func workspaceDir(artifactRoot string) string {
return filepath.Join(filepath.Dir(artifactRoot), "ci-workspaces")
}
// IsDockerAvailable checks whether the docker CLI is reachable.
func IsDockerAvailable() bool {
cmd := exec.Command("docker", "info")
cmd.Env = []string{"HOME=/tmp"}
return cmd.Run() == nil
}
// stepsForJob loads PipelineStep rows for a job ordered by seq.
func stepsForJob(db *xorm.Engine, jobID int64) ([]models.PipelineStep, error) {
var steps []models.PipelineStep
err := db.Where("job_id = ?", jobID).Asc("seq").Find(&steps)
return steps, err
}
// repoForRun loads the Repository for a given run.
func repoForRun(db *xorm.Engine, runID int64) (models.Repository, models.PipelineRun, bool) {
var run models.PipelineRun
if found, _ := db.ID(runID).Get(&run); !found {
return models.Repository{}, run, false
}
var repo models.Repository
if found, _ := db.ID(run.RepoID).Get(&repo); !found {
return models.Repository{}, run, false
}
return repo, run, true
}
// buildJobContext assembles a JobContext from DB rows.
func buildJobContext(db *xorm.Engine, jobID int64) (JobContext, bool) {
var job models.PipelineJob
if found, _ := db.ID(jobID).Get(&job); !found {
return JobContext{}, false
}
repo, run, ok := repoForRun(db, job.RunID)
if !ok {
return JobContext{}, false
}
steps, err := stepsForJob(db, jobID)
if err != nil {
return JobContext{}, false
}
return JobContext{Run: run, Job: job, Steps: steps, Repo: repo}, true
}
// pipeForRun returns the longest-matching step label for an image.
// Phase 2B: unused placeholder for future label matching.
func pipeForRun(_ string) string { return "" }
// sanitizeImage prevents injection in docker image names.
func sanitizeImage(image string) string {
// Allow only characters valid in Docker image references.
var b strings.Builder
for _, c := range image {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' ||
c == '/' || c == ':' || c == '@' {
b.WriteRune(c)
}
}
return b.String()
}