package nix import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "syscall" "time" "tangled.org/core/api/tangled" "tangled.org/core/spindle/config" "tangled.org/core/spindle/engine" "tangled.org/core/spindle/models" "tangled.org/core/spindle/secrets" ) // eval json is buffered whole before parsing, so it needs a hard ceiling, // a hostile flake must not oom the executor through stdout const maxEvalOutputBytes = 32 << 20 // paths inside the bwrap namespace, populated by symlink in baseBwrapArgs const ( containerNix = "/usr/bin/nix" containerBash = "/usr/bin/bash" containerPrlimit = "/usr/bin/prlimit" ) type Engine struct { cfg *config.Config slotter *engine.SemaphoreSlotter nixBinPath string bashBinPath string gitBinPath string prlimitBinPath string bwrapBinPath string workspaces sync.Map // keyed by full models.WorkflowId } // per-workflow host state, workspaceDir is bound at /workdir and homeDir at // /home inside the sandbox type addlFields struct { workspaceDir string homeDir string } type Step struct { name string command string drvPath string kind models.StepKind isDiscovery bool } func (s Step) Name() string { return s.name } func (s Step) Command() string { return s.command } func (s Step) Kind() models.StepKind { return s.kind } // targetKind names the shape of a flake output without encoding it in an attr // path string type targetKind int const ( targetSystemOutput targetKind = iota // packages/checks/devShells.. targetDirect // formatter/defaultPackage/devShell. targetHomeActivation // homeConfigurations..activationPackage targetToplevel // nixos|darwinConfigurations..config.system.build.toplevel ) // the installable and --apply expression resolving a candidate's drvPath. // the installable is a parent attrset built from trusted constants and the // validated currentSystem, only the apply lambda sees the output name, as a // nixStringLit literal looked up with builtins.getAttr func (c outputCandidate) drvTarget() (installable, apply string, err error) { switch c.kind { case targetSystemOutput: installable, err = parentInstallable(c.category, c.system) apply = fmt.Sprintf(systemOutputDrvApply, nixStringLit(c.name)) case targetDirect: installable, err = parentInstallable(c.category, c.system) apply = directDrvApply case targetHomeActivation: installable = ".#homeConfigurations" apply = fmt.Sprintf(homeActivationDrvApply, nixStringLit(c.name)) case targetToplevel: installable = ".#" + c.category apply = fmt.Sprintf(toplevelDrvApply, nixStringLit(c.name)) default: return "", "", fmt.Errorf("unknown candidate kind %d for %s", c.kind, c.display()) } if err != nil { return "", "", err } return installable, apply, nil } // too many outputs is a resource attack, refuse outright func checkOutputLimit(count, max int) error { if count > max { return fmt.Errorf("flake exposes %d candidate outputs, over the limit of %d", count, max) } return nil } // a discovered buildable, kept structured end to end: category/system/name go // to nix as separate string literals, never joined into an attr path type outputCandidate struct { kind targetKind category string system string name string } // display is for humans only, nothing parses this back func (c outputCandidate) display() string { switch c.kind { case targetSystemOutput: return fmt.Sprintf(".#%s.%s.%s", c.category, c.system, c.name) case targetDirect: return fmt.Sprintf(".#%s.%s", c.category, c.system) case targetHomeActivation: return fmt.Sprintf(".#homeConfigurations.%s.activationPackage", c.name) case targetToplevel: return fmt.Sprintf(".#%s.%s.config.system.build.toplevel", c.category, c.name) default: return ".#" } } func New(cfg *config.Config) (*Engine, error) { // every one of these is required inside the sandbox, fail fast at startup // rather than mid-pipeline bwrapPath, err := executablePath("bwrap") if err != nil { return nil, err } nixPath, err := executablePath("nix") if err != nil { return nil, err } bashPath, err := executablePath("bash") if err != nil { return nil, err } gitPath, err := executablePath("git") if err != nil { return nil, err } prlimitPath, err := executablePath("prlimit") if err != nil { return nil, err } return &Engine{ cfg: cfg, slotter: engine.NewSemaphoreSlotter(cfg.NixPipelines.MaxConcurrentWorkflows), nixBinPath: nixPath, bashBinPath: bashPath, gitBinPath: gitPath, prlimitBinPath: prlimitPath, bwrapBinPath: bwrapPath, }, nil } // resolves to the real binary so the sandbox symlinks survive any wrapper // symlinks on the host PATH func executablePath(name string) (string, error) { path, err := exec.LookPath(name) if err != nil { return "", fmt.Errorf("%s executable not found: %w", name, err) } if resolved, err := filepath.EvalSymlinks(path); err == nil { path = resolved } return path, nil } func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { // the nix engine owns the step list, so the only valid manifest keys are // the generic workflow ones (engine/when/clone), anything else, including // user steps, is a structural error if err := engine.DescribeManifestError(twf.Raw, struct{}{}); err != nil { return nil, err } wf := &models.Workflow{Name: twf.Name, Data: addlFields{}} if tpl.TriggerMetadata != nil { if cloneStep := models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev); cloneStep.Command() != "" { wf.Steps = append(wf.Steps, cloneStep) } } wf.Steps = append(wf.Steps, Step{ name: "Evaluate flake outputs", command: "nix flake metadata --json .; nix eval --apply --json", kind: models.StepKindSystem, isDiscovery: true, }) return wf, nil } func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) { return e.slotter.AcquireWorkflowSlot(ctx, wid, wf) } func (e *Engine) SetupWorkflow(_ context.Context, wid models.WorkflowId, wf *models.Workflow, _ models.WorkflowLogger) error { workDirBase := e.cfg.NixPipelines.WorkDirBase if workDirBase == "" { workDirBase = os.TempDir() } workspaceDir, err := os.MkdirTemp(workDirBase, "spindle-nix-workspace-"+wid.String()+"-*") if err != nil { return fmt.Errorf("creating host workspace directory: %w", err) } homeDir, err := os.MkdirTemp(workDirBase, "spindle-nix-home-"+wid.String()+"-*") if err != nil { os.RemoveAll(workspaceDir) return fmt.Errorf("creating host home directory: %w", err) } homeTmpDir := filepath.Join(homeDir, "tmp") if err := os.MkdirAll(homeTmpDir, 0o755); err != nil { os.RemoveAll(workspaceDir) os.RemoveAll(homeDir) return fmt.Errorf("creating host home tmp directory: %w", err) } // the sandbox runs as the configured unprivileged user, so hand it // ownership of its writable dirs when we have the power to if os.Geteuid() == 0 { uid := e.cfg.NixPipelines.SandboxUid gid := e.cfg.NixPipelines.SandboxGid for _, dir := range []string{workspaceDir, homeDir, homeTmpDir} { if err := os.Chown(dir, uid, gid); err != nil { os.RemoveAll(workspaceDir) os.RemoveAll(homeDir) return fmt.Errorf("chowning %s to sandbox uid/gid: %w", dir, err) } } } addl := addlFields{workspaceDir: workspaceDir, homeDir: homeDir} wf.Data = addl e.workspaces.Store(wid, addl) return nil } func (e *Engine) WorkflowTimeout() time.Duration { if e.cfg.NixPipelines.WorkflowTimeout > 0 { return e.cfg.NixPipelines.WorkflowTimeout } return 5 * time.Minute } func (e *Engine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error { val, ok := e.workspaces.LoadAndDelete(wid) if !ok { // nothing stored: setup never ran or destroy already happened return nil } addl := val.(addlFields) return errors.Join(os.RemoveAll(addl.workspaceDir), os.RemoveAll(addl.homeDir)) } // the entire environment the sandboxed process sees, nothing leaks in from // the executor func (e *Engine) buildEnv() []string { return []string{ "HOME=/home", "TMPDIR=/home/tmp", "NIX_REMOTE=daemon", "PATH=/usr/bin:/bin", } } func (e *Engine) baseBwrapArgs(addl addlFields) []string { args := []string{ "--die-with-parent", "--new-session", "--unshare-all", "--share-net", // builds fetch from the network, the nix daemon gates what matters "--ro-bind", "/nix/store", "/nix/store", } // evaluation and builds both talk to the host nix daemon if _, err := os.Stat("/nix/var/nix/daemon-socket"); err == nil { args = append(args, "--ro-bind", "/nix/var/nix/daemon-socket", "/nix/var/nix/daemon-socket") } args = append(args, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--bind", addl.workspaceDir, "/workdir", "--bind", addl.homeDir, "/home", "--dir", "/etc", ) // /etc stays synthetic: only the resolved files networking/TLS actually // need, bound one at a time. no blanket /etc, no /run for _, f := range []string{ "/etc/resolv.conf", "/etc/nsswitch.conf", "/etc/hosts", "/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs/ca-bundle.crt", } { resolved, err := filepath.EvalSymlinks(f) if err != nil { continue } if _, err := os.Stat(resolved); err != nil { continue } if dir := filepath.Dir(f); dir != "/etc" && dir != "/" { args = append(args, "--dir", dir) } args = append(args, "--ro-bind", resolved, f) } args = append(args, "--dir", "/usr", "--dir", "/usr/bin", "--dir", "/bin", "--symlink", e.nixBinPath, containerNix, "--symlink", e.bashBinPath, containerBash, "--symlink", e.bashBinPath, "/bin/sh", "--symlink", e.gitBinPath, "/usr/bin/git", "--symlink", e.prlimitBinPath, containerPrlimit, "--chdir", "/workdir", "--", ) return args } // shared stdout+stderr budget, whichever stream blows the limit truncates the // output and kills the child type maxLogWriter struct { mu sync.Mutex written int64 limit int64 cancel context.CancelFunc exceeded bool } type limitedStreamWriter struct { parent *maxLogWriter out io.Writer } func (w *limitedStreamWriter) Write(p []byte) (int, error) { return w.parent.write(w.out, p) } func (mw *maxLogWriter) write(w io.Writer, p []byte) (int, error) { mw.mu.Lock() if mw.exceeded { mw.mu.Unlock() return 0, fmt.Errorf("log output exceeded the %d byte limit", mw.limit) } if mw.limit > 0 { if remaining := mw.limit - mw.written; int64(len(p)) > remaining { mw.exceeded = true if mw.cancel != nil { mw.cancel() } mw.written = mw.limit var writeErr error if remaining > 0 && w != nil { _, writeErr = w.Write(p[:remaining]) } mw.mu.Unlock() if writeErr != nil { return int(remaining), fmt.Errorf("log output exceeded the %d byte limit: %w", mw.limit, writeErr) } return int(remaining), fmt.Errorf("log output exceeded the %d byte limit", mw.limit) } } mw.written += int64(len(p)) mw.mu.Unlock() if w != nil { return w.Write(p) } return len(p), nil } func (e *Engine) maxLogBytes() int64 { if e.cfg == nil { return 0 } return e.cfg.NixPipelines.MaxLogBytes } func (e *Engine) maxEvalMemoryBytes() int64 { if e.cfg != nil && e.cfg.NixPipelines.MaxEvalMemoryBytes > 0 { return e.cfg.NixPipelines.MaxEvalMemoryBytes } return 8 << 30 } func (e *Engine) maxOutputs() int { if e.cfg != nil && e.cfg.NixPipelines.MaxOutputs > 0 { return e.cfg.NixPipelines.MaxOutputs } return 100 } // newSandboxCmd assembles bwrap with the sandbox argv, the returned command // runs as the configured unprivileged uid/gid when we're root func (e *Engine) newSandboxCmd(ctx context.Context, addl addlFields, executable string, args []string) *exec.Cmd { bwrapArgs := append(e.baseBwrapArgs(addl), executable) bwrapArgs = append(bwrapArgs, args...) cmd := exec.CommandContext(ctx, e.bwrapBinPath, bwrapArgs...) cmd.Env = e.buildEnv() if os.Geteuid() == 0 && e.cfg != nil { cmd.SysProcAttr = &syscall.SysProcAttr{ Credential: &syscall.Credential{ Uid: uint32(e.cfg.NixPipelines.SandboxUid), Gid: uint32(e.cfg.NixPipelines.SandboxGid), }, } } return cmd } // runSandbox streams a step's output to the workflow log under the configured // log cap func (e *Engine) runSandbox(ctx context.Context, addl addlFields, executable string, args []string, stdout, stderr io.Writer) error { subCtx, cancel := context.WithCancel(ctx) defer cancel() logMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel} cmd := e.newSandboxCmd(subCtx, addl, executable, args) cmd.Stdout = &limitedStreamWriter{parent: logMgr, out: stdout} cmd.Stderr = &limitedStreamWriter{parent: logMgr, out: stderr} err := cmd.Run() if logMgr.exceeded { return fmt.Errorf("step log output exceeded the %d byte limit", logMgr.limit) } return err } // captureSandbox buffers stdout up to maxOut before anyone parses it, teeing // stderr to the workflow log. overflowing either budget kills the child func (e *Engine) captureSandbox(ctx context.Context, addl addlFields, executable string, args []string, maxOut int64, stderrLog io.Writer) ([]byte, error) { subCtx, cancel := context.WithCancel(ctx) defer cancel() buf := new(bytes.Buffer) outMgr := &maxLogWriter{limit: maxOut, cancel: cancel} errMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel} cmd := e.newSandboxCmd(subCtx, addl, executable, args) cmd.Stdout = &limitedStreamWriter{parent: outMgr, out: buf} cmd.Stderr = &limitedStreamWriter{parent: errMgr, out: stderrLog} err := cmd.Run() if outMgr.exceeded { return nil, fmt.Errorf("evaluation output exceeded the %d byte limit", maxOut) } if errMgr.exceeded { return nil, fmt.Errorf("evaluation stderr exceeded the %d byte limit", errMgr.limit) } if err != nil { return nil, err } return buf.Bytes(), nil } // every nix evaluation runs under prlimit --as so a hostile flake cannot OOM // the executor while evaluating. the sandbox HOME is synthetic, so the // flakes/nix-command features must be requested on the command line instead // of relying on a nix.conf, builds keep the host daemon's own config func (e *Engine) evalArgv(nixArgs []string) (string, []string) { argv := append([]string{ fmt.Sprintf("--as=%d", e.maxEvalMemoryBytes()), containerNix, "--extra-experimental-features", "nix-command flakes", }, nixArgs...) return containerPrlimit, argv } func (e *Engine) captureEval(ctx context.Context, addl addlFields, nixArgs []string, stderrLog io.Writer) ([]byte, error) { executable, argv := e.evalArgv(nixArgs) return e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, stderrLog) } // nixStringLit quotes s as a nix string literal. discovery names reach nix // only through this: the constant apply templates take parameters as escaped // literals and look them up with builtins.getAttr, never joined attr paths func nixStringLit(s string) string { r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `${`, `\${`) return `"` + r.Replace(s) + `"` } // constant --apply templates against parent installables, parameterized only // through nixStringLit. every eval targets a whole category attrset, never a // full output path const ( // names inside a category (home/nixos/darwinConfigurations) or per-system // category attrset (packages/checks/devShells.) attrNamesApply = `attrs: builtins.attrNames attrs` // presence probe for a direct output (formatter/defaultPackage/devShell) presentApply = `x: true` systemOutputDrvApply = `attrs: let output = builtins.getAttr %s attrs; in if builtins.isAttrs output && output ? drvPath then output.drvPath else throw "flake output is not a derivation"` directDrvApply = `output: if builtins.isAttrs output && output ? drvPath then output.drvPath else throw "flake output is not a derivation"` homeActivationDrvApply = `attrs: let output = (builtins.getAttr %s attrs).activationPackage; in if builtins.isAttrs output && output ? drvPath then output.drvPath else throw "home configuration activationPackage is not a derivation"` toplevelDrvApply = `attrs: let output = (builtins.getAttr %s attrs).config.system.build.toplevel; in if builtins.isAttrs output && output ? drvPath then output.drvPath else throw "system configuration toplevel is not a derivation"` ) // currentSystem comes from nix itself, but it still lands inside an // installable argv string, so pin it to the charset real system strings use // before trusting it var systemPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // parentInstallable builds the installable for a whole category // (home/nixos/darwinConfigurations) or a per-system category attrset // (packages/checks/devShells/formatter/defaultPackage/devShell) func parentInstallable(category, system string) (string, error) { if system == "" { return ".#" + category, nil } if !systemPattern.MatchString(system) { return "", fmt.Errorf("refusing to build an installable from unsafe system string %q", system) } return ".#" + category + "." + system, nil } // nix answers a probe of a category or system attr the flake doesn't define // with "does not provide attribute" on stderr, like garnix's // isDoesNotProvideAttributeError, probes treat that as "absent" rather than // a workflow failure. older nix phrases it as "attribute ... missing" func isMissingAttrError(stderr string) bool { return strings.Contains(stderr, "does not provide attribute") || (strings.Contains(stderr, "error: attribute") && strings.Contains(stderr, "missing")) } type flakeLockNode struct { Original map[string]any `json:"original"` Locked map[string]any `json:"locked"` } // authorizeFlakeInputs decides which fetched sources a flake may pull from // before we evaluate it any further. anything not explicitly permitted here // is rejected func authorizeFlakeInputs(metadataBytes []byte, workspaceDir string) error { var meta struct { Locks struct { Root string `json:"root"` Nodes map[string]flakeLockNode `json:"nodes"` } `json:"locks"` } if err := json.Unmarshal(metadataBytes, &meta); err != nil { return fmt.Errorf("parsing flake metadata JSON: %w", err) } root := meta.Locks.Root if root == "" { root = "root" } for name, node := range meta.Locks.Nodes { if name == root { continue } if err := authorizeFlakeInput(name, node, workspaceDir); err != nil { return err } } return nil } func authorizeFlakeInput(name string, node flakeLockNode, workspaceDir string) error { // what the user wrote is authoritative, only indirect (registry) inputs // get their fetch source from the lock target := node.Original if origType, _ := node.Original["type"].(string); origType == "indirect" { target = node.Locked } if target == nil { // a follows-only node fetches nothing return nil } typ, _ := target["type"].(string) switch typ { case "github", "gitlab", "sourcehut", "tarball": return nil case "path": return authorizePathInput(name, target, workspaceDir) case "file": return authorizeURLInput(name, typ, target, "http", "https") case "git", "hg": return authorizeURLInput(name, typ, target, "http", "https", "ssh") default: return fmt.Errorf("flake input %q uses unauthorized type %q", name, typ) } } // path inputs must stay relative and land inside the checked-out workspace; // anything absolute or escaping is a sandbox breakout attempt func authorizePathInput(name string, target map[string]any, workspaceDir string) error { p, _ := target["path"].(string) if p == "" { return fmt.Errorf("flake input %q is a path input without a path", name) } if filepath.IsAbs(p) { return fmt.Errorf("flake input %q uses absolute path %q", name, p) } canonicalWorkspace, err := filepath.EvalSymlinks(workspaceDir) if err != nil { return fmt.Errorf("resolving workspace dir: %w", err) } joined := filepath.Join(canonicalWorkspace, filepath.Clean(p)) if joined != canonicalWorkspace && !strings.HasPrefix(joined, canonicalWorkspace+string(filepath.Separator)) { return fmt.Errorf("flake input %q path %q escapes the workspace", name, p) } // a lexically-inside path can still escape through a symlink, so re-check // the canonical target when it exists, nonexistent paths are created by // the fetch inside the sandbox and stay where the lexical check put them if canonical, err := filepath.EvalSymlinks(joined); err == nil { if canonical != canonicalWorkspace && !strings.HasPrefix(canonical, canonicalWorkspace+string(filepath.Separator)) { return fmt.Errorf("flake input %q path %q escapes the workspace through a symlink", name, p) } } return nil } func authorizeURLInput(name, typ string, target map[string]any, schemes ...string) error { raw, _ := target["url"].(string) u, err := url.Parse(raw) if err != nil { return fmt.Errorf("flake input %q has unparseable url %q: %w", name, raw, err) } for _, scheme := range schemes { if u.Scheme == scheme { return nil } } return fmt.Errorf("flake input %q (%s) url %q must use one of: %s", name, typ, raw, strings.Join(schemes, ", ")) } // discover evaluates the checked-out flake and appends one build step per // buildable output to the workflow func (e *Engine) discover(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, wfLogger models.WorkflowLogger) error { val, ok := e.workspaces.Load(wid) if !ok { return errors.New("no workspace for workflow; SetupWorkflow must run first") } addl := val.(addlFields) stdout := wfLogger.DataWriter(idx, "stdout") stderr := wfLogger.DataWriter(idx, "stderr") sysBytes, err := e.captureEval(ctx, addl, []string{"eval", "--impure", "--raw", "--expr", "builtins.currentSystem"}, stderr) if err != nil { return fmt.Errorf("determining currentSystem: %w", err) } system := strings.TrimSpace(string(sysBytes)) if system == "" { return errors.New("nix reported an empty builtins.currentSystem") } if !systemPattern.MatchString(system) { return fmt.Errorf("nix reported an unsafe currentSystem %q", system) } metaBytes, err := e.captureEval(ctx, addl, []string{"flake", "metadata", "--json", "."}, stderr) if err != nil { return fmt.Errorf("reading flake metadata: %w", err) } if err := authorizeFlakeInputs(metaBytes, addl.workspaceDir); err != nil { return fmt.Errorf("authorizing flake inputs: %w", err) } var candidates []outputCandidate for _, category := range []string{"packages", "checks", "devShells"} { names, err := e.evalNames(ctx, addl, category, system) if err != nil { return fmt.Errorf("listing %s for %s: %w", category, system, err) } for _, name := range names { candidates = append(candidates, outputCandidate{ kind: targetSystemOutput, category: category, system: system, name: name, }) } } for _, category := range []string{"formatter", "defaultPackage", "devShell"} { present, err := e.evalPresent(ctx, addl, category, system) if err != nil { return fmt.Errorf("checking %s.%s: %w", category, system, err) } if present { candidates = append(candidates, outputCandidate{ kind: targetDirect, category: category, system: system, }) } } homeNames, err := e.evalNames(ctx, addl, "homeConfigurations", "") if err != nil { return fmt.Errorf("listing homeConfigurations: %w", err) } for _, name := range homeNames { candidates = append(candidates, outputCandidate{ kind: targetHomeActivation, category: "homeConfigurations", name: name, }) } for _, category := range []string{"nixosConfigurations", "darwinConfigurations"} { names, err := e.evalNames(ctx, addl, category, "") if err != nil { return fmt.Errorf("listing %s: %w", category, err) } for _, name := range names { candidates = append(candidates, outputCandidate{ kind: targetToplevel, category: category, name: name, }) } } if err := checkOutputLimit(len(candidates), e.maxOutputs()); err != nil { return err } for _, cand := range candidates { drvPath, err := e.resolveDrvPath(ctx, addl, cand, stderr) if err != nil { return err } step := Step{ name: "Build " + cand.display(), command: fmt.Sprintf("nix build --no-link --print-build-logs %s^*", drvPath), drvPath: drvPath, kind: models.StepKindUser, } wf.Steps = append(wf.Steps, step) _, _ = fmt.Fprintln(stdout, step.Name()) } return nil } // captureEvalProbe captures stderr instead of teeing it to the workflow log: // presence/name probes hit attributes the flake may not define, and nix's // "does not provide attribute" spew would read as a failure to users func (e *Engine) captureEvalProbe(ctx context.Context, addl addlFields, nixArgs []string) (stdout, stderr []byte, err error) { errBuf := new(bytes.Buffer) executable, argv := e.evalArgv(nixArgs) out, runErr := e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, errBuf) return out, errBuf.Bytes(), runErr } // evalNames lists the outputs inside a category or per-system category // parent attrset, a flake that doesn't define the category or system yields // an empty list, not an error func (e *Engine) evalNames(ctx context.Context, addl addlFields, category, system string) ([]string, error) { installable, err := parentInstallable(category, system) if err != nil { return nil, err } out, probeErr, err := e.captureEvalProbe(ctx, addl, []string{"eval", installable, "--apply", attrNamesApply, "--json"}) if err != nil { if isMissingAttrError(string(probeErr)) { return nil, nil } return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr))) } var names []string if err := json.Unmarshal(out, &names); err != nil { return nil, fmt.Errorf("parsing output names JSON: %w", err) } sort.Strings(names) return names, nil } // evalPresent probes a direct output (formatter/defaultPackage/devShell); // a flake that doesn't define it yields false, not an error func (e *Engine) evalPresent(ctx context.Context, addl addlFields, category, system string) (bool, error) { installable, err := parentInstallable(category, system) if err != nil { return false, err } _, probeErr, err := e.captureEvalProbe(ctx, addl, []string{"eval", installable, "--apply", presentApply, "--json"}) if err != nil { if isMissingAttrError(string(probeErr)) { return false, nil } return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr))) } return true, nil } // resolveDrvPath pins a candidate to a concrete store derivation, anything // that isn't a derivation fails the workflow rather than being skipped func (e *Engine) resolveDrvPath(ctx context.Context, addl addlFields, cand outputCandidate, stderr io.Writer) (string, error) { installable, apply, err := cand.drvTarget() if err != nil { return "", err } out, err := e.captureEval(ctx, addl, []string{"eval", installable, "--apply", apply, "--raw"}, stderr) if err != nil { return "", fmt.Errorf("resolving %s: %w", cand.display(), err) } drvPath := strings.TrimSpace(string(out)) if !isValidDrvPath(drvPath) { return "", fmt.Errorf("%s resolved to invalid derivation path %q", cand.display(), drvPath) } return drvPath, nil } // the resolved path becomes a build installable, so pin its shape before // trusting it func isValidDrvPath(p string) bool { return strings.HasPrefix(p, "/nix/store/") && strings.HasSuffix(p, ".drv") && !strings.ContainsAny(p, " \t\n") } func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { if idx < 0 || idx >= len(wf.Steps) { return fmt.Errorf("step index %d out of range (%d steps)", idx, len(wf.Steps)) } val, ok := e.workspaces.Load(wid) if !ok { return errors.New("no workspace for workflow; SetupWorkflow must run first") } addl := val.(addlFields) var err error switch s := wf.Steps[idx].(type) { case models.CloneStep: err = e.runSandbox(ctx, addl, containerBash, []string{"-euo", "pipefail", "-c", s.Command()}, wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr")) case Step: switch { case s.isDiscovery: err = e.discover(ctx, wid, wf, idx, wfLogger) case s.drvPath != "": err = e.runSandbox(ctx, addl, containerNix, []string{"--extra-experimental-features", "nix-command flakes", "build", "--no-link", "--print-build-logs", s.drvPath + "^*"}, wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr")) default: err = fmt.Errorf("nix engine step %q has nothing to run", s.Name()) } default: err = fmt.Errorf("unknown step type %T", wf.Steps[idx]) } if err != nil { // a cancellation must survive untouched, only a deadline is a timeout if ctxErr := ctx.Err(); ctxErr != nil { if errors.Is(ctxErr, context.DeadlineExceeded) { return fmt.Errorf("%w: %v", engine.ErrTimedOut, ctxErr) } return ctxErr } return err } return nil }