This repository has no description
0

Configure Feed

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

core / spindle / engines / nix / engine.go
29 kB 909 lines
1package nix 2 3import ( 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 30const maxEvalOutputBytes = 32 << 20 31 32// paths inside the bwrap namespace, populated by symlink in baseBwrapArgs 33const ( 34 containerNix = "/usr/bin/nix" 35 containerBash = "/usr/bin/bash" 36 containerPrlimit = "/usr/bin/prlimit" 37) 38 39type 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 52type addlFields struct { 53 workspaceDir string 54 homeDir string 55} 56 57type Step struct { 58 name string 59 command string 60 drvPath string 61 kind models.StepKind 62 isDiscovery bool 63} 64 65func (s Step) Name() string { return s.name } 66func (s Step) Command() string { return s.command } 67func (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 71type targetKind int 72 73const ( 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 84func (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 108func 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 117type 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 125func (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 140func 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 177func 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 188func (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 211func (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 215func (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 257func (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 264func (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 276func (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 285func (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 348type maxLogWriter struct { 349 mu sync.Mutex 350 written int64 351 limit int64 352 cancel context.CancelFunc 353 exceeded bool 354} 355 356type limitedStreamWriter struct { 357 parent *maxLogWriter 358 out io.Writer 359} 360 361func (w *limitedStreamWriter) Write(p []byte) (int, error) { 362 return w.parent.write(w.out, p) 363} 364 365func (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 397func (e *Engine) maxLogBytes() int64 { 398 if e.cfg == nil { 399 return 0 400 } 401 return e.cfg.NixPipelines.MaxLogBytes 402} 403 404func (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 411func (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 420func (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 438func (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 456func (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 485func (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 494func (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 502func 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 510const ( 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: 519let 520 output = builtins.getAttr %s attrs; 521in 522if builtins.isAttrs output && output ? drvPath 523then output.drvPath 524else throw "flake output is not a derivation"` 525 526 directDrvApply = `output: 527if builtins.isAttrs output && output ? drvPath 528then output.drvPath 529else throw "flake output is not a derivation"` 530 531 homeActivationDrvApply = `attrs: 532let 533 output = (builtins.getAttr %s attrs).activationPackage; 534in 535if builtins.isAttrs output && output ? drvPath 536then output.drvPath 537else throw "home configuration activationPackage is not a derivation"` 538 539 toplevelDrvApply = `attrs: 540let 541 output = (builtins.getAttr %s attrs).config.system.build.toplevel; 542in 543if builtins.isAttrs output && output ? drvPath 544then output.drvPath 545else 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 551var 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) 556func 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" 570func 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 575type 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 583func 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 610func 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 639func 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 669func 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 685func (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 789func (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 799func (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 822func (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 840func (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 860func isValidDrvPath(p string) bool { 861 return strings.HasPrefix(p, "/nix/store/") && 862 strings.HasSuffix(p, ".drv") && 863 !strings.ContainsAny(p, " \t\n") 864} 865 866func (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}