Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.