This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle/engines/nix: add host nix bubblewrap workflow engine

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 23, 2026, 8:44 PM +0300) commit 8b515ac1 parent 50574018 change-id tomxokqy
+1370 -3
+4
.tangled/workflows/test.yml
··· 8 8 dependencies: 9 9 - go 10 10 - gcc 11 + - bubblewrap 12 + - nix 13 + - git 11 14 12 15 environment: 13 16 CGO_ENABLED: 1 17 + RUN_NIX_INTEGRATION_TEST: "true" 14 18 15 19 steps: 16 20 - name: patch static dir
+54
nix/modules/spindle.nix
··· 167 167 }; 168 168 }; 169 169 170 + nix = { 171 + enable = mkOption { 172 + type = types.bool; 173 + default = false; 174 + description = "Enable the host Nix bubblewrap workflow engine. Enabling lets repository authors evaluate flakes and submit builds to the configured Nix daemon."; 175 + }; 176 + workDirBase = mkOption { 177 + type = types.str; 178 + default = "/tmp"; 179 + description = "Directory for temporary Nix workflow workspaces"; 180 + }; 181 + maxConcurrentWorkflows = mkOption { 182 + type = types.int; 183 + default = 8; 184 + description = "Maximum number of Nix workflows running simultaneously. Zero disables this limit."; 185 + }; 186 + maxOutputs = mkOption { 187 + type = types.int; 188 + default = 100; 189 + description = "Maximum number of build outputs per Nix workflow."; 190 + }; 191 + maxEvalMemoryBytes = mkOption { 192 + type = types.int; 193 + default = 8589934592; 194 + description = "Maximum memory limit in bytes during Nix evaluation."; 195 + }; 196 + maxLogBytes = mkOption { 197 + type = types.int; 198 + default = 33554432; 199 + description = "Maximum log output size in bytes for a Nix workflow step."; 200 + }; 201 + sandboxUid = mkOption { 202 + type = types.int; 203 + default = 65534; 204 + description = "User ID to run sandboxed Nix workflow processes as."; 205 + }; 206 + sandboxGid = mkOption { 207 + type = types.int; 208 + default = 65534; 209 + description = "Group ID to run sandboxed Nix workflow processes as."; 210 + }; 211 + }; 212 + 170 213 microvm = { 171 214 enableKVM = mkOption { 172 215 type = types.bool; ··· 307 350 308 351 config = let 309 352 deps = [ 353 + pkgs.bash 310 354 pkgs.git 355 + pkgs.bubblewrap 311 356 pkgs.qemu 312 357 pkgs.e2fsprogs 313 358 pkgs.slirp4netns ··· 368 413 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 369 414 "SPINDLE_NIXERY_PIPELINES_MAX_JOB_MEMORY_MB=${toString cfg.pipelines.nixery.maxJobMemoryMb}" 370 415 "SPINDLE_NIXERY_PIPELINES_MAX_CONCURRENT_WORKFLOWS=${toString cfg.pipelines.nixery.maxConcurrentWorkflows}" 416 + "SPINDLE_NIX_PIPELINES_ENABLED=${lib.boolToString cfg.pipelines.nix.enable}" 417 + "SPINDLE_NIX_PIPELINES_WORK_DIR_BASE=${cfg.pipelines.nix.workDirBase}" 418 + "SPINDLE_NIX_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 419 + "SPINDLE_NIX_PIPELINES_MAX_CONCURRENT_WORKFLOWS=${toString cfg.pipelines.nix.maxConcurrentWorkflows}" 420 + "SPINDLE_NIX_PIPELINES_MAX_OUTPUTS=${toString cfg.pipelines.nix.maxOutputs}" 421 + "SPINDLE_NIX_PIPELINES_MAX_EVAL_MEMORY_BYTES=${toString cfg.pipelines.nix.maxEvalMemoryBytes}" 422 + "SPINDLE_NIX_PIPELINES_MAX_LOG_BYTES=${toString cfg.pipelines.nix.maxLogBytes}" 423 + "SPINDLE_NIX_PIPELINES_SANDBOX_UID=${toString cfg.pipelines.nix.sandboxUid}" 424 + "SPINDLE_NIX_PIPELINES_SANDBOX_GID=${toString cfg.pipelines.nix.sandboxGid}" 371 425 "SPINDLE_MICROVM_PIPELINES_IMAGE_DIR=${cfg.pipelines.microvm.imageDir}" 372 426 "SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR=${cfg.pipelines.microvm.overlayDir}" 373 427 "SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE=${cfg.pipelines.microvm.defaultImage}"
+13
spindle/config/config.go
··· 57 57 MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` // max number of workflow containers running at once (memory cap) 58 58 } 59 59 60 + type NixPipelines struct { 61 + Enabled bool `env:"ENABLED, default=false"` 62 + WorkDirBase string `env:"WORK_DIR_BASE, default=/tmp"` 63 + WorkflowTimeout time.Duration `env:"WORKFLOW_TIMEOUT, default=5m"` 64 + MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` 65 + MaxOutputs int `env:"MAX_OUTPUTS, default=100"` 66 + MaxEvalMemoryBytes int64 `env:"MAX_EVAL_MEMORY_BYTES, default=8589934592"` 67 + MaxLogBytes int64 `env:"MAX_LOG_BYTES, default=33554432"` 68 + SandboxUid int `env:"SANDBOX_UID, default=65534"` 69 + SandboxGid int `env:"SANDBOX_GID, default=65534"` 70 + } 71 + 60 72 type S3 struct { 61 73 LogBucket string `env:"LOG_BUCKET"` 62 74 } ··· 96 108 type Config struct { 97 109 Server Server `env:",prefix=SPINDLE_SERVER_"` 98 110 NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 111 + NixPipelines NixPipelines `env:",prefix=SPINDLE_NIX_PIPELINES_"` 99 112 MicroVMPipelines MicroVMPipelines `env:",prefix=SPINDLE_MICROVM_PIPELINES_"` 100 113 NixCache NixCache `env:",prefix=SPINDLE_NIX_CACHE_"` 101 114 S3 S3 `env:",prefix=SPINDLE_S3_"`
+4 -1
spindle/engine/engine.go
··· 191 191 } 192 192 defer eng.DestroyWorkflow(ctx, wid) 193 193 194 - for stepIdx, step := range w.Steps { 194 + // discovery can append build steps while the loop runs, so index 195 + // live instead of ranging a snapshot of w.Steps 196 + for stepIdx := 0; stepIdx < len(w.Steps); stepIdx++ { 197 + step := w.Steps[stepIdx] 195 198 if wfLogger != nil { 196 199 wfLogger. 197 200 ControlWriter(stepIdx, step, models.StepStatusStart).
+909
spindle/engines/nix/engine.go
··· 1 + package nix 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "net/url" 11 + "os" 12 + "os/exec" 13 + "path/filepath" 14 + "regexp" 15 + "sort" 16 + "strings" 17 + "sync" 18 + "syscall" 19 + "time" 20 + 21 + "tangled.org/core/api/tangled" 22 + "tangled.org/core/spindle/config" 23 + "tangled.org/core/spindle/engine" 24 + "tangled.org/core/spindle/models" 25 + "tangled.org/core/spindle/secrets" 26 + ) 27 + 28 + // eval json is buffered whole before parsing, so it needs a hard ceiling, 29 + // a hostile flake must not oom the executor through stdout 30 + const maxEvalOutputBytes = 32 << 20 31 + 32 + // paths inside the bwrap namespace, populated by symlink in baseBwrapArgs 33 + const ( 34 + containerNix = "/usr/bin/nix" 35 + containerBash = "/usr/bin/bash" 36 + containerPrlimit = "/usr/bin/prlimit" 37 + ) 38 + 39 + type Engine struct { 40 + cfg *config.Config 41 + slotter *engine.SemaphoreSlotter 42 + nixBinPath string 43 + bashBinPath string 44 + gitBinPath string 45 + prlimitBinPath string 46 + bwrapBinPath string 47 + workspaces sync.Map // keyed by full models.WorkflowId 48 + } 49 + 50 + // per-workflow host state, workspaceDir is bound at /workdir and homeDir at 51 + // /home inside the sandbox 52 + type addlFields struct { 53 + workspaceDir string 54 + homeDir string 55 + } 56 + 57 + type Step struct { 58 + name string 59 + command string 60 + drvPath string 61 + kind models.StepKind 62 + isDiscovery bool 63 + } 64 + 65 + func (s Step) Name() string { return s.name } 66 + func (s Step) Command() string { return s.command } 67 + func (s Step) Kind() models.StepKind { return s.kind } 68 + 69 + // targetKind names the shape of a flake output without encoding it in an attr 70 + // path string 71 + type targetKind int 72 + 73 + const ( 74 + targetSystemOutput targetKind = iota // packages/checks/devShells.<system>.<name> 75 + targetDirect // formatter/defaultPackage/devShell.<system> 76 + targetHomeActivation // homeConfigurations.<name>.activationPackage 77 + targetToplevel // nixos|darwinConfigurations.<name>.config.system.build.toplevel 78 + ) 79 + 80 + // the installable and --apply expression resolving a candidate's drvPath. 81 + // the installable is a parent attrset built from trusted constants and the 82 + // validated currentSystem, only the apply lambda sees the output name, as a 83 + // nixStringLit literal looked up with builtins.getAttr 84 + func (c outputCandidate) drvTarget() (installable, apply string, err error) { 85 + switch c.kind { 86 + case targetSystemOutput: 87 + installable, err = parentInstallable(c.category, c.system) 88 + apply = fmt.Sprintf(systemOutputDrvApply, nixStringLit(c.name)) 89 + case targetDirect: 90 + installable, err = parentInstallable(c.category, c.system) 91 + apply = directDrvApply 92 + case targetHomeActivation: 93 + installable = ".#homeConfigurations" 94 + apply = fmt.Sprintf(homeActivationDrvApply, nixStringLit(c.name)) 95 + case targetToplevel: 96 + installable = ".#" + c.category 97 + apply = fmt.Sprintf(toplevelDrvApply, nixStringLit(c.name)) 98 + default: 99 + return "", "", fmt.Errorf("unknown candidate kind %d for %s", c.kind, c.display()) 100 + } 101 + if err != nil { 102 + return "", "", err 103 + } 104 + return installable, apply, nil 105 + } 106 + 107 + // too many outputs is a resource attack, refuse outright 108 + func checkOutputLimit(count, max int) error { 109 + if count > max { 110 + return fmt.Errorf("flake exposes %d candidate outputs, over the limit of %d", count, max) 111 + } 112 + return nil 113 + } 114 + 115 + // a discovered buildable, kept structured end to end: category/system/name go 116 + // to nix as separate string literals, never joined into an attr path 117 + type outputCandidate struct { 118 + kind targetKind 119 + category string 120 + system string 121 + name string 122 + } 123 + 124 + // display is for humans only, nothing parses this back 125 + func (c outputCandidate) display() string { 126 + switch c.kind { 127 + case targetSystemOutput: 128 + return fmt.Sprintf(".#%s.%s.%s", c.category, c.system, c.name) 129 + case targetDirect: 130 + return fmt.Sprintf(".#%s.%s", c.category, c.system) 131 + case targetHomeActivation: 132 + return fmt.Sprintf(".#homeConfigurations.%s.activationPackage", c.name) 133 + case targetToplevel: 134 + return fmt.Sprintf(".#%s.%s.config.system.build.toplevel", c.category, c.name) 135 + default: 136 + return ".#<unknown>" 137 + } 138 + } 139 + 140 + func New(cfg *config.Config) (*Engine, error) { 141 + // every one of these is required inside the sandbox, fail fast at startup 142 + // rather than mid-pipeline 143 + bwrapPath, err := executablePath("bwrap") 144 + if err != nil { 145 + return nil, err 146 + } 147 + nixPath, err := executablePath("nix") 148 + if err != nil { 149 + return nil, err 150 + } 151 + bashPath, err := executablePath("bash") 152 + if err != nil { 153 + return nil, err 154 + } 155 + gitPath, err := executablePath("git") 156 + if err != nil { 157 + return nil, err 158 + } 159 + prlimitPath, err := executablePath("prlimit") 160 + if err != nil { 161 + return nil, err 162 + } 163 + 164 + return &Engine{ 165 + cfg: cfg, 166 + slotter: engine.NewSemaphoreSlotter(cfg.NixPipelines.MaxConcurrentWorkflows), 167 + nixBinPath: nixPath, 168 + bashBinPath: bashPath, 169 + gitBinPath: gitPath, 170 + prlimitBinPath: prlimitPath, 171 + bwrapBinPath: bwrapPath, 172 + }, nil 173 + } 174 + 175 + // resolves to the real binary so the sandbox symlinks survive any wrapper 176 + // symlinks on the host PATH 177 + func executablePath(name string) (string, error) { 178 + path, err := exec.LookPath(name) 179 + if err != nil { 180 + return "", fmt.Errorf("%s executable not found: %w", name, err) 181 + } 182 + if resolved, err := filepath.EvalSymlinks(path); err == nil { 183 + path = resolved 184 + } 185 + return path, nil 186 + } 187 + 188 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 189 + // the nix engine owns the step list, so the only valid manifest keys are 190 + // the generic workflow ones (engine/when/clone), anything else, including 191 + // user steps, is a structural error 192 + if err := engine.DescribeManifestError(twf.Raw, struct{}{}); err != nil { 193 + return nil, err 194 + } 195 + 196 + wf := &models.Workflow{Name: twf.Name, Data: addlFields{}} 197 + if tpl.TriggerMetadata != nil { 198 + if cloneStep := models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev); cloneStep.Command() != "" { 199 + wf.Steps = append(wf.Steps, cloneStep) 200 + } 201 + } 202 + wf.Steps = append(wf.Steps, Step{ 203 + name: "Evaluate flake outputs", 204 + command: "nix flake metadata --json .; nix eval --apply <discovery> --json", 205 + kind: models.StepKindSystem, 206 + isDiscovery: true, 207 + }) 208 + return wf, nil 209 + } 210 + 211 + func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) { 212 + return e.slotter.AcquireWorkflowSlot(ctx, wid, wf) 213 + } 214 + 215 + func (e *Engine) SetupWorkflow(_ context.Context, wid models.WorkflowId, wf *models.Workflow, _ models.WorkflowLogger) error { 216 + workDirBase := e.cfg.NixPipelines.WorkDirBase 217 + if workDirBase == "" { 218 + workDirBase = os.TempDir() 219 + } 220 + 221 + workspaceDir, err := os.MkdirTemp(workDirBase, "spindle-nix-workspace-"+wid.String()+"-*") 222 + if err != nil { 223 + return fmt.Errorf("creating host workspace directory: %w", err) 224 + } 225 + homeDir, err := os.MkdirTemp(workDirBase, "spindle-nix-home-"+wid.String()+"-*") 226 + if err != nil { 227 + os.RemoveAll(workspaceDir) 228 + return fmt.Errorf("creating host home directory: %w", err) 229 + } 230 + homeTmpDir := filepath.Join(homeDir, "tmp") 231 + if err := os.MkdirAll(homeTmpDir, 0o755); err != nil { 232 + os.RemoveAll(workspaceDir) 233 + os.RemoveAll(homeDir) 234 + return fmt.Errorf("creating host home tmp directory: %w", err) 235 + } 236 + 237 + // the sandbox runs as the configured unprivileged user, so hand it 238 + // ownership of its writable dirs when we have the power to 239 + if os.Geteuid() == 0 { 240 + uid := e.cfg.NixPipelines.SandboxUid 241 + gid := e.cfg.NixPipelines.SandboxGid 242 + for _, dir := range []string{workspaceDir, homeDir, homeTmpDir} { 243 + if err := os.Chown(dir, uid, gid); err != nil { 244 + os.RemoveAll(workspaceDir) 245 + os.RemoveAll(homeDir) 246 + return fmt.Errorf("chowning %s to sandbox uid/gid: %w", dir, err) 247 + } 248 + } 249 + } 250 + 251 + addl := addlFields{workspaceDir: workspaceDir, homeDir: homeDir} 252 + wf.Data = addl 253 + e.workspaces.Store(wid, addl) 254 + return nil 255 + } 256 + 257 + func (e *Engine) WorkflowTimeout() time.Duration { 258 + if e.cfg.NixPipelines.WorkflowTimeout > 0 { 259 + return e.cfg.NixPipelines.WorkflowTimeout 260 + } 261 + return 5 * time.Minute 262 + } 263 + 264 + func (e *Engine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error { 265 + val, ok := e.workspaces.LoadAndDelete(wid) 266 + if !ok { 267 + // nothing stored: setup never ran or destroy already happened 268 + return nil 269 + } 270 + addl := val.(addlFields) 271 + return errors.Join(os.RemoveAll(addl.workspaceDir), os.RemoveAll(addl.homeDir)) 272 + } 273 + 274 + // the entire environment the sandboxed process sees, nothing leaks in from 275 + // the executor 276 + func (e *Engine) buildEnv() []string { 277 + return []string{ 278 + "HOME=/home", 279 + "TMPDIR=/home/tmp", 280 + "NIX_REMOTE=daemon", 281 + "PATH=/usr/bin:/bin", 282 + } 283 + } 284 + 285 + func (e *Engine) baseBwrapArgs(addl addlFields) []string { 286 + args := []string{ 287 + "--die-with-parent", 288 + "--new-session", 289 + "--unshare-all", 290 + "--share-net", // builds fetch from the network, the nix daemon gates what matters 291 + "--ro-bind", "/nix/store", "/nix/store", 292 + } 293 + 294 + // evaluation and builds both talk to the host nix daemon 295 + if _, err := os.Stat("/nix/var/nix/daemon-socket"); err == nil { 296 + args = append(args, "--ro-bind", "/nix/var/nix/daemon-socket", "/nix/var/nix/daemon-socket") 297 + } 298 + 299 + args = append(args, 300 + "--proc", "/proc", 301 + "--dev", "/dev", 302 + "--tmpfs", "/tmp", 303 + "--bind", addl.workspaceDir, "/workdir", 304 + "--bind", addl.homeDir, "/home", 305 + "--dir", "/etc", 306 + ) 307 + 308 + // /etc stays synthetic: only the resolved files networking/TLS actually 309 + // need, bound one at a time. no blanket /etc, no /run 310 + for _, f := range []string{ 311 + "/etc/resolv.conf", 312 + "/etc/nsswitch.conf", 313 + "/etc/hosts", 314 + "/etc/ssl/certs/ca-certificates.crt", 315 + "/etc/ssl/certs/ca-bundle.crt", 316 + } { 317 + resolved, err := filepath.EvalSymlinks(f) 318 + if err != nil { 319 + continue 320 + } 321 + if _, err := os.Stat(resolved); err != nil { 322 + continue 323 + } 324 + if dir := filepath.Dir(f); dir != "/etc" && dir != "/" { 325 + args = append(args, "--dir", dir) 326 + } 327 + args = append(args, "--ro-bind", resolved, f) 328 + } 329 + 330 + args = append(args, 331 + "--dir", "/usr", 332 + "--dir", "/usr/bin", 333 + "--dir", "/bin", 334 + "--symlink", e.nixBinPath, containerNix, 335 + "--symlink", e.bashBinPath, containerBash, 336 + "--symlink", e.bashBinPath, "/bin/sh", 337 + "--symlink", e.gitBinPath, "/usr/bin/git", 338 + "--symlink", e.prlimitBinPath, containerPrlimit, 339 + "--chdir", "/workdir", 340 + "--", 341 + ) 342 + 343 + return args 344 + } 345 + 346 + // shared stdout+stderr budget, whichever stream blows the limit truncates the 347 + // output and kills the child 348 + type maxLogWriter struct { 349 + mu sync.Mutex 350 + written int64 351 + limit int64 352 + cancel context.CancelFunc 353 + exceeded bool 354 + } 355 + 356 + type limitedStreamWriter struct { 357 + parent *maxLogWriter 358 + out io.Writer 359 + } 360 + 361 + func (w *limitedStreamWriter) Write(p []byte) (int, error) { 362 + return w.parent.write(w.out, p) 363 + } 364 + 365 + func (mw *maxLogWriter) write(w io.Writer, p []byte) (int, error) { 366 + mw.mu.Lock() 367 + if mw.exceeded { 368 + mw.mu.Unlock() 369 + return 0, fmt.Errorf("log output exceeded the %d byte limit", mw.limit) 370 + } 371 + if mw.limit > 0 { 372 + if remaining := mw.limit - mw.written; int64(len(p)) > remaining { 373 + mw.exceeded = true 374 + if mw.cancel != nil { 375 + mw.cancel() 376 + } 377 + mw.written = mw.limit 378 + var writeErr error 379 + if remaining > 0 && w != nil { 380 + _, writeErr = w.Write(p[:remaining]) 381 + } 382 + mw.mu.Unlock() 383 + if writeErr != nil { 384 + return int(remaining), fmt.Errorf("log output exceeded the %d byte limit: %w", mw.limit, writeErr) 385 + } 386 + return int(remaining), fmt.Errorf("log output exceeded the %d byte limit", mw.limit) 387 + } 388 + } 389 + mw.written += int64(len(p)) 390 + mw.mu.Unlock() 391 + if w != nil { 392 + return w.Write(p) 393 + } 394 + return len(p), nil 395 + } 396 + 397 + func (e *Engine) maxLogBytes() int64 { 398 + if e.cfg == nil { 399 + return 0 400 + } 401 + return e.cfg.NixPipelines.MaxLogBytes 402 + } 403 + 404 + func (e *Engine) maxEvalMemoryBytes() int64 { 405 + if e.cfg != nil && e.cfg.NixPipelines.MaxEvalMemoryBytes > 0 { 406 + return e.cfg.NixPipelines.MaxEvalMemoryBytes 407 + } 408 + return 8 << 30 409 + } 410 + 411 + func (e *Engine) maxOutputs() int { 412 + if e.cfg != nil && e.cfg.NixPipelines.MaxOutputs > 0 { 413 + return e.cfg.NixPipelines.MaxOutputs 414 + } 415 + return 100 416 + } 417 + 418 + // newSandboxCmd assembles bwrap with the sandbox argv, the returned command 419 + // runs as the configured unprivileged uid/gid when we're root 420 + func (e *Engine) newSandboxCmd(ctx context.Context, addl addlFields, executable string, args []string) *exec.Cmd { 421 + bwrapArgs := append(e.baseBwrapArgs(addl), executable) 422 + bwrapArgs = append(bwrapArgs, args...) 423 + cmd := exec.CommandContext(ctx, e.bwrapBinPath, bwrapArgs...) 424 + cmd.Env = e.buildEnv() 425 + if os.Geteuid() == 0 && e.cfg != nil { 426 + cmd.SysProcAttr = &syscall.SysProcAttr{ 427 + Credential: &syscall.Credential{ 428 + Uid: uint32(e.cfg.NixPipelines.SandboxUid), 429 + Gid: uint32(e.cfg.NixPipelines.SandboxGid), 430 + }, 431 + } 432 + } 433 + return cmd 434 + } 435 + 436 + // runSandbox streams a step's output to the workflow log under the configured 437 + // log cap 438 + func (e *Engine) runSandbox(ctx context.Context, addl addlFields, executable string, args []string, stdout, stderr io.Writer) error { 439 + subCtx, cancel := context.WithCancel(ctx) 440 + defer cancel() 441 + 442 + logMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel} 443 + cmd := e.newSandboxCmd(subCtx, addl, executable, args) 444 + cmd.Stdout = &limitedStreamWriter{parent: logMgr, out: stdout} 445 + cmd.Stderr = &limitedStreamWriter{parent: logMgr, out: stderr} 446 + 447 + err := cmd.Run() 448 + if logMgr.exceeded { 449 + return fmt.Errorf("step log output exceeded the %d byte limit", logMgr.limit) 450 + } 451 + return err 452 + } 453 + 454 + // captureSandbox buffers stdout up to maxOut before anyone parses it, teeing 455 + // stderr to the workflow log. overflowing either budget kills the child 456 + func (e *Engine) captureSandbox(ctx context.Context, addl addlFields, executable string, args []string, maxOut int64, stderrLog io.Writer) ([]byte, error) { 457 + subCtx, cancel := context.WithCancel(ctx) 458 + defer cancel() 459 + 460 + buf := new(bytes.Buffer) 461 + outMgr := &maxLogWriter{limit: maxOut, cancel: cancel} 462 + errMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel} 463 + 464 + cmd := e.newSandboxCmd(subCtx, addl, executable, args) 465 + cmd.Stdout = &limitedStreamWriter{parent: outMgr, out: buf} 466 + cmd.Stderr = &limitedStreamWriter{parent: errMgr, out: stderrLog} 467 + 468 + err := cmd.Run() 469 + if outMgr.exceeded { 470 + return nil, fmt.Errorf("evaluation output exceeded the %d byte limit", maxOut) 471 + } 472 + if errMgr.exceeded { 473 + return nil, fmt.Errorf("evaluation stderr exceeded the %d byte limit", errMgr.limit) 474 + } 475 + if err != nil { 476 + return nil, err 477 + } 478 + return buf.Bytes(), nil 479 + } 480 + 481 + // every nix evaluation runs under prlimit --as so a hostile flake cannot OOM 482 + // the executor while evaluating. the sandbox HOME is synthetic, so the 483 + // flakes/nix-command features must be requested on the command line instead 484 + // of relying on a nix.conf, builds keep the host daemon's own config 485 + func (e *Engine) evalArgv(nixArgs []string) (string, []string) { 486 + argv := append([]string{ 487 + fmt.Sprintf("--as=%d", e.maxEvalMemoryBytes()), 488 + containerNix, 489 + "--extra-experimental-features", "nix-command flakes", 490 + }, nixArgs...) 491 + return containerPrlimit, argv 492 + } 493 + 494 + func (e *Engine) captureEval(ctx context.Context, addl addlFields, nixArgs []string, stderrLog io.Writer) ([]byte, error) { 495 + executable, argv := e.evalArgv(nixArgs) 496 + return e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, stderrLog) 497 + } 498 + 499 + // nixStringLit quotes s as a nix string literal. discovery names reach nix 500 + // only through this: the constant apply templates take parameters as escaped 501 + // literals and look them up with builtins.getAttr, never joined attr paths 502 + func nixStringLit(s string) string { 503 + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `${`, `\${`) 504 + return `"` + r.Replace(s) + `"` 505 + } 506 + 507 + // constant --apply templates against parent installables, parameterized only 508 + // through nixStringLit. every eval targets a whole category attrset, never a 509 + // full output path 510 + const ( 511 + // names inside a category (home/nixos/darwinConfigurations) or per-system 512 + // category attrset (packages/checks/devShells.<system>) 513 + attrNamesApply = `attrs: builtins.attrNames attrs` 514 + 515 + // presence probe for a direct output (formatter/defaultPackage/devShell) 516 + presentApply = `x: true` 517 + 518 + systemOutputDrvApply = `attrs: 519 + let 520 + output = builtins.getAttr %s attrs; 521 + in 522 + if builtins.isAttrs output && output ? drvPath 523 + then output.drvPath 524 + else throw "flake output is not a derivation"` 525 + 526 + directDrvApply = `output: 527 + if builtins.isAttrs output && output ? drvPath 528 + then output.drvPath 529 + else throw "flake output is not a derivation"` 530 + 531 + homeActivationDrvApply = `attrs: 532 + let 533 + output = (builtins.getAttr %s attrs).activationPackage; 534 + in 535 + if builtins.isAttrs output && output ? drvPath 536 + then output.drvPath 537 + else throw "home configuration activationPackage is not a derivation"` 538 + 539 + toplevelDrvApply = `attrs: 540 + let 541 + output = (builtins.getAttr %s attrs).config.system.build.toplevel; 542 + in 543 + if builtins.isAttrs output && output ? drvPath 544 + then output.drvPath 545 + else throw "system configuration toplevel is not a derivation"` 546 + ) 547 + 548 + // currentSystem comes from nix itself, but it still lands inside an 549 + // installable argv string, so pin it to the charset real system strings use 550 + // before trusting it 551 + var systemPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) 552 + 553 + // parentInstallable builds the installable for a whole category 554 + // (home/nixos/darwinConfigurations) or a per-system category attrset 555 + // (packages/checks/devShells/formatter/defaultPackage/devShell) 556 + func parentInstallable(category, system string) (string, error) { 557 + if system == "" { 558 + return ".#" + category, nil 559 + } 560 + if !systemPattern.MatchString(system) { 561 + return "", fmt.Errorf("refusing to build an installable from unsafe system string %q", system) 562 + } 563 + return ".#" + category + "." + system, nil 564 + } 565 + 566 + // nix answers a probe of a category or system attr the flake doesn't define 567 + // with "does not provide attribute" on stderr, like garnix's 568 + // isDoesNotProvideAttributeError, probes treat that as "absent" rather than 569 + // a workflow failure. older nix phrases it as "attribute ... missing" 570 + func isMissingAttrError(stderr string) bool { 571 + return strings.Contains(stderr, "does not provide attribute") || 572 + (strings.Contains(stderr, "error: attribute") && strings.Contains(stderr, "missing")) 573 + } 574 + 575 + type flakeLockNode struct { 576 + Original map[string]any `json:"original"` 577 + Locked map[string]any `json:"locked"` 578 + } 579 + 580 + // authorizeFlakeInputs decides which fetched sources a flake may pull from 581 + // before we evaluate it any further. anything not explicitly permitted here 582 + // is rejected 583 + func authorizeFlakeInputs(metadataBytes []byte, workspaceDir string) error { 584 + var meta struct { 585 + Locks struct { 586 + Root string `json:"root"` 587 + Nodes map[string]flakeLockNode `json:"nodes"` 588 + } `json:"locks"` 589 + } 590 + if err := json.Unmarshal(metadataBytes, &meta); err != nil { 591 + return fmt.Errorf("parsing flake metadata JSON: %w", err) 592 + } 593 + 594 + root := meta.Locks.Root 595 + if root == "" { 596 + root = "root" 597 + } 598 + 599 + for name, node := range meta.Locks.Nodes { 600 + if name == root { 601 + continue 602 + } 603 + if err := authorizeFlakeInput(name, node, workspaceDir); err != nil { 604 + return err 605 + } 606 + } 607 + return nil 608 + } 609 + 610 + func authorizeFlakeInput(name string, node flakeLockNode, workspaceDir string) error { 611 + // what the user wrote is authoritative, only indirect (registry) inputs 612 + // get their fetch source from the lock 613 + target := node.Original 614 + if origType, _ := node.Original["type"].(string); origType == "indirect" { 615 + target = node.Locked 616 + } 617 + if target == nil { 618 + // a follows-only node fetches nothing 619 + return nil 620 + } 621 + 622 + typ, _ := target["type"].(string) 623 + switch typ { 624 + case "github", "gitlab", "sourcehut", "tarball": 625 + return nil 626 + case "path": 627 + return authorizePathInput(name, target, workspaceDir) 628 + case "file": 629 + return authorizeURLInput(name, typ, target, "http", "https") 630 + case "git", "hg": 631 + return authorizeURLInput(name, typ, target, "http", "https", "ssh") 632 + default: 633 + return fmt.Errorf("flake input %q uses unauthorized type %q", name, typ) 634 + } 635 + } 636 + 637 + // path inputs must stay relative and land inside the checked-out workspace; 638 + // anything absolute or escaping is a sandbox breakout attempt 639 + func authorizePathInput(name string, target map[string]any, workspaceDir string) error { 640 + p, _ := target["path"].(string) 641 + if p == "" { 642 + return fmt.Errorf("flake input %q is a path input without a path", name) 643 + } 644 + if filepath.IsAbs(p) { 645 + return fmt.Errorf("flake input %q uses absolute path %q", name, p) 646 + } 647 + 648 + canonicalWorkspace, err := filepath.EvalSymlinks(workspaceDir) 649 + if err != nil { 650 + return fmt.Errorf("resolving workspace dir: %w", err) 651 + } 652 + joined := filepath.Join(canonicalWorkspace, filepath.Clean(p)) 653 + if joined != canonicalWorkspace && 654 + !strings.HasPrefix(joined, canonicalWorkspace+string(filepath.Separator)) { 655 + return fmt.Errorf("flake input %q path %q escapes the workspace", name, p) 656 + } 657 + // a lexically-inside path can still escape through a symlink, so re-check 658 + // the canonical target when it exists, nonexistent paths are created by 659 + // the fetch inside the sandbox and stay where the lexical check put them 660 + if canonical, err := filepath.EvalSymlinks(joined); err == nil { 661 + if canonical != canonicalWorkspace && 662 + !strings.HasPrefix(canonical, canonicalWorkspace+string(filepath.Separator)) { 663 + return fmt.Errorf("flake input %q path %q escapes the workspace through a symlink", name, p) 664 + } 665 + } 666 + return nil 667 + } 668 + 669 + func authorizeURLInput(name, typ string, target map[string]any, schemes ...string) error { 670 + raw, _ := target["url"].(string) 671 + u, err := url.Parse(raw) 672 + if err != nil { 673 + return fmt.Errorf("flake input %q has unparseable url %q: %w", name, raw, err) 674 + } 675 + for _, scheme := range schemes { 676 + if u.Scheme == scheme { 677 + return nil 678 + } 679 + } 680 + return fmt.Errorf("flake input %q (%s) url %q must use one of: %s", name, typ, raw, strings.Join(schemes, ", ")) 681 + } 682 + 683 + // discover evaluates the checked-out flake and appends one build step per 684 + // buildable output to the workflow 685 + func (e *Engine) discover(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, wfLogger models.WorkflowLogger) error { 686 + val, ok := e.workspaces.Load(wid) 687 + if !ok { 688 + return errors.New("no workspace for workflow; SetupWorkflow must run first") 689 + } 690 + addl := val.(addlFields) 691 + 692 + stdout := wfLogger.DataWriter(idx, "stdout") 693 + stderr := wfLogger.DataWriter(idx, "stderr") 694 + 695 + sysBytes, err := e.captureEval(ctx, addl, 696 + []string{"eval", "--impure", "--raw", "--expr", "builtins.currentSystem"}, stderr) 697 + if err != nil { 698 + return fmt.Errorf("determining currentSystem: %w", err) 699 + } 700 + system := strings.TrimSpace(string(sysBytes)) 701 + if system == "" { 702 + return errors.New("nix reported an empty builtins.currentSystem") 703 + } 704 + if !systemPattern.MatchString(system) { 705 + return fmt.Errorf("nix reported an unsafe currentSystem %q", system) 706 + } 707 + 708 + metaBytes, err := e.captureEval(ctx, addl, []string{"flake", "metadata", "--json", "."}, stderr) 709 + if err != nil { 710 + return fmt.Errorf("reading flake metadata: %w", err) 711 + } 712 + if err := authorizeFlakeInputs(metaBytes, addl.workspaceDir); err != nil { 713 + return fmt.Errorf("authorizing flake inputs: %w", err) 714 + } 715 + 716 + var candidates []outputCandidate 717 + 718 + for _, category := range []string{"packages", "checks", "devShells"} { 719 + names, err := e.evalNames(ctx, addl, category, system) 720 + if err != nil { 721 + return fmt.Errorf("listing %s for %s: %w", category, system, err) 722 + } 723 + for _, name := range names { 724 + candidates = append(candidates, outputCandidate{ 725 + kind: targetSystemOutput, category: category, system: system, name: name, 726 + }) 727 + } 728 + } 729 + 730 + for _, category := range []string{"formatter", "defaultPackage", "devShell"} { 731 + present, err := e.evalPresent(ctx, addl, category, system) 732 + if err != nil { 733 + return fmt.Errorf("checking %s.%s: %w", category, system, err) 734 + } 735 + if present { 736 + candidates = append(candidates, outputCandidate{ 737 + kind: targetDirect, category: category, system: system, 738 + }) 739 + } 740 + } 741 + 742 + homeNames, err := e.evalNames(ctx, addl, "homeConfigurations", "") 743 + if err != nil { 744 + return fmt.Errorf("listing homeConfigurations: %w", err) 745 + } 746 + for _, name := range homeNames { 747 + candidates = append(candidates, outputCandidate{ 748 + kind: targetHomeActivation, category: "homeConfigurations", name: name, 749 + }) 750 + } 751 + 752 + for _, category := range []string{"nixosConfigurations", "darwinConfigurations"} { 753 + names, err := e.evalNames(ctx, addl, category, "") 754 + if err != nil { 755 + return fmt.Errorf("listing %s: %w", category, err) 756 + } 757 + for _, name := range names { 758 + candidates = append(candidates, outputCandidate{ 759 + kind: targetToplevel, category: category, name: name, 760 + }) 761 + } 762 + } 763 + 764 + if err := checkOutputLimit(len(candidates), e.maxOutputs()); err != nil { 765 + return err 766 + } 767 + 768 + for _, cand := range candidates { 769 + drvPath, err := e.resolveDrvPath(ctx, addl, cand, stderr) 770 + if err != nil { 771 + return err 772 + } 773 + step := Step{ 774 + name: "Build " + cand.display(), 775 + command: fmt.Sprintf("nix build --no-link --print-build-logs %s^*", drvPath), 776 + drvPath: drvPath, 777 + kind: models.StepKindUser, 778 + } 779 + wf.Steps = append(wf.Steps, step) 780 + _, _ = fmt.Fprintln(stdout, step.Name()) 781 + } 782 + 783 + return nil 784 + } 785 + 786 + // captureEvalProbe captures stderr instead of teeing it to the workflow log: 787 + // presence/name probes hit attributes the flake may not define, and nix's 788 + // "does not provide attribute" spew would read as a failure to users 789 + func (e *Engine) captureEvalProbe(ctx context.Context, addl addlFields, nixArgs []string) (stdout, stderr []byte, err error) { 790 + errBuf := new(bytes.Buffer) 791 + executable, argv := e.evalArgv(nixArgs) 792 + out, runErr := e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, errBuf) 793 + return out, errBuf.Bytes(), runErr 794 + } 795 + 796 + // evalNames lists the outputs inside a category or per-system category 797 + // parent attrset, a flake that doesn't define the category or system yields 798 + // an empty list, not an error 799 + func (e *Engine) evalNames(ctx context.Context, addl addlFields, category, system string) ([]string, error) { 800 + installable, err := parentInstallable(category, system) 801 + if err != nil { 802 + return nil, err 803 + } 804 + out, probeErr, err := e.captureEvalProbe(ctx, addl, 805 + []string{"eval", installable, "--apply", attrNamesApply, "--json"}) 806 + if err != nil { 807 + if isMissingAttrError(string(probeErr)) { 808 + return nil, nil 809 + } 810 + return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr))) 811 + } 812 + var names []string 813 + if err := json.Unmarshal(out, &names); err != nil { 814 + return nil, fmt.Errorf("parsing output names JSON: %w", err) 815 + } 816 + sort.Strings(names) 817 + return names, nil 818 + } 819 + 820 + // evalPresent probes a direct output (formatter/defaultPackage/devShell); 821 + // a flake that doesn't define it yields false, not an error 822 + func (e *Engine) evalPresent(ctx context.Context, addl addlFields, category, system string) (bool, error) { 823 + installable, err := parentInstallable(category, system) 824 + if err != nil { 825 + return false, err 826 + } 827 + _, probeErr, err := e.captureEvalProbe(ctx, addl, 828 + []string{"eval", installable, "--apply", presentApply, "--json"}) 829 + if err != nil { 830 + if isMissingAttrError(string(probeErr)) { 831 + return false, nil 832 + } 833 + return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr))) 834 + } 835 + return true, nil 836 + } 837 + 838 + // resolveDrvPath pins a candidate to a concrete store derivation, anything 839 + // that isn't a derivation fails the workflow rather than being skipped 840 + func (e *Engine) resolveDrvPath(ctx context.Context, addl addlFields, cand outputCandidate, stderr io.Writer) (string, error) { 841 + installable, apply, err := cand.drvTarget() 842 + if err != nil { 843 + return "", err 844 + } 845 + 846 + out, err := e.captureEval(ctx, addl, 847 + []string{"eval", installable, "--apply", apply, "--raw"}, stderr) 848 + if err != nil { 849 + return "", fmt.Errorf("resolving %s: %w", cand.display(), err) 850 + } 851 + drvPath := strings.TrimSpace(string(out)) 852 + if !isValidDrvPath(drvPath) { 853 + return "", fmt.Errorf("%s resolved to invalid derivation path %q", cand.display(), drvPath) 854 + } 855 + return drvPath, nil 856 + } 857 + 858 + // the resolved path becomes a build installable, so pin its shape before 859 + // trusting it 860 + func isValidDrvPath(p string) bool { 861 + return strings.HasPrefix(p, "/nix/store/") && 862 + strings.HasSuffix(p, ".drv") && 863 + !strings.ContainsAny(p, " \t\n") 864 + } 865 + 866 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 867 + if idx < 0 || idx >= len(wf.Steps) { 868 + return fmt.Errorf("step index %d out of range (%d steps)", idx, len(wf.Steps)) 869 + } 870 + 871 + val, ok := e.workspaces.Load(wid) 872 + if !ok { 873 + return errors.New("no workspace for workflow; SetupWorkflow must run first") 874 + } 875 + addl := val.(addlFields) 876 + 877 + var err error 878 + switch s := wf.Steps[idx].(type) { 879 + case models.CloneStep: 880 + err = e.runSandbox(ctx, addl, containerBash, 881 + []string{"-euo", "pipefail", "-c", s.Command()}, 882 + wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr")) 883 + case Step: 884 + switch { 885 + case s.isDiscovery: 886 + err = e.discover(ctx, wid, wf, idx, wfLogger) 887 + case s.drvPath != "": 888 + err = e.runSandbox(ctx, addl, containerNix, 889 + []string{"--extra-experimental-features", "nix-command flakes", "build", "--no-link", "--print-build-logs", s.drvPath + "^*"}, 890 + wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr")) 891 + default: 892 + err = fmt.Errorf("nix engine step %q has nothing to run", s.Name()) 893 + } 894 + default: 895 + err = fmt.Errorf("unknown step type %T", wf.Steps[idx]) 896 + } 897 + 898 + if err != nil { 899 + // a cancellation must survive untouched, only a deadline is a timeout 900 + if ctxErr := ctx.Err(); ctxErr != nil { 901 + if errors.Is(ctxErr, context.DeadlineExceeded) { 902 + return fmt.Errorf("%w: %v", engine.ErrTimedOut, ctxErr) 903 + } 904 + return ctxErr 905 + } 906 + return err 907 + } 908 + return nil 909 + }
+374
spindle/engines/nix/engine_test.go
··· 1 + package nix 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + 12 + "github.com/stretchr/testify/assert" 13 + "github.com/stretchr/testify/require" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/spindle/config" 16 + "tangled.org/core/spindle/models" 17 + "tangled.org/core/workflow" 18 + ) 19 + 20 + func TestInitWorkflow(t *testing.T) { 21 + e := &Engine{cfg: &config.Config{}} 22 + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{}) 23 + require.NoError(t, err) 24 + require.Len(t, wf.Steps, 1) 25 + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 26 + assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind()) 27 + } 28 + 29 + func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) { 30 + e := &Engine{cfg: &config.Config{}} 31 + raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n" 32 + compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{ 33 + Name: ".tangled/workflows/ci.yml", 34 + Engine: "nix", 35 + Raw: raw, 36 + }}) 37 + require.Len(t, compiled.Workflows, 1) 38 + require.Equal(t, raw, compiled.Workflows[0].Raw) 39 + 40 + wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{}) 41 + require.NoError(t, err) 42 + require.Len(t, wf.Steps, 1) 43 + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 44 + } 45 + 46 + func TestInitWorkflowRejectsCustomSteps(t *testing.T) { 47 + e := &Engine{cfg: &config.Config{}} 48 + _, err := e.InitWorkflow(tangled.Pipeline_Workflow{ 49 + Name: "flake", 50 + Raw: "steps:\n - name: nope\n command: echo nope\n", 51 + }, tangled.Pipeline{}) 52 + require.Error(t, err) 53 + assert.Contains(t, err.Error(), "unknown field") 54 + } 55 + 56 + // a generic workflow document (engine/when/clone, no engine-specific keys) 57 + // must initialize, only structural keys like `steps` are rejected 58 + func TestInitWorkflowAcceptsGenericKeys(t *testing.T) { 59 + e := &Engine{cfg: &config.Config{}} 60 + raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n depth: 1\n" 61 + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{}) 62 + require.NoError(t, err) 63 + require.Len(t, wf.Steps, 1) 64 + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 65 + 66 + _, err = e.InitWorkflow(tangled.Pipeline_Workflow{ 67 + Name: "flake", 68 + Raw: raw + "steps:\n - name: nope\n command: echo nope\n", 69 + }, tangled.Pipeline{}) 70 + require.Error(t, err) 71 + assert.Contains(t, err.Error(), "unknown field") 72 + } 73 + 74 + // every eval/metadata invocation needs the flakes features flags because the 75 + // sandbox HOME has no nix.conf 76 + func TestEvalArgv(t *testing.T) { 77 + e := &Engine{cfg: &config.Config{}} 78 + exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."}) 79 + assert.Equal(t, containerPrlimit, exe) 80 + require.GreaterOrEqual(t, len(argv), 6) 81 + assert.True(t, strings.HasPrefix(argv[0], "--as=")) 82 + assert.Equal(t, containerNix, argv[1]) 83 + assert.Equal(t, "--extra-experimental-features", argv[2]) 84 + assert.Equal(t, "nix-command flakes", argv[3]) 85 + assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:]) 86 + } 87 + 88 + func TestNixStringLit(t *testing.T) { 89 + cases := map[string]string{ 90 + "plain": `"plain"`, 91 + "dots.and%percent": `"dots.and%percent"`, 92 + `quote"injection`: `"quote\"injection"`, 93 + `interp${pwn}`: `"interp\${pwn}"`, 94 + `back\slash`: `"back\\slash"`, 95 + `"; throw "escaped`: `"\"; throw \"escaped"`, 96 + "newline\ninside": "\"newline\ninside\"", // literal newlines are legal in nix strings 97 + } 98 + for in, want := range cases { 99 + assert.Equal(t, want, nixStringLit(in), "input %q", in) 100 + } 101 + } 102 + 103 + // metacharacter attr names stay structured: the installable is only the 104 + // trusted category.system parent, the apply lambda sees just the escaped 105 + // name, display is for humans 106 + func TestOutputCandidateMetachars(t *testing.T) { 107 + cand := outputCandidate{ 108 + kind: targetSystemOutput, 109 + category: "checks", 110 + system: "x86_64-linux", 111 + name: `unit.100%"cov${throw}`, 112 + } 113 + installable, apply, err := cand.drvTarget() 114 + require.NoError(t, err) 115 + assert.Equal(t, ".#checks.x86_64-linux", installable) 116 + assert.Contains(t, apply, nixStringLit(cand.name)) 117 + assert.NotContains(t, apply, cand.name) 118 + assert.NotContains(t, installable, cand.name) 119 + assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display()) 120 + 121 + _, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget() 122 + require.Error(t, err) 123 + } 124 + 125 + // only validated system strings may reach an installable, anything else is 126 + // refused before it becomes argv 127 + func TestParentInstallable(t *testing.T) { 128 + installable, err := parentInstallable("packages", "x86_64-linux") 129 + require.NoError(t, err) 130 + assert.Equal(t, ".#packages.x86_64-linux", installable) 131 + 132 + installable, err = parentInstallable("nixosConfigurations", "") 133 + require.NoError(t, err) 134 + assert.Equal(t, ".#nixosConfigurations", installable) 135 + 136 + for _, system := range []string{ 137 + `x86_64-linux"; throw "`, 138 + "x86_64-linux ${throw 1}", 139 + "x86_64-linux --option", 140 + "x86_64-linux/x", 141 + "x86_64 linux", 142 + } { 143 + _, err := parentInstallable("packages", system) 144 + require.Error(t, err, "system %q", system) 145 + assert.Contains(t, err.Error(), "unsafe system") 146 + } 147 + } 148 + 149 + // a flake that doesn't define a probed category is "absent", not broken; 150 + // other evaluation failures must not be swallowed 151 + func TestIsMissingAttrError(t *testing.T) { 152 + // verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake 153 + // without checks 154 + assert.True(t, isMissingAttrError( 155 + "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'")) 156 + // older nix phrasing 157 + assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing")) 158 + 159 + assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file")) 160 + assert.False(t, isMissingAttrError("error: flake output is not a derivation")) 161 + assert.False(t, isMissingAttrError("")) 162 + } 163 + 164 + func TestCheckOutputLimit(t *testing.T) { 165 + require.NoError(t, checkOutputLimit(0, 100)) 166 + require.NoError(t, checkOutputLimit(100, 100)) 167 + err := checkOutputLimit(101, 100) 168 + require.Error(t, err) 169 + assert.Contains(t, err.Error(), "101") 170 + assert.Contains(t, err.Error(), "100") 171 + } 172 + 173 + func TestMaxOutputsDefault(t *testing.T) { 174 + assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs()) 175 + assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs()) 176 + } 177 + 178 + func TestMaxLogWriterOverflow(t *testing.T) { 179 + var buf bytes.Buffer 180 + canceled := false 181 + mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }} 182 + w := &limitedStreamWriter{parent: mw, out: &buf} 183 + n, err := w.Write([]byte("0123456789")) 184 + require.NoError(t, err) 185 + require.Equal(t, 10, n) 186 + assert.False(t, mw.exceeded) 187 + 188 + n, err = w.Write([]byte("overflow")) 189 + require.Error(t, err) 190 + assert.Equal(t, 0, n) 191 + assert.Contains(t, err.Error(), "exceeded the 10 byte limit") 192 + assert.True(t, mw.exceeded) 193 + assert.True(t, canceled) 194 + assert.Equal(t, "0123456789", buf.String()) 195 + 196 + _, err = w.Write([]byte("more")) 197 + require.Error(t, err) 198 + assert.Equal(t, "0123456789", buf.String()) 199 + } 200 + 201 + func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) { 202 + var buf bytes.Buffer 203 + mw := &maxLogWriter{limit: 5} 204 + w := &limitedStreamWriter{parent: mw, out: &buf} 205 + n, err := w.Write([]byte("0123456789")) 206 + require.Error(t, err) 207 + assert.Equal(t, 5, n) 208 + assert.Equal(t, "01234", buf.String()) 209 + assert.True(t, mw.exceeded) 210 + } 211 + 212 + func TestAuthorizePathInput(t *testing.T) { 213 + workspace := t.TempDir() 214 + outside := t.TempDir() 215 + 216 + require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755)) 217 + require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace)) 218 + require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace)) 219 + require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace)) 220 + 221 + require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace)) 222 + require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace)) 223 + require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace)) 224 + 225 + require.Error(t, authorizePathInput("g", map[string]any{}, workspace)) 226 + 227 + require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link"))) 228 + require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace)) 229 + 230 + // a lexically-inside path can still escape through a symlink 231 + require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape"))) 232 + err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace) 233 + require.Error(t, err) 234 + assert.Contains(t, err.Error(), "symlink") 235 + 236 + require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755)) 237 + require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape"))) 238 + require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace)) 239 + } 240 + 241 + func TestInitWorkflowWithClone(t *testing.T) { 242 + e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}} 243 + repoName := "my-repo" 244 + repoDid := "did:plc:repo" 245 + tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ 246 + Kind: string(workflow.TriggerKindPush), 247 + Push: &tangled.Pipeline_PushTriggerData{ 248 + Ref: "refs/heads/main", 249 + NewSha: "1234567890abcdef1234567890abcdef12345678", 250 + }, 251 + Repo: &tangled.Pipeline_TriggerRepo{ 252 + Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid, 253 + }, 254 + }} 255 + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl) 256 + require.NoError(t, err) 257 + require.Len(t, wf.Steps, 2) 258 + assert.IsType(t, models.CloneStep{}, wf.Steps[0]) 259 + assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name()) 260 + } 261 + 262 + func TestBaseBwrapArgs(t *testing.T) { 263 + e := &Engine{ 264 + nixBinPath: "/nix/store/nix/bin/nix", 265 + bashBinPath: "/nix/store/bash/bin/bash", 266 + gitBinPath: "/nix/store/git/bin/git", 267 + prlimitBinPath: "/nix/store/prlimit/bin/prlimit", 268 + } 269 + args := e.baseBwrapArgs(addlFields{ 270 + workspaceDir: "/tmp/host/workspace", 271 + homeDir: "/tmp/host/home", 272 + }) 273 + argString := strings.Join(args, " ") 274 + for _, expected := range []string{ 275 + "--die-with-parent", "--new-session", "--unshare-all --share-net", 276 + "--ro-bind /nix/store /nix/store", 277 + "--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket", 278 + "--proc /proc", "--dev /dev", "--tmpfs /tmp", 279 + "--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home", 280 + "--symlink /nix/store/nix/bin/nix /usr/bin/nix", 281 + "--symlink /nix/store/bash/bin/bash /usr/bin/bash", 282 + "--symlink /nix/store/bash/bin/bash /bin/sh", 283 + "--symlink /nix/store/git/bin/git /usr/bin/git", 284 + "--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit", 285 + "--chdir /workdir", 286 + } { 287 + assert.Contains(t, argString, expected) 288 + } 289 + assert.NotContains(t, argString, "nixpkgs#") 290 + assert.NotContains(t, argString, " shell ") 291 + } 292 + 293 + type mockWorkflowLogger struct { 294 + stdout strings.Builder 295 + stderr strings.Builder 296 + } 297 + 298 + func (m *mockWorkflowLogger) Close() error { return nil } 299 + func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer { 300 + if stream == "stderr" { 301 + return &m.stderr 302 + } 303 + return &m.stdout 304 + } 305 + func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer { 306 + return io.Discard 307 + } 308 + 309 + func TestIntegrationRealFlakeBuilds(t *testing.T) { 310 + if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" { 311 + t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run") 312 + } 313 + 314 + cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}} 315 + e, err := New(cfg) 316 + require.NoError(t, err) 317 + wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{}) 318 + require.NoError(t, err) 319 + wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"} 320 + logger := &mockWorkflowLogger{} 321 + require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger)) 322 + defer e.DestroyWorkflow(context.Background(), wid) 323 + 324 + addl := wf.Data.(addlFields) 325 + flake := `{ 326 + inputs.nixpkgs.url = "nixpkgs"; 327 + outputs = { nixpkgs, ... }: 328 + let 329 + system = "x86_64-linux"; 330 + pkgs = nixpkgs.legacyPackages.${system}; 331 + in { 332 + packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out"; 333 + checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out"; 334 + formatter.${system} = pkgs.hello; 335 + devShells.${system}.ci = pkgs.mkShell {}; 336 + }; 337 + } 338 + ` 339 + require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600)) 340 + 341 + // the loop bound is evaluated per iteration, so build steps appended by 342 + // the discovery step run in the same pass 343 + for idx := 0; idx < len(wf.Steps); idx++ { 344 + require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String()) 345 + } 346 + 347 + require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds") 348 + assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 349 + var names []string 350 + for _, step := range wf.Steps[1:] { 351 + s, ok := step.(Step) 352 + require.True(t, ok, "generated step %q is a nix engine Step", step.Name()) 353 + require.NotEmpty(t, s.drvPath, s.Name()) 354 + assert.Contains(t, s.Command(), "^*", s.Name()) 355 + names = append(names, s.Name()) 356 + } 357 + for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} { 358 + assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment) 359 + } 360 + 361 + require.NoError(t, e.DestroyWorkflow(context.Background(), wid)) 362 + assert.NoDirExists(t, addl.workspaceDir) 363 + assert.NoDirExists(t, addl.homeDir) 364 + assert.NoError(t, e.DestroyWorkflow(context.Background(), wid)) 365 + } 366 + 367 + func containsName(names []string, fragment string) bool { 368 + for _, name := range names { 369 + if strings.Contains(name, fragment) { 370 + return true 371 + } 372 + } 373 + return false 374 + }
+12 -2
spindle/server.go
··· 37 37 "tangled.org/core/spindle/engine" 38 38 "tangled.org/core/spindle/engines/dummy" 39 39 "tangled.org/core/spindle/engines/microvm" 40 + nixengine "tangled.org/core/spindle/engines/nix" 40 41 "tangled.org/core/spindle/engines/nixery" 41 42 "tangled.org/core/spindle/git" 42 43 "tangled.org/core/spindle/models" ··· 357 358 return err 358 359 } 359 360 360 - s, err := New(ctx, cfg, d, map[string]models.Engine{ 361 + engines := map[string]models.Engine{ 361 362 "nixery": nixeryEng, 362 363 "microvm": microvmEng, 363 364 "dummy": dummy.New(log.FromContext(ctx)), 364 - }) 365 + } 366 + if cfg.NixPipelines.Enabled { 367 + nixEng, err := nixengine.New(cfg) 368 + if err != nil { 369 + return fmt.Errorf("setting up nix engine: %w", err) 370 + } 371 + engines["nix"] = nixEng 372 + } 373 + 374 + s, err := New(ctx, cfg, d, engines) 365 375 if err != nil { 366 376 return err 367 377 }