repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/luci/internal/runner/runner.go
Rawpackage runner
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"bugabinga.net/luci/internal/artifact"
"bugabinga.net/luci/internal/buildctx"
"bugabinga.net/luci/internal/cache"
"bugabinga.net/luci/internal/ciconfig"
"bugabinga.net/luci/internal/config"
"bugabinga.net/luci/internal/gitrepo"
"bugabinga.net/luci/internal/history"
"bugabinga.net/luci/internal/lifecycle"
"bugabinga.net/luci/internal/logs"
"bugabinga.net/luci/internal/podman"
"bugabinga.net/luci/internal/publish"
"bugabinga.net/luci/internal/runstate"
"bugabinga.net/luci/internal/trigger"
)
type Runner struct {
Config config.Config
Executor podman.Executor
Publisher publish.Executor
Now func() time.Time
}
var defaultExecutor = func(socket string) podman.Executor { return podman.CLI{Socket: socket} }
var defaultPublisher = func(cfg config.Config) publish.Executor {
return publish.Service{
RegistryAuthFile: cfg.PublishRegistryAuthFile,
PkgRoot: cfg.PublishPkgRoot,
SiteRoot: cfg.PublishSiteRoot,
}
}
type plan struct {
repo string
parentJob string
repoPath string
rev string
ref string
trigger string
subject string
children []ciconfig.ExpandedJob
buildImages map[string]string
buildTags map[string]bool
}
type childResult struct {
status string
detail string
}
func (r Runner) Process(runID string, event trigger.Event) error {
return r.ProcessContext(context.Background(), runID, event)
}
// ProcessContext permits cancellation while waiting for durable job mounts.
func (r Runner) ProcessContext(ctx context.Context, runID string, event trigger.Event) error {
lock, err := lifecycle.TryLockRun(r.Config.DataDir, runID, true)
if err != nil {
return err
}
defer lock.Close()
deleted, err := lifecycle.Deleted(r.Config.DataDir, runID)
if err != nil {
return err
}
if deleted {
return nil
}
completed, err := r.history().Contains(runID, "run")
if err != nil {
return err
}
if completed {
return nil
}
switch event.(type) {
case trigger.ManualEvent, trigger.PushEvent, trigger.ScheduleEvent:
default:
return fmt.Errorf("unsupported event %T", event)
}
started := r.now()
p, err := r.makePlan(event, runID)
defer os.RemoveAll(filepath.Join(r.Config.DataDir, "workspaces", p.repo, runID))
if err != nil {
if p.parentJob == "" {
p = eventPlan(event)
}
_, appendErr := r.history().AppendOnce(history.Event{RunID: runID, ChildID: "run", Repo: p.repo, Job: p.parentJob, Rev: p.rev, Ref: p.ref, Trigger: p.trigger, Status: "failed", Subject: p.subject, Detail: err.Error(), StartedAt: started, Time: r.now()})
return appendErr
}
p.buildImages = make(map[string]string)
p.buildTags = make(map[string]bool)
defer r.finishBuiltImages(p.buildTags)
failed := false
for i, child := range p.children {
childID := fmt.Sprintf("%03d", i+1)
if failed {
_, err := r.history().AppendOnce(history.Event{RunID: runID, ChildID: childID, Repo: p.repo, Job: child.Name, Rev: p.rev, Ref: p.ref, Trigger: p.trigger, Status: "skipped", Detail: "skipped after earlier failure", Matrix: child.Values, Time: r.now()})
if err != nil {
return err
}
continue
}
result, err := r.runExpandedContext(ctx, runID, childID, p, child)
if err != nil {
return err
}
failed = result.status == "failed"
}
status := "success"
if failed {
status = "failed"
}
_, err = r.history().AppendOnce(history.Event{RunID: runID, ChildID: "run", Repo: p.repo, Job: p.parentJob, Rev: p.rev, Ref: p.ref, Trigger: p.trigger, Status: status, Subject: p.subject, StartedAt: started, Time: r.now()})
return err
}
func (r Runner) makePlan(event trigger.Event, runID string) (plan, error) {
switch event := event.(type) {
case trigger.ManualEvent:
return r.manualPlan(event, runID)
case trigger.PushEvent:
return r.pushPlan(event, runID)
case trigger.ScheduleEvent:
return r.schedulePlan(event, runID)
default:
return plan{}, fmt.Errorf("unsupported event %T", event)
}
}
// AdmitManual resolves a manual request and validates its selected job before it enters the inbox.
func AdmitManual(cfg config.Config, event trigger.ManualEvent) (trigger.ManualEvent, error) {
if err := event.Validate(); err != nil {
return trigger.ManualEvent{}, err
}
repoPath, err := gitrepo.RepoPath(cfg.RepoRoots, event.Repo)
if err != nil {
return trigger.ManualEvent{}, err
}
ref, rev := "", ""
if event.Ref != nil {
ref = *event.Ref
}
if event.Rev != nil {
rev = *event.Rev
}
resolved, err := gitrepo.Resolve(repoPath, ref, rev)
if err != nil {
return trigger.ManualEvent{}, err
}
files, err := gitrepo.FilesAtRev(repoPath, resolved, ".ci")
if err != nil {
return trigger.ManualEvent{}, err
}
for path := range files {
if !strings.HasSuffix(path, ".kdl") {
delete(files, path)
}
}
repository, err := ciconfig.ParseFiles(files)
if err != nil {
return trigger.ManualEvent{}, err
}
if _, ok := repository.Job(event.Job); !ok {
return trigger.ManualEvent{}, fmt.Errorf("job %q not found", event.Job)
}
event.ResolvedRev = resolved
return event, nil
}
func (r Runner) manualPlan(event trigger.ManualEvent, runID string) (plan, error) {
p := eventPlan(event)
if p.rev == "" {
return p, fmt.Errorf("manual event was not admitted; resubmit the run")
}
repoPath, err := gitrepo.RepoPath(r.Config.RepoRoots, event.Repo)
if err != nil {
return p, err
}
p.repoPath = repoPath
p.subject = gitrepo.Subject(repoPath, p.rev)
cfg, _, err := r.loadRepoConfig(repoPath, p.rev, event.Repo, runID)
if err != nil {
return p, err
}
job, ok := cfg.Job(event.Job)
if !ok {
return p, fmt.Errorf("job %q not found", event.Job)
}
p.children = job.ExpandMatrix()
return p, nil
}
func (r Runner) schedulePlan(event trigger.ScheduleEvent, runID string) (plan, error) {
p := eventPlan(event)
repoPath, err := gitrepo.RepoPath(r.Config.RepoRoots, event.Repo)
if err != nil {
return p, err
}
resolved, err := gitrepo.Resolve(repoPath, "", event.Rev)
if err != nil {
return p, err
}
p.repoPath, p.rev = repoPath, resolved
p.subject = gitrepo.Subject(repoPath, resolved)
cfg, _, err := r.loadRepoConfig(repoPath, resolved, event.Repo, runID)
if err != nil {
return p, err
}
job, ok := cfg.Job(event.Job)
if !ok {
return p, fmt.Errorf("job %q not found", event.Job)
}
p.children = job.ExpandMatrix()
return p, nil
}
func (r Runner) pushPlan(event trigger.PushEvent, runID string) (plan, error) {
p := eventPlan(event)
requestedRev := p.rev
p.rev = ""
repoPath, err := gitrepo.RepoPath(r.Config.RepoRoots, event.Repo)
if err != nil {
return p, err
}
resolved, err := gitrepo.Resolve(repoPath, "", requestedRev)
if err != nil {
p.rev = ""
return p, err
}
p.repoPath, p.rev = repoPath, resolved
p.subject = gitrepo.Subject(repoPath, resolved)
cfg, _, err := r.loadRepoConfig(repoPath, resolved, event.Repo, runID)
if err != nil {
return p, err
}
var changedPaths []string
for _, job := range cfg.Jobs {
if job.NeedsChangedPaths() {
changedPaths, err = gitrepo.ChangedPaths(repoPath, event.Old, resolved)
if err != nil {
return p, err
}
break
}
}
for _, job := range cfg.Jobs {
if job.MatchesPush(event.Ref, changedPaths) {
p.children = append(p.children, job.ExpandMatrix()...)
}
}
return p, nil
}
func eventPlan(event trigger.Event) plan {
switch event := event.(type) {
case trigger.ManualEvent:
p := plan{repo: event.Repo, parentJob: event.Job, rev: event.ResolvedRev, trigger: "manual"}
if event.Ref != nil {
p.ref = *event.Ref
}
return p
case trigger.PushEvent:
return plan{repo: event.Repo, parentJob: "push", rev: event.New, ref: event.Ref, trigger: "push"}
case trigger.ScheduleEvent:
return plan{repo: event.Repo, parentJob: event.Job, rev: event.Rev, ref: event.Ref, trigger: "schedule"}
default:
return plan{parentJob: "run", trigger: "unknown"}
}
}
func (r Runner) loadRepoConfig(repoPath, rev, repo, runID string) (ciconfig.Config, string, error) {
workspace := filepath.Join(r.Config.DataDir, "workspaces", repo, runID, "config")
if err := gitrepo.Checkout(repoPath, rev, workspace); err != nil {
return ciconfig.Config{}, "", err
}
cfg, err := ciconfig.Load(workspace)
return cfg, workspace, err
}
func (r Runner) runExpanded(runID, childID string, p plan, expanded ciconfig.ExpandedJob) (childResult, error) {
return r.runExpandedContext(context.Background(), runID, childID, p, expanded)
}
func (r Runner) runExpandedContext(ctx context.Context, runID, childID string, p plan, expanded ciconfig.ExpandedJob) (childResult, error) {
started := r.now()
activeStore := runstate.Store{DataDir: r.Config.DataDir}
if err := activeStore.Write(runstate.Active{RunID: runID, ChildID: childID, Repo: p.repo, Job: expanded.Name, Rev: p.rev, Ref: p.ref, Trigger: p.trigger, StartedAt: started}); err != nil {
return childResult{}, err
}
logName := runID + "-" + childID
logStore := logs.Store{DataDir: r.Config.DataDir}
logFile, err := logStore.CreateLive(logName)
if err != nil {
return childResult{}, err
}
workspace := filepath.Join(r.Config.DataDir, "workspaces", p.repo, runID, childID)
var logWriter *logs.Sanitizer
var logErr error
runErr := gitrepo.Checkout(p.repoPath, p.rev, workspace)
if runErr == nil {
runErr = registryOutputsAbsent(workspace, expanded)
}
image := ciconfig.Interpolate(expanded.Job.Image, expanded.Values)
runCtx, cancel := context.WithTimeout(ctx, r.Config.DefaultTimeout)
defer cancel()
if runErr == nil && expanded.Job.Build != nil {
logWriter = logs.NewBoundedSanitizer(logFile, nil)
image, runErr = r.buildImage(runCtx, p, expanded, workspace, runID, childID, logWriter)
}
if runErr == nil {
var secretValues [][]byte
var secretMounts []podman.Mount
var envFile string
var removeSecrets func()
secretMounts, envFile, secretValues, removeSecrets, runErr = r.materializeSecrets(p.repo, expanded.Job, runID, childID)
defer removeSecrets()
logWriter = logs.NewBoundedSanitizer(logFile, secretValues)
if runErr == nil {
env, envErr := frozenEnvironment(runID, childID, p, expanded, nil)
if envErr != nil {
runErr = envErr
}
resolvedCaches := make([]ciconfig.Cache, len(expanded.Job.Caches))
cacheKeys := make([]string, len(expanded.Job.Caches))
cacheStore := cache.Store{DataDir: r.Config.DataDir, MaxVersions: r.Config.CacheMaxVersions, MaxBytes: r.Config.CacheMaxBytes}
for i, entry := range expanded.Job.Caches {
if runErr != nil {
break
}
entry.Path = ciconfig.Interpolate(entry.Path, expanded.Values)
entry.KeyFiles = append([]string(nil), entry.KeyFiles...)
for j := range entry.KeyFiles {
entry.KeyFiles[j] = ciconfig.Interpolate(entry.KeyFiles[j], expanded.Values)
}
resolvedCaches[i] = entry
cacheKeys[i], runErr = cache.Key(workspace, entry)
if runErr == nil {
var outcome string
outcome, runErr = cacheStore.Restore(p.repo, expanded.Job.Name, entry, cacheKeys[i], workspace)
if runErr == nil {
_, runErr = fmt.Fprintf(logWriter, "[luci] cache %s: %s\n", entry.Name, outcome)
if runErr != nil {
logErr = errors.Join(logErr, runErr)
}
}
}
if runErr != nil {
break
}
}
if runErr == nil {
commands := make([]string, 0, len(expanded.Job.Run))
for _, command := range expanded.Job.Run {
commands = append(commands, ciconfig.Interpolate(command, expanded.Values))
}
if r.Executor == nil {
r.Executor = defaultExecutor(r.Config.PodmanSocket)
}
mounts, releaseMounts, mountErr := durableMounts(runCtx, r.Config.DataDir, p.repo, expanded.Job.Name, expanded.Job.Mounts)
if mountErr != nil {
runErr = mountErr
} else {
defer releaseMounts()
spec := podman.Spec{Image: image, Pull: pullPolicy(expanded.Job), Workspace: workspace, Memory: r.Config.DefaultMemory, CPUs: r.Config.DefaultCPUs, Env: env, EnvFile: envFile, Mounts: append(mounts, secretMounts...), Commands: commands, Timeout: r.Config.DefaultTimeout}
args := podman.BuildArgs(spec)
if executor, ok := r.Executor.(podman.EnvironmentExecutor); ok {
runErr = executor.RunWithEnv(runCtx, args, podman.Environment(spec), logWriter, logWriter)
} else {
runErr = r.Executor.Run(runCtx, args, logWriter, logWriter)
}
}
}
if runErr == nil {
for i, entry := range resolvedCaches {
if runErr = cacheStore.Save(p.repo, expanded.Job.Name, entry, cacheKeys[i], workspace); runErr != nil {
break
}
}
}
if runErr == nil && len(expanded.Job.Publishes) > 0 {
if r.Publisher == nil {
r.Publisher = defaultPublisher(r.Config)
}
values := make(map[string]string, len(expanded.Values)+1)
for key, value := range expanded.Values {
values[key] = value
}
values["rev"] = p.rev
for _, declaration := range expanded.Job.Publishes {
runErr = r.Publisher.Publish(runCtx, publish.Request{Adapter: declaration.Adapter, Image: ciconfig.Interpolate(declaration.Image, values), From: ciconfig.Interpolate(declaration.From, values), To: ciconfig.Interpolate(declaration.To, values), Workspace: workspace}, logWriter)
if runErr != nil {
break
}
}
}
artifactStore := artifact.Store{DataDir: r.Config.DataDir}
for _, declaration := range expanded.Job.Artifacts {
if runErr != nil && !declaration.AfterFailure {
continue
}
if err := artifactStore.Collect(workspace, runID, childID, declaration); err != nil {
runErr = errors.Join(runErr, err)
}
}
}
}
if logWriter == nil {
logWriter = logs.NewBoundedSanitizer(logFile, nil)
}
if runErr != nil {
if _, err := fmt.Fprintln(logWriter, runErr); err != nil {
logErr = errors.Join(logErr, err)
}
}
if err := logWriter.Close(); err != nil {
logErr = errors.Join(logErr, err)
}
if err := logFile.Close(); err != nil {
logErr = errors.Join(logErr, err)
}
if _, err := logStore.Finalize(logName); err != nil {
return childResult{}, errors.Join(logErr, err)
}
if logErr != nil {
return childResult{}, logErr
}
result := childResult{status: "success"}
if runErr != nil {
result.status, result.detail = "failed", runErr.Error()
}
_, err = r.history().AppendOnce(history.Event{RunID: runID, ChildID: childID, Repo: p.repo, Job: expanded.Name, Rev: p.rev, Ref: p.ref, Trigger: p.trigger, Status: result.status, Subject: p.subject, Detail: result.detail, Matrix: expanded.Values, StartedAt: started, Time: r.now()})
if err != nil {
return childResult{}, err
}
if err := activeStore.Remove(runID); err != nil {
return childResult{}, err
}
return result, nil
}
func registryOutputsAbsent(workspace string, expanded ciconfig.ExpandedJob) error {
for _, declaration := range expanded.Job.Publishes {
if declaration.Adapter != "registry" {
continue
}
from := ciconfig.Interpolate(declaration.From, expanded.Values)
clean := filepath.Clean(from)
if strings.TrimSpace(from) == "" || filepath.IsAbs(from) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return fmt.Errorf("publish registry has unsafe from path %q", from)
}
for _, cache := range expanded.Job.Caches {
path := ciconfig.Interpolate(cache.Path, expanded.Values)
if pathsOverlap(clean, filepath.Clean(path)) {
return fmt.Errorf("publish registry from %q conflicts with cache path %q", from, path)
}
}
for _, artifact := range expanded.Job.Artifacts {
for _, path := range artifact.Paths {
if pathsOverlap(clean, filepath.Clean(path)) {
return fmt.Errorf("publish registry from %q conflicts with artifact path %q", from, path)
}
}
}
if _, err := os.Lstat(filepath.Join(workspace, clean)); err == nil {
return fmt.Errorf("publish registry output %q already exists before commands", from)
} else if !os.IsNotExist(err) {
return err
}
}
return nil
}
func pathsOverlap(left, right string) bool {
return left == right || strings.HasPrefix(left, right+string(filepath.Separator)) || strings.HasPrefix(right, left+string(filepath.Separator))
}
func (r *Runner) buildImage(ctx context.Context, p plan, expanded ciconfig.ExpandedJob, workspace, runID, childID string, output io.Writer) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
build := *expanded.Job.Build
build.Containerfile = ciconfig.Interpolate(build.Containerfile, expanded.Values)
build.Context = ciconfig.Interpolate(build.Context, expanded.Values)
key := p.rev + "\x00" + build.Containerfile + "\x00" + build.Context
if image := p.buildImages[key]; image != "" {
fmt.Fprintf(output, "[luci] build reused image=%s\n", image)
return image, nil
}
if err := r.prepareBuildStorage(p, output); err != nil {
return "", err
}
root := filepath.Join(r.Config.DataDir, "build-context")
if err := os.MkdirAll(root, 0o700); err != nil {
return "", err
}
stage, err := os.MkdirTemp(root, "build-")
if err != nil {
return "", err
}
defer os.RemoveAll(stage)
containerfile, contextDir, err := buildctx.Stage(workspace, build.Containerfile, build.Context, stage, r.Config.BuildContextMaxBytes, r.Config.BuildContextMaxFiles)
if err != nil {
return "", err
}
if err := safeContainerfile(containerfile); err != nil {
return "", err
}
iid := filepath.Join(stage, "iid")
authFile := filepath.Join(stage, "auth.json")
if err := os.WriteFile(authFile, []byte(`{"auths":{}}`), 0o600); err != nil {
return "", err
}
tag := "luci-build-" + runID + "-" + childID
args, err := podman.BuildArgsForContainerfile(podman.BuildSpec{Containerfile: containerfile, Context: contextDir, Tag: tag, IIDFile: iid, AuthFile: authFile, Memory: r.Config.DefaultMemory, CPUs: r.Config.DefaultCPUs})
if err != nil {
return "", err
}
if p.buildTags == nil {
p.buildTags = make(map[string]bool)
}
p.buildTags[tag] = true
fmt.Fprintf(output, "[luci] build start containerfile=%s context=%s\n", build.Containerfile, build.Context)
buildStarted := time.Now()
executor := r.executor()
if environmentExecutor, ok := executor.(podman.EnvironmentExecutor); ok {
err = environmentExecutor.RunWithEnv(ctx, args, nil, output, output)
} else {
err = executor.Run(ctx, args, output, output)
}
if err != nil {
return "", fmt.Errorf("build %s failed after %s: %w", build.Containerfile, time.Since(buildStarted).Round(time.Second), err)
}
data, err := os.ReadFile(iid)
if err != nil {
return "", fmt.Errorf("build did not write image ID: %w", err)
}
image := strings.TrimSpace(string(data))
if !immutableImageID(image) {
return "", fmt.Errorf("build returned invalid image ID %q", image)
}
if err := r.verifyLuciBuildImage(image); err != nil {
return "", err
}
p.buildImages[key] = image
fmt.Fprintf(output, "[luci] build done image=%s in %s\n", image, time.Since(buildStarted).Round(time.Second))
return image, nil
}
func (r *Runner) executor() podman.Executor {
if r.Executor == nil {
r.Executor = defaultExecutor(r.Config.PodmanSocket)
}
return r.Executor
}
func (r *Runner) finishBuiltImages(tags map[string]bool) {
if len(tags) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), r.Config.BuildCleanupTimeout)
defer cancel()
executor := r.executor()
for tag := range tags {
// A tag, unlike an image ID, removes only this run's unique reference.
// Podman receives no --force, so it preserves any active container.
_ = executor.Run(ctx, podman.RemoveImageArgs(tag), io.Discard, io.Discard)
}
_, _ = podman.PruneLuciBuildCache(ctx, executor, r.Config.BuildCacheMaxBytes, r.Config.BuildCacheMaxAge, nil, r.now())
}
func (r *Runner) prepareBuildStorage(p plan, output io.Writer) error {
ctx, cancel := context.WithTimeout(context.Background(), r.Config.BuildCleanupTimeout)
defer cancel()
protected := make(map[string]bool, len(p.buildImages))
for _, image := range p.buildImages {
protected[image] = true
}
report, err := podman.PruneLuciBuildCache(ctx, r.executor(), r.Config.BuildCacheMaxBytes, r.Config.BuildCacheMaxAge, protected, r.now())
if err != nil {
return fmt.Errorf("build cache retention: %w", err)
}
info, err := podman.StorageInfoFor(ctx, r.executor())
if err != nil {
return fmt.Errorf("build storage preflight: %w", err)
}
if info.Free() < r.Config.BuildMinFreeBytes {
return fmt.Errorf("build requires %d free bytes on Podman graphroot; engine reports %d free of %d; free Podman storage or raise LUCI_BUILD_MIN_FREE_BYTES", r.Config.BuildMinFreeBytes, info.Free(), info.Allocated)
}
_, _ = fmt.Fprintf(output, "[luci] build cache bytes=%d removed=%d graphroot-free=%d\n", report.Bytes, len(report.Removed), info.Free())
return nil
}
func (r *Runner) verifyLuciBuildImage(id string) error {
ctx, cancel := context.WithTimeout(context.Background(), r.Config.BuildCleanupTimeout)
defer cancel()
images, err := podman.LuciBuildImages(ctx, r.executor())
if err != nil {
return fmt.Errorf("verify Luci build label: %w", err)
}
for _, image := range images {
if sameImageID(image.ID, id) {
return nil
}
}
return fmt.Errorf("build output %s lacks required %s label; Containerfile must not override it", id, podman.BuildCacheLabel)
}
func sameImageID(left, right string) bool {
return strings.TrimPrefix(left, "sha256:") == strings.TrimPrefix(right, "sha256:")
}
func pullPolicy(job ciconfig.Job) string {
if job.Build != nil {
return "never"
}
return ""
}
func immutableImageID(value string) bool {
if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 {
return false
}
for _, char := range value[len("sha256:"):] {
if !strings.ContainsRune("0123456789abcdef", char) {
return false
}
}
return true
}
// safeContainerfile rejects only RUN options that alter Buildah's execution
// boundary. It deliberately accepts ordinary shell text, comments, package
// installation, and every other normal Containerfile instruction.
func safeContainerfile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
escape := byte('\\')
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "# escape=") {
value := strings.TrimSpace(trimmed[len("# escape="):])
if len(value) == 1 && (value[0] == '\\' || value[0] == '`') {
escape = value[0]
}
break
}
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
break
}
}
for lineNo := 0; lineNo < len(lines); lineNo++ {
line := strings.TrimSpace(lines[lineNo])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
logical := line
for continued(logical, escape) && lineNo+1 < len(lines) {
// Containerfile continuation removes the escape and newline. Preserve
// the author-supplied space before the escape, but do not add one:
// --net\ followed by work=host is still --network=host.
logical = strings.TrimSuffix(strings.TrimRight(logical, " \t"), string(escape)) + strings.TrimLeft(lines[lineNo+1], " \t")
lineNo++
}
instructionEnd := strings.IndexAny(logical, " \t")
if instructionEnd < 0 || !strings.EqualFold(logical[:instructionEnd], "RUN") {
continue
}
body := logical[instructionEnd:]
for {
body = strings.TrimLeft(body, " \t")
if !strings.HasPrefix(body, "--") {
break
}
optionEnd := strings.IndexAny(body, " \t")
option, rest, found := body, "", optionEnd >= 0
if found {
option, rest = body[:optionEnd], body[optionEnd:]
}
name, _, _ := strings.Cut(strings.TrimPrefix(option, "--"), "=")
switch strings.ToLower(name) {
case "mount", "network", "security", "device":
return fmt.Errorf("containerfile line %d uses unsupported RUN --%s extension", lineNo+1, name)
}
if !found {
break
}
body = rest
}
}
return nil
}
func continued(line string, escape byte) bool {
trimmed := strings.TrimRight(line, " \t")
count := 0
for i := len(trimmed) - 1; i >= 0 && trimmed[i] == escape; i-- {
count++
}
return count%2 == 1
}
func (r Runner) history() history.Store { return history.Store{DataDir: r.Config.DataDir, Now: r.Now} }
func (r Runner) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func frozenEnvironment(runID, childID string, p plan, expanded ciconfig.ExpandedJob, secrets map[string]string) (map[string]string, error) {
env := map[string]string{
"CI_RUN_ID": runID,
"CI_CHILD_ID": childID,
"CI_REPO": p.repo,
"CI_JOB": expanded.Name,
"CI_REV": p.rev,
"CI_REF": p.ref,
"CI_TRIGGER": p.trigger,
}
for key, value := range expanded.Values {
name := "CI_MATRIX_" + strings.ToUpper(key)
if _, exists := env[name]; exists {
return nil, fmt.Errorf("matrix env collision %q", name)
}
env[name] = value
}
for name, value := range secrets {
if _, exists := env[name]; exists {
return nil, fmt.Errorf("secret env %q collides with CI metadata", name)
}
env[name] = value
}
return env, nil
}
func (r Runner) materializeSecrets(repo string, job ciconfig.Job, runID, childID string) ([]podman.Mount, string, [][]byte, func(), error) {
paths, err := job.SecretPaths(r.Config.DataDir, repo)
if err != nil {
return nil, "", nil, func() {}, err
}
tmpRoot := filepath.Join(r.Config.DataDir, "tmp")
if err := os.MkdirAll(tmpRoot, 0o700); err != nil {
return nil, "", nil, func() {}, err
}
dir, err := os.MkdirTemp(tmpRoot, "secrets-"+runID+"-"+childID+"-")
if err != nil {
return nil, "", nil, func() {}, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
var mounts []podman.Mount
var values [][]byte
var envLines []string
for _, secret := range job.Secrets {
data, err := os.ReadFile(paths[secret.Name])
if err != nil {
cleanup()
return nil, "", nil, func() {}, fmt.Errorf("missing secret %s", secret.Name)
}
if len(data) > 0 {
values = append(values, data)
}
if secret.Env != "" {
value := strings.TrimRight(string(data), "\r\n")
if strings.ContainsAny(value, "\r\n") {
cleanup()
return nil, "", nil, func() {}, fmt.Errorf("secret %s cannot be an environment value containing a newline", secret.Name)
}
envLines = append(envLines, secret.Env+"="+value)
}
if secret.File != "" {
file := filepath.Join(dir, secret.Name)
if err := os.WriteFile(file, data, 0o600); err != nil {
cleanup()
return nil, "", nil, func() {}, err
}
mounts = append(mounts, podman.Mount{Source: file, Target: "/work/" + filepath.ToSlash(secret.File), ReadOnly: true})
}
}
envFile := ""
if len(envLines) > 0 {
envFile = filepath.Join(dir, "env")
if err := os.WriteFile(envFile, []byte(strings.Join(envLines, "\n")+"\n"), 0o600); err != nil {
cleanup()
return nil, "", nil, func() {}, err
}
}
return mounts, envFile, values, cleanup, nil
}
func Drain(events []trigger.Event, writer io.Writer) {
for _, event := range events {
fmt.Fprintln(writer, event.EventKind())
}
}