vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Let a job budget its whole run
e7aa0785cb69ace8c1338cc0b924113ea52a7abb · Keenan Nemetz · 6d ago · parent 2bc0ae74
Let a job budget its whole run A step was capped at an hour and a job was not capped at all, so a job of several slow steps held a runner for as long as they took, with no number saying what the job was allowed to spend. And one long step — a session, a soak test, a slow suite — could not be expressed, because the only clock was per step and its ceiling was an hour. A job may now declare `timeout: 2h`: a wall clock its steps share, starting with the first step. A step that declares its own timeout is cut short when the budget ends first, and a step that declares none runs with whatever is left, so one long step needs only the job's number. A budget spent before a step starts stops the job there instead of running the step on its own cap. The budget is optional, pinned with the run, and spent by a local run the same way, so nothing changes for a workflow that does not declare one. The step ceiling rises from one hour to four to make a single long step possible; the default stays ten minutes. The image build is not charged to the budget — it has its own cap, so a slow pull cannot eat the time the steps were given. A step timeout longer than its job's budget is refused at push time: the budget would end first, so the step could never use what it asked for. Closes #161. # Conflicts: # ci/agent/agent.go # cmd/vole/workflowrun.go # docs/reference/workflow-reference.md
ci/workflow_test.go +120−2
| @@ -43,8 +43,13 @@ func TestValidWorkflowParses(t *testing.T) { | |||
| 43 | 43 | if got := len(wf.Jobs[0].Steps); got != 2 { | |
| 44 | 44 | t.Fatalf("steps = %d, want 2", got) | |
| 45 | 45 | } | |
| 46 | if wf.Jobs[0].Steps[0].Timeout != DefaultTimeout { | ||
| 47 | t.Fatalf("default timeout = %s", wf.Jobs[0].Steps[0].Timeout) | ||
| 46 | // A step that declares no timeout keeps no number of its own: what it gets is | ||
| 47 | // decided when it runs, from the job's budget or DefaultTimeout. | ||
| 48 | if wf.Jobs[0].Steps[0].Timeout != 0 { | ||
| 49 | t.Fatalf("an undeclared step timeout = %s, want zero", wf.Jobs[0].Steps[0].Timeout) | ||
| 50 | } | ||
| 51 | if got := StepLimit(-1, wf.Jobs[0].Steps[0].Timeout); got != DefaultTimeout { | ||
| 52 | t.Fatalf("an undeclared step runs for %s, want %s", got, DefaultTimeout) | ||
| 48 | 53 | } | |
| 49 | 54 | if wf.Jobs[0].Steps[1].Timeout != 20*time.Minute { | |
| 50 | 55 | t.Fatalf("explicit timeout = %s", wf.Jobs[0].Steps[1].Timeout) | |
| @@ -469,6 +474,119 @@ jobs: | |||
| 469 | 474 | } | |
| 470 | 475 | } | |
| 471 | 476 | ||
| 477 | // A job's timeout is the clock its steps share. It is optional, bounded, and a step | ||
| 478 | // that asks for longer than the job can give is a mistake worth naming at push time | ||
| 479 | // rather than discovering when the budget runs out. | ||
| 480 | func TestJobTimeoutIsParsedAndValidated(t *testing.T) { | ||
| 481 | t.Parallel() | ||
| 482 | // Saying nothing keeps the old behaviour: each step is bounded on its own. | ||
| 483 | wf, problems := Parse("ci.yaml", `version: 1 | ||
| 484 | jobs: | ||
| 485 | build: | ||
| 486 | image: alpine:3.22 | ||
| 487 | steps: | ||
| 488 | - run: true | ||
| 489 | `) | ||
| 490 | if len(problems) > 0 { | ||
| 491 | t.Fatalf("problems: %v", problems) | ||
| 492 | } | ||
| 493 | if got := wf.Jobs[0].Timeout; got != 0 { | ||
| 494 | t.Fatalf("timeout = %s, want none for a job that does not budget one", got) | ||
| 495 | } | ||
| 496 | |||
| 497 | wf, problems = Parse("ci.yaml", `version: 1 | ||
| 498 | jobs: | ||
| 499 | build: | ||
| 500 | image: alpine:3.22 | ||
| 501 | timeout: 90m | ||
| 502 | steps: | ||
| 503 | - run: true | ||
| 504 | timeout: 20m | ||
| 505 | `) | ||
| 506 | if len(problems) > 0 { | ||
| 507 | t.Fatalf("problems: %v", problems) | ||
| 508 | } | ||
| 509 | if got := wf.Jobs[0].Timeout; got != 90*time.Minute { | ||
| 510 | t.Fatalf("timeout = %s, want 1h30m", got) | ||
| 511 | } | ||
| 512 | |||
| 513 | // A budget no runner would honour, or one that is not a duration, fails the push. | ||
| 514 | for _, bad := range []string{"0", "-5m", "5h", "soon", "1"} { | ||
| 515 | src := "version: 1\njobs:\n build:\n image: alpine:3.22\n timeout: " + | ||
| 516 | strconv.Quote(bad) + "\n steps:\n - run: true\n" | ||
| 517 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | ||
| 518 | t.Fatalf("job timeout %q was accepted", bad) | ||
| 519 | } else if !strings.Contains(strings.Join(problems, " "), "timeout") { | ||
| 520 | t.Fatalf("the problem does not name the timeout: %v", problems) | ||
| 521 | } | ||
| 522 | } | ||
| 523 | |||
| 524 | // A step that asks for more time than the job has could never use it. | ||
| 525 | src := `version: 1 | ||
| 526 | jobs: | ||
| 527 | build: | ||
| 528 | image: alpine:3.22 | ||
| 529 | timeout: 30m | ||
| 530 | steps: | ||
| 531 | - run: true | ||
| 532 | timeout: 45m | ||
| 533 | ` | ||
| 534 | _, problems = Parse("ci.yaml", src) | ||
| 535 | if len(problems) == 0 { | ||
| 536 | t.Fatal("a step budget longer than the job's was accepted") | ||
| 537 | } | ||
| 538 | if !strings.Contains(strings.Join(problems, " "), "longer than the job's timeout") { | ||
| 539 | t.Fatalf("the problem does not explain the budget: %v", problems) | ||
| 540 | } | ||
| 541 | } | ||
| 542 | |||
| 543 | // StepLimit is the one rule the runner and a local run share, so it is checked on | ||
| 544 | // its own rather than through either of them. | ||
| 545 | func TestStepLimit(t *testing.T) { | ||
| 546 | t.Parallel() | ||
| 547 | // left is -1 when the job has no budget. | ||
| 548 | cases := []struct { | ||
| 549 | name string | ||
| 550 | left string | ||
| 551 | own time.Duration | ||
| 552 | want time.Duration | ||
| 553 | }{ | ||
| 554 | {"no job budget, no step timeout", "none", 0, DefaultTimeout}, | ||
| 555 | {"no job budget, step timeout", "none", 20 * time.Minute, 20 * time.Minute}, | ||
| 556 | {"budget larger than the step", "2h", 20 * time.Minute, 20 * time.Minute}, | ||
| 557 | {"step does not declare one", "2h", 0, 2 * time.Hour}, | ||
| 558 | {"budget smaller than the step", "10m", 20 * time.Minute, 10 * time.Minute}, | ||
| 559 | {"budget spent", "spent", 20 * time.Minute, 0}, | ||
| 560 | {"budget spent, no step timeout", "spent", 0, 0}, | ||
| 561 | } | ||
| 562 | for _, c := range cases { | ||
| 563 | left := time.Duration(-1) | ||
| 564 | switch c.left { | ||
| 565 | case "spent": | ||
| 566 | left = 0 | ||
| 567 | case "2h": | ||
| 568 | left = 2 * time.Hour | ||
| 569 | case "10m": | ||
| 570 | left = 10 * time.Minute | ||
| 571 | } | ||
| 572 | if got := StepLimit(left, c.own); got != c.want { | ||
| 573 | t.Fatalf("%s: StepLimit(%s, %s) = %s, want %s", c.name, c.left, c.own, got, c.want) | ||
| 574 | } | ||
| 575 | } | ||
| 576 | |||
| 577 | // A deadline in the past is spent, not unlimited, and no deadline is -1. | ||
| 578 | now := time.Now() | ||
| 579 | if got := BudgetLeft(now.Add(-time.Second), now); got != 0 { | ||
| 580 | t.Fatalf("a past deadline left %s, want 0", got) | ||
| 581 | } | ||
| 582 | if got := BudgetLeft(time.Time{}, now); got != -1 { | ||
| 583 | t.Fatalf("no deadline left %s, want -1", got) | ||
| 584 | } | ||
| 585 | if got := BudgetLeft(now.Add(time.Minute), now); got <= 0 || got > time.Minute { | ||
| 586 | t.Fatalf("a minute left = %s", got) | ||
| 587 | } | ||
| 588 | } | ||
| 589 | |||
| 472 | 590 | // TestJobEnvironment: a job's env is how it opts in to things the runner provides | |
| 473 | 591 | // but does not decide, such as the cache at /cache. It is validated like the image | |
| 474 | 592 | // definition's env, and it may not replace what the sandbox itself sets. | |