| @@ -14,6 +14,7 @@ import ( |
| 14 | 14 | "flag" |
| 15 | 15 | "fmt" |
| 16 | 16 | "io" |
| 17 | "io/fs" |
| 17 | 18 | "log" |
| 18 | 19 | "os" |
| 19 | 20 | "os/exec" |
| @@ -30,14 +31,18 @@ import ( |
| 30 | 31 | ) |
| 31 | 32 | |
| 32 | 33 | type job struct { |
| 33 | | ID int64 `json:"id"` |
| 34 | | Repo string `json:"repo"` |
| 35 | | Number int64 `json:"number"` |
| 36 | | Job string `json:"job"` |
| 37 | | SHA string `json:"sha"` |
| 38 | | Ref string `json:"ref"` |
| 39 | | Steps []string `json:"steps"` |
| 40 | | Image string `json:"image"` |
| 34 | ID int64 `json:"id"` |
| 35 | Repo string `json:"repo"` |
| 36 | Number int64 `json:"number"` |
| 37 | Job string `json:"job"` |
| 38 | SHA string `json:"sha"` |
| 39 | Ref string `json:"ref"` |
| 40 | Steps []string `json:"steps"` |
| 41 | Image string `json:"image"` |
| 42 | // Trusted is false for a merge request head from a fork, and when the |
| 43 | // server did not say: such a build gets no secrets and a home of its |
| 44 | // own (#255). |
| 45 | Trusted bool `json:"trusted"` |
| 41 | 46 | Secrets map[string]string `json:"secrets"` |
| 42 | 47 | } |
| 43 | 48 | |
| @@ -312,22 +317,12 @@ func (r *runner) run(j job) bool { |
| 312 | 317 | dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID)) |
| 313 | 318 | defer os.RemoveAll(dir) |
| 314 | 319 | |
| 315 | | // A build's HOME. Not the workspace, which is removed after every |
| 316 | | // build: the Go module cache, the sonar scanner and every other tool |
| 317 | | // cache live under HOME, so a per-build one re-downloads the world |
| 318 | | // each time. Not the runner's own home either, where its SSH key and |
| 319 | | // credential dotfiles are. A directory beside the workspaces is |
| 320 | | // neither. |
| 321 | | // |
| 322 | | // One per repository: shared across repositories, a step could poison |
| 323 | | // the module cache or plant a .gitconfig that another repository's |
| 324 | | // build would honour, and the container mounts the home read-write |
| 325 | | // (#184). |
| 326 | | buildHome, err := buildHomeFor(r.workdir, j.Repo) |
| 320 | home, doneHome, err := buildHome(r.workdir, j) |
| 327 | 321 | if err != nil { |
| 328 | 322 | log.Printf("build %d: build home: %v", j.ID, err) |
| 329 | 323 | return false |
| 330 | 324 | } |
| 325 | defer doneHome() |
| 331 | 326 | |
| 332 | 327 | // One long-lived `runner log` session receives the whole stream. |
| 333 | 328 | logCmd := exec.Command(toolpath.Look("ssh"), append(r.sshOpts, r.remote, "runner", "log", fmt.Sprint(j.ID))...) |
| @@ -430,36 +425,62 @@ func (r *runner) run(j job) bool { |
| 430 | 425 | } |
| 431 | 426 | } |
| 432 | 427 | |
| 433 | | env := stepEnv(j, buildHome, r.buildSSH()) |
| 428 | env := stepEnv(j, home, r.buildSSH()) |
| 434 | 429 | return r.runSteps(j, dir, env, sink, deadline, runStep) |
| 435 | 430 | } |
| 436 | 431 | |
| 437 | | // stepEnv builds the environment a build step runs with. It is |
| 438 | | // constructed, not inherited: os.Environ() would hand repository content |
| 439 | | // the runner's entire environment, including anything an operator set on |
| 440 | | // the service (#144). |
| 432 | // buildHome is a build's HOME and what to do with it when the build ends. |
| 441 | 433 | // |
| 442 | | // HOME is the repository's build home, not the runner's own: tools read |
| 443 | | // credentials out of dotfiles — .netrc, .npmrc, .gitconfig — and a build |
| 444 | | // has no business finding the runner's. It is not the workspace either, |
| 445 | | // because the workspace is deleted after every build and every tool |
| 446 | | // cache lives under HOME. |
| 434 | // Not the workspace, which is removed after every build: the Go module |
| 435 | // cache and every other tool cache live under HOME. Not the runner's own |
| 436 | // home either, where its SSH key and credential dotfiles are. |
| 447 | 437 | // |
| 448 | | // PATH is the one thing carried over: without it a step cannot find the |
| 449 | | // tools the host was provisioned with. |
| 450 | | // buildHomeFor is the build home for one repository: <workdir>/home/<owner>/<name>, |
| 451 | | // created on first use. The repository path comes from the server, but a |
| 452 | | // home must still never resolve outside the home root. |
| 453 | | func buildHomeFor(workdir, repo string) (string, error) { |
| 454 | | root := filepath.Join(workdir, "home") |
| 455 | | dir := filepath.Join(root, filepath.FromSlash(repo)) |
| 438 | // A trusted build gets its repository's home, |
| 439 | // <workdir>/trusted-home/<owner>/<name>, kept between builds so the |
| 440 | // caches survive. One per repository: shared across repositories, a step |
| 441 | // could poison a cache or plant a .gitconfig that another repository's |
| 442 | // build would honour (#184). The root is not <workdir>/home, where homes |
| 443 | // that untrusted builds could write were kept before #255, so none of |
| 444 | // those is read again. |
| 445 | // |
| 446 | // An untrusted build gets <workdir>/build-<id>-home, new and empty, |
| 447 | // removed when the build ends. The container mounts HOME read-write, so |
| 448 | // a home a fork's build could write is a cache a stranger controls |
| 449 | // (#255). |
| 450 | func buildHome(workdir string, j job) (string, func(), error) { |
| 451 | if !j.Trusted { |
| 452 | dir := filepath.Join(workdir, fmt.Sprintf("build-%d-home", j.ID)) |
| 453 | if err := os.Mkdir(dir, 0o700); err != nil { |
| 454 | return "", nil, err |
| 455 | } |
| 456 | return dir, func() { |
| 457 | if err := removeTree(dir); err != nil { |
| 458 | log.Printf("build %d: removing its home: %v", j.ID, err) |
| 459 | } |
| 460 | }, nil |
| 461 | } |
| 462 | root := filepath.Join(workdir, "trusted-home") |
| 463 | dir := filepath.Join(root, filepath.FromSlash(j.Repo)) |
| 456 | 464 | if rel, err := filepath.Rel(root, dir); err != nil || rel == "." || strings.HasPrefix(rel, "..") { |
| 457 | | return "", fmt.Errorf("repository path %q escapes the build home root", repo) |
| 465 | return "", nil, fmt.Errorf("repository path %q escapes the build home root", j.Repo) |
| 458 | 466 | } |
| 459 | 467 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 460 | | return "", err |
| 468 | return "", nil, err |
| 461 | 469 | } |
| 462 | | return dir, nil |
| 470 | return dir, func() {}, nil |
| 471 | } |
| 472 | |
| 473 | // removeTree deletes dir and everything under it. os.RemoveAll alone |
| 474 | // fails on a directory without write permission, and the Go module cache |
| 475 | // makes every directory it fills read-only. |
| 476 | func removeTree(dir string) error { |
| 477 | filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { |
| 478 | if err == nil && d.IsDir() { |
| 479 | os.Chmod(p, 0o700) |
| 480 | } |
| 481 | return nil |
| 482 | }) |
| 483 | return os.RemoveAll(dir) |
| 463 | 484 | } |
| 464 | 485 | |
| 465 | 486 | // buildSSH is the instance's ssh destination as a build reaches it. Under |
| @@ -485,6 +506,17 @@ func (r *runner) buildSSH() string { |
| 485 | 506 | return "169.254.1.2" |
| 486 | 507 | } |
| 487 | 508 | |
| 509 | // stepEnv builds the environment a build step runs with. It is |
| 510 | // constructed, not inherited: os.Environ() would hand repository content |
| 511 | // the runner's entire environment, including anything an operator set on |
| 512 | // the service (#144). |
| 513 | // |
| 514 | // HOME is the build's home (buildHome), not the runner's own: tools read |
| 515 | // credentials out of dotfiles — .netrc, .npmrc, .gitconfig — and a build |
| 516 | // has no business finding the runner's. |
| 517 | // |
| 518 | // PATH is the one thing carried over: without it a step cannot find the |
| 519 | // tools the host was provisioned with. |
| 488 | 520 | func stepEnv(j job, home, sshDest string) []string { |
| 489 | 521 | path := os.Getenv("PATH") |
| 490 | 522 | if path == "" { |
| @@ -501,11 +533,12 @@ func stepEnv(j job, home, sshDest string) []string { |
| 501 | 533 | "GITBAY_JOB=" + j.Job, |
| 502 | 534 | "GITBAY_SSH=" + sshDest, |
| 503 | 535 | } |
| 504 | | // The server sends secrets only for a trusted build — a merge request |
| 505 | | // head from a fork arrives with none — so this loop is empty exactly |
| 506 | | // when it should be. |
| 507 | | for name, value := range j.Secrets { |
| 508 | | env = append(env, name+"="+value) |
| 536 | // The server sends secrets only for a trusted build. The claim's |
| 537 | // trust flag decides here as well, not whether any arrived (#255). |
| 538 | if j.Trusted { |
| 539 | for name, value := range j.Secrets { |
| 540 | env = append(env, name+"="+value) |
| 541 | } |
| 509 | 542 | } |
| 510 | 543 | return env |
| 511 | 544 | } |