From 8b515ac1f3f5ebd2e2d7e2deb47011b0241d1e41 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 23 Jul 2026 17:12:25 +0300 Subject: [PATCH] spindle/engines/nix: add host nix bubblewrap workflow engine Signed-off-by: dawn --- .tangled/workflows/test.yml | 4 + nix/modules/spindle.nix | 54 ++ spindle/config/config.go | 13 + spindle/engine/engine.go | 5 +- spindle/engines/nix/engine.go | 909 +++++++++++++++++++++++++++++ spindle/engines/nix/engine_test.go | 374 ++++++++++++ spindle/server.go | 14 +- 7 files changed, 1370 insertions(+), 3 deletions(-) create mode 100644 spindle/engines/nix/engine.go create mode 100644 spindle/engines/nix/engine_test.go diff --git a/.tangled/workflows/test.yml b/.tangled/workflows/test.yml index 9b743678..90765c5c 100644 --- a/.tangled/workflows/test.yml +++ b/.tangled/workflows/test.yml @@ -8,9 +8,13 @@ image: nixos dependencies: - go - gcc + - bubblewrap + - nix + - git environment: CGO_ENABLED: 1 + RUN_NIX_INTEGRATION_TEST: "true" steps: - name: patch static dir diff --git a/nix/modules/spindle.nix b/nix/modules/spindle.nix index 8ba2487b..482799c6 100644 --- a/nix/modules/spindle.nix +++ b/nix/modules/spindle.nix @@ -167,6 +167,49 @@ in }; }; + nix = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable the host Nix bubblewrap workflow engine. Enabling lets repository authors evaluate flakes and submit builds to the configured Nix daemon."; + }; + workDirBase = mkOption { + type = types.str; + default = "/tmp"; + description = "Directory for temporary Nix workflow workspaces"; + }; + maxConcurrentWorkflows = mkOption { + type = types.int; + default = 8; + description = "Maximum number of Nix workflows running simultaneously. Zero disables this limit."; + }; + maxOutputs = mkOption { + type = types.int; + default = 100; + description = "Maximum number of build outputs per Nix workflow."; + }; + maxEvalMemoryBytes = mkOption { + type = types.int; + default = 8589934592; + description = "Maximum memory limit in bytes during Nix evaluation."; + }; + maxLogBytes = mkOption { + type = types.int; + default = 33554432; + description = "Maximum log output size in bytes for a Nix workflow step."; + }; + sandboxUid = mkOption { + type = types.int; + default = 65534; + description = "User ID to run sandboxed Nix workflow processes as."; + }; + sandboxGid = mkOption { + type = types.int; + default = 65534; + description = "Group ID to run sandboxed Nix workflow processes as."; + }; + }; + microvm = { enableKVM = mkOption { type = types.bool; @@ -307,7 +350,9 @@ in config = let deps = [ + pkgs.bash pkgs.git + pkgs.bubblewrap pkgs.qemu pkgs.e2fsprogs pkgs.slirp4netns @@ -368,6 +413,15 @@ in "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" "SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB=${toString cfg.pipelines.nixery.maxJobMemoryMb}" "SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS=${toString cfg.pipelines.nixery.maxConcurrentWorkflows}" + "SPINDLE_NIX_PIPELINES_ENABLED=${lib.boolToString cfg.pipelines.nix.enable}" + "SPINDLE_NIX_PIPELINES_WORK_DIR_BASE=${cfg.pipelines.nix.workDirBase}" + "SPINDLE_NIX_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" + "SPINDLE_NIX_PIPELINES_MAX_CONCURRENT_WORKFLOWS=${toString cfg.pipelines.nix.maxConcurrentWorkflows}" + "SPINDLE_NIX_PIPELINES_MAX_OUTPUTS=${toString cfg.pipelines.nix.maxOutputs}" + "SPINDLE_NIX_PIPELINES_MAX_EVAL_MEMORY_BYTES=${toString cfg.pipelines.nix.maxEvalMemoryBytes}" + "SPINDLE_NIX_PIPELINES_MAX_LOG_BYTES=${toString cfg.pipelines.nix.maxLogBytes}" + "SPINDLE_NIX_PIPELINES_SANDBOX_UID=${toString cfg.pipelines.nix.sandboxUid}" + "SPINDLE_NIX_PIPELINES_SANDBOX_GID=${toString cfg.pipelines.nix.sandboxGid}" "SPINDLE_MICROVM_PIPELINES_IMAGE_DIR=${cfg.pipelines.microvm.imageDir}" "SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR=${cfg.pipelines.microvm.overlayDir}" "SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE=${cfg.pipelines.microvm.defaultImage}" diff --git a/spindle/config/config.go b/spindle/config/config.go index 661e35f7..75c710f7 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -57,6 +57,18 @@ type NixeryPipelines struct { MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` // max number of workflow containers running at once (memory cap) } +type NixPipelines struct { + Enabled bool `env:"ENABLED, default=false"` + WorkDirBase string `env:"WORK_DIR_BASE, default=/tmp"` + WorkflowTimeout time.Duration `env:"WORKFLOW_TIMEOUT, default=5m"` + MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` + MaxOutputs int `env:"MAX_OUTPUTS, default=100"` + MaxEvalMemoryBytes int64 `env:"MAX_EVAL_MEMORY_BYTES, default=8589934592"` + MaxLogBytes int64 `env:"MAX_LOG_BYTES, default=33554432"` + SandboxUid int `env:"SANDBOX_UID, default=65534"` + SandboxGid int `env:"SANDBOX_GID, default=65534"` +} + type S3 struct { LogBucket string `env:"LOG_BUCKET"` } @@ -96,6 +108,7 @@ type NixCache struct { type Config struct { Server Server `env:",prefix=SPINDLE_SERVER_"` NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` + NixPipelines NixPipelines `env:",prefix=SPINDLE_NIX_PIPELINES_"` MicroVMPipelines MicroVMPipelines `env:",prefix=SPINDLE_MICROVM_PIPELINES_"` NixCache NixCache `env:",prefix=SPINDLE_NIX_CACHE_"` S3 S3 `env:",prefix=SPINDLE_S3_"` diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go index f1cddd3a..ba8d1651 100644 --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -191,7 +191,10 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, d } defer eng.DestroyWorkflow(ctx, wid) - for stepIdx, step := range w.Steps { + // discovery can append build steps while the loop runs, so index + // live instead of ranging a snapshot of w.Steps + for stepIdx := 0; stepIdx < len(w.Steps); stepIdx++ { + step := w.Steps[stepIdx] if wfLogger != nil { wfLogger. ControlWriter(stepIdx, step, models.StepStatusStart). diff --git a/spindle/engines/nix/engine.go b/spindle/engines/nix/engine.go new file mode 100644 index 00000000..70cf3826 --- /dev/null +++ b/spindle/engines/nix/engine.go @@ -0,0 +1,909 @@ +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 +} diff --git a/spindle/engines/nix/engine_test.go b/spindle/engines/nix/engine_test.go new file mode 100644 index 00000000..9d2902a9 --- /dev/null +++ b/spindle/engines/nix/engine_test.go @@ -0,0 +1,374 @@ +package nix + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tangled.org/core/api/tangled" + "tangled.org/core/spindle/config" + "tangled.org/core/spindle/models" + "tangled.org/core/workflow" +) + +func TestInitWorkflow(t *testing.T) { + e := &Engine{cfg: &config.Config{}} + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{}) + require.NoError(t, err) + require.Len(t, wf.Steps, 1) + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) + assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind()) +} + +func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) { + e := &Engine{cfg: &config.Config{}} + raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n" + compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{ + Name: ".tangled/workflows/ci.yml", + Engine: "nix", + Raw: raw, + }}) + require.Len(t, compiled.Workflows, 1) + require.Equal(t, raw, compiled.Workflows[0].Raw) + + wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{}) + require.NoError(t, err) + require.Len(t, wf.Steps, 1) + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) +} + +func TestInitWorkflowRejectsCustomSteps(t *testing.T) { + e := &Engine{cfg: &config.Config{}} + _, err := e.InitWorkflow(tangled.Pipeline_Workflow{ + Name: "flake", + Raw: "steps:\n - name: nope\n command: echo nope\n", + }, tangled.Pipeline{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") +} + +// a generic workflow document (engine/when/clone, no engine-specific keys) +// must initialize, only structural keys like `steps` are rejected +func TestInitWorkflowAcceptsGenericKeys(t *testing.T) { + e := &Engine{cfg: &config.Config{}} + raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n depth: 1\n" + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{}) + require.NoError(t, err) + require.Len(t, wf.Steps, 1) + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) + + _, err = e.InitWorkflow(tangled.Pipeline_Workflow{ + Name: "flake", + Raw: raw + "steps:\n - name: nope\n command: echo nope\n", + }, tangled.Pipeline{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") +} + +// every eval/metadata invocation needs the flakes features flags because the +// sandbox HOME has no nix.conf +func TestEvalArgv(t *testing.T) { + e := &Engine{cfg: &config.Config{}} + exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."}) + assert.Equal(t, containerPrlimit, exe) + require.GreaterOrEqual(t, len(argv), 6) + assert.True(t, strings.HasPrefix(argv[0], "--as=")) + assert.Equal(t, containerNix, argv[1]) + assert.Equal(t, "--extra-experimental-features", argv[2]) + assert.Equal(t, "nix-command flakes", argv[3]) + assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:]) +} + +func TestNixStringLit(t *testing.T) { + cases := map[string]string{ + "plain": `"plain"`, + "dots.and%percent": `"dots.and%percent"`, + `quote"injection`: `"quote\"injection"`, + `interp${pwn}`: `"interp\${pwn}"`, + `back\slash`: `"back\\slash"`, + `"; throw "escaped`: `"\"; throw \"escaped"`, + "newline\ninside": "\"newline\ninside\"", // literal newlines are legal in nix strings + } + for in, want := range cases { + assert.Equal(t, want, nixStringLit(in), "input %q", in) + } +} + +// metacharacter attr names stay structured: the installable is only the +// trusted category.system parent, the apply lambda sees just the escaped +// name, display is for humans +func TestOutputCandidateMetachars(t *testing.T) { + cand := outputCandidate{ + kind: targetSystemOutput, + category: "checks", + system: "x86_64-linux", + name: `unit.100%"cov${throw}`, + } + installable, apply, err := cand.drvTarget() + require.NoError(t, err) + assert.Equal(t, ".#checks.x86_64-linux", installable) + assert.Contains(t, apply, nixStringLit(cand.name)) + assert.NotContains(t, apply, cand.name) + assert.NotContains(t, installable, cand.name) + assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display()) + + _, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget() + require.Error(t, err) +} + +// only validated system strings may reach an installable, anything else is +// refused before it becomes argv +func TestParentInstallable(t *testing.T) { + installable, err := parentInstallable("packages", "x86_64-linux") + require.NoError(t, err) + assert.Equal(t, ".#packages.x86_64-linux", installable) + + installable, err = parentInstallable("nixosConfigurations", "") + require.NoError(t, err) + assert.Equal(t, ".#nixosConfigurations", installable) + + for _, system := range []string{ + `x86_64-linux"; throw "`, + "x86_64-linux ${throw 1}", + "x86_64-linux --option", + "x86_64-linux/x", + "x86_64 linux", + } { + _, err := parentInstallable("packages", system) + require.Error(t, err, "system %q", system) + assert.Contains(t, err.Error(), "unsafe system") + } +} + +// a flake that doesn't define a probed category is "absent", not broken; +// other evaluation failures must not be swallowed +func TestIsMissingAttrError(t *testing.T) { + // verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake + // without checks + assert.True(t, isMissingAttrError( + "error: flake 'path:/workdir' does not provide attribute 'packages.x86_64-linux.checks.x86_64-linux', 'legacyPackages.x86_64-linux.checks.x86_64-linux' or 'checks.x86_64-linux'")) + // older nix phrasing + assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing")) + + assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file")) + assert.False(t, isMissingAttrError("error: flake output is not a derivation")) + assert.False(t, isMissingAttrError("")) +} + +func TestCheckOutputLimit(t *testing.T) { + require.NoError(t, checkOutputLimit(0, 100)) + require.NoError(t, checkOutputLimit(100, 100)) + err := checkOutputLimit(101, 100) + require.Error(t, err) + assert.Contains(t, err.Error(), "101") + assert.Contains(t, err.Error(), "100") +} + +func TestMaxOutputsDefault(t *testing.T) { + assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs()) + assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs()) +} + +func TestMaxLogWriterOverflow(t *testing.T) { + var buf bytes.Buffer + canceled := false + mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }} + w := &limitedStreamWriter{parent: mw, out: &buf} + n, err := w.Write([]byte("0123456789")) + require.NoError(t, err) + require.Equal(t, 10, n) + assert.False(t, mw.exceeded) + + n, err = w.Write([]byte("overflow")) + require.Error(t, err) + assert.Equal(t, 0, n) + assert.Contains(t, err.Error(), "exceeded the 10 byte limit") + assert.True(t, mw.exceeded) + assert.True(t, canceled) + assert.Equal(t, "0123456789", buf.String()) + + _, err = w.Write([]byte("more")) + require.Error(t, err) + assert.Equal(t, "0123456789", buf.String()) +} + +func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) { + var buf bytes.Buffer + mw := &maxLogWriter{limit: 5} + w := &limitedStreamWriter{parent: mw, out: &buf} + n, err := w.Write([]byte("0123456789")) + require.Error(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "01234", buf.String()) + assert.True(t, mw.exceeded) +} + +func TestAuthorizePathInput(t *testing.T) { + workspace := t.TempDir() + outside := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755)) + require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace)) + require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace)) + require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace)) + + require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace)) + require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace)) + require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace)) + + require.Error(t, authorizePathInput("g", map[string]any{}, workspace)) + + require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link"))) + require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace)) + + // a lexically-inside path can still escape through a symlink + require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape"))) + err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink") + + require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755)) + require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape"))) + require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace)) +} + +func TestInitWorkflowWithClone(t *testing.T) { + e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}} + repoName := "my-repo" + repoDid := "did:plc:repo" + tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ + Kind: string(workflow.TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/main", + NewSha: "1234567890abcdef1234567890abcdef12345678", + }, + Repo: &tangled.Pipeline_TriggerRepo{ + Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid, + }, + }} + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl) + require.NoError(t, err) + require.Len(t, wf.Steps, 2) + assert.IsType(t, models.CloneStep{}, wf.Steps[0]) + assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name()) +} + +func TestBaseBwrapArgs(t *testing.T) { + e := &Engine{ + nixBinPath: "/nix/store/nix/bin/nix", + bashBinPath: "/nix/store/bash/bin/bash", + gitBinPath: "/nix/store/git/bin/git", + prlimitBinPath: "/nix/store/prlimit/bin/prlimit", + } + args := e.baseBwrapArgs(addlFields{ + workspaceDir: "/tmp/host/workspace", + homeDir: "/tmp/host/home", + }) + argString := strings.Join(args, " ") + for _, expected := range []string{ + "--die-with-parent", "--new-session", "--unshare-all --share-net", + "--ro-bind /nix/store /nix/store", + "--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket", + "--proc /proc", "--dev /dev", "--tmpfs /tmp", + "--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home", + "--symlink /nix/store/nix/bin/nix /usr/bin/nix", + "--symlink /nix/store/bash/bin/bash /usr/bin/bash", + "--symlink /nix/store/bash/bin/bash /bin/sh", + "--symlink /nix/store/git/bin/git /usr/bin/git", + "--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit", + "--chdir /workdir", + } { + assert.Contains(t, argString, expected) + } + assert.NotContains(t, argString, "nixpkgs#") + assert.NotContains(t, argString, " shell ") +} + +type mockWorkflowLogger struct { + stdout strings.Builder + stderr strings.Builder +} + +func (m *mockWorkflowLogger) Close() error { return nil } +func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer { + if stream == "stderr" { + return &m.stderr + } + return &m.stdout +} +func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer { + return io.Discard +} + +func TestIntegrationRealFlakeBuilds(t *testing.T) { + if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" { + t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run") + } + + cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}} + e, err := New(cfg) + require.NoError(t, err) + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{}) + require.NoError(t, err) + wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"} + logger := &mockWorkflowLogger{} + require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger)) + defer e.DestroyWorkflow(context.Background(), wid) + + addl := wf.Data.(addlFields) + flake := `{ + inputs.nixpkgs.url = "nixpkgs"; + outputs = { nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in { + packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out"; + checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out"; + formatter.${system} = pkgs.hello; + devShells.${system}.ci = pkgs.mkShell {}; + }; +} +` + require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600)) + + // the loop bound is evaluated per iteration, so build steps appended by + // the discovery step run in the same pass + for idx := 0; idx < len(wf.Steps); idx++ { + require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String()) + } + + require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds") + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) + var names []string + for _, step := range wf.Steps[1:] { + s, ok := step.(Step) + require.True(t, ok, "generated step %q is a nix engine Step", step.Name()) + require.NotEmpty(t, s.drvPath, s.Name()) + assert.Contains(t, s.Command(), "^*", s.Name()) + names = append(names, s.Name()) + } + for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} { + assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment) + } + + require.NoError(t, e.DestroyWorkflow(context.Background(), wid)) + assert.NoDirExists(t, addl.workspaceDir) + assert.NoDirExists(t, addl.homeDir) + assert.NoError(t, e.DestroyWorkflow(context.Background(), wid)) +} + +func containsName(names []string, fragment string) bool { + for _, name := range names { + if strings.Contains(name, fragment) { + return true + } + } + return false +} diff --git a/spindle/server.go b/spindle/server.go index d631c578..bb1fee09 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -37,6 +37,7 @@ import ( "tangled.org/core/spindle/engine" "tangled.org/core/spindle/engines/dummy" "tangled.org/core/spindle/engines/microvm" + nixengine "tangled.org/core/spindle/engines/nix" "tangled.org/core/spindle/engines/nixery" "tangled.org/core/spindle/git" "tangled.org/core/spindle/models" @@ -357,11 +358,20 @@ func Run(ctx context.Context) error { return err } - s, err := New(ctx, cfg, d, map[string]models.Engine{ + engines := map[string]models.Engine{ "nixery": nixeryEng, "microvm": microvmEng, "dummy": dummy.New(log.FromContext(ctx)), - }) + } + if cfg.NixPipelines.Enabled { + nixEng, err := nixengine.New(cfg) + if err != nil { + return fmt.Errorf("setting up nix engine: %w", err) + } + engines["nix"] = nixEng + } + + s, err := New(ctx, cfg, d, engines) if err != nil { return err } -- 2.51.2