v0.9.0 dd8597a

ci_runner.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11	"time"
12	"unicode/utf8"
13
14	"github.com/bnema/zerowrap"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// maxVersionLen bounds RegisterRunner.version.
21const maxVersionLen = 64
22
23// --- registration ---
24
25// Register adds a runner and returns its token, shown once. The principal
26// needs runner:register on a token that is not restricted to repositories, or
27// to be root. A name in use is domain.ErrAlreadyExists unless Replace is set,
28// which removes the old runner (its token stops working at once).
29func (c *CI) Register(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error) {
30	if err := canManageRunners(p); err != nil {
31		return ports.RegisteredRunner{}, err
32	}
33	if err := domain.ParseRunnerName(params.Name); err != nil {
34		return ports.RegisteredRunner{}, err
35	}
36	labels, err := domain.ParseRunnerLabels(params.Labels)
37	if err != nil {
38		return ports.RegisteredRunner{}, err
39	}
40	var v domain.Violations
41	if len(params.Version) > maxVersionLen || !utf8.ValidString(params.Version) {
42		v.Add("version", "is at most 64 characters")
43	}
44	switch params.Isolation {
45	case domain.CIIsolationVM, domain.CIIsolationContainerSysbox, domain.CIIsolationContainerPrivileged:
46	default:
47		v.Add("isolation", "is required")
48	}
49	if params.Arch != "x64" && params.Arch != "arm64" {
50		v.Add("arch", `must be "x64" or "arm64"`)
51	}
52	if params.OS != "linux" {
53		v.Add("os", `must be "linux"`)
54	}
55	if err := v.Err(); err != nil {
56		return ports.RegisteredRunner{}, err
57	}
58
59	token := newToken(domain.RunnerTokenPrefix)
60	r := domain.CIRunner{
61		ID: newRunnerID(), Name: params.Name, Labels: domain.RunnerLabels(labels, params.OS, params.Arch),
62		Isolation: params.Isolation, Version: params.Version, Arch: params.Arch, OS: params.OS,
63		RegisteredAt: c.now(), TokenHash: domain.HashTokenSecret(token),
64	}
65	removed, err := c.cfg.Store.CreateRunner(ctx, r, params.Replace)
66	if err != nil {
67		return ports.RegisteredRunner{}, err
68	}
69	log := zerowrap.FromCtx(ctx)
70	log.Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("isolation", string(r.Isolation)).
71		Int("replaced", len(removed)).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner registered")
72	c.forgetRunners(removed)
73	return ports.RegisteredRunner{Runner: r, Token: token}, nil
74}
75
76// forgetRunners drops what is kept in memory for removed runners and ends
77// their polls in progress.
78func (c *CI) forgetRunners(rs []domain.CIRunner) {
79	c.mu.Lock()
80	for _, r := range rs {
81		delete(c.polls, r.ID)
82		delete(c.seen, r.ID)
83	}
84	c.mu.Unlock()
85	c.wakeQueue()
86}
87
88// runnerFor authenticates a runner token.
89func (c *CI) runnerFor(ctx context.Context, secret string) (domain.CIRunner, error) {
90	if !domain.IsRunnerToken(secret) {
91		return domain.CIRunner{}, domain.ErrUnauthenticated
92	}
93	r, err := c.cfg.Store.FindRunnerByToken(ctx, domain.HashTokenSecret(secret))
94	if errors.Is(err, domain.ErrNotFound) {
95		return domain.CIRunner{}, domain.ErrUnauthenticated
96	}
97	return r, err
98}
99
100func (c *CI) markSeen(runnerID string) {
101	c.mu.Lock()
102	c.seen[runnerID] = c.now()
103	c.mu.Unlock()
104}
105
106// --- FetchJob ---
107
108var errNoJob = errors.New("no job")
109
110// FetchJob holds the call until a queued job matches the runner, or the poll
111// time passes. A job is given to exactly one runner: assignments are made one
112// at a time under a lock and recorded before the answer is sent.
113func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error) {
114	runner, err := c.runnerFor(ctx, secret)
115	if err != nil {
116		return ports.FetchJobResult{}, err
117	}
118	offered, err := offeredLabels(runner, params.Labels)
119	if err != nil {
120		return ports.FetchJobResult{}, err
121	}
122	if params.PollID != "" {
123		if err := checkPollID(params.PollID); err != nil {
124			return ports.FetchJobResult{}, err
125		}
126		if j, ok := c.repeatPoll(ctx, runner, params.PollID); ok {
127			return ports.FetchJobResult{Job: j}, nil
128		}
129	}
130	c.markSeen(runner.ID)
131	deadline := time.NewTimer(c.cfg.PollTimeout)
132	defer deadline.Stop()
133	for {
134		wake := c.pending.wait(queueKey) // before looking: a job made meanwhile wakes us
135		job, err := c.assignTo(ctx, runner, offered, params.PollID)
136		switch {
137		case err == nil:
138			c.markSeen(runner.ID)
139			return ports.FetchJobResult{Job: job}, nil
140		case !errors.Is(err, errNoJob):
141			return ports.FetchJobResult{}, err
142		}
143		select {
144		case <-wake:
145		case <-deadline.C:
146			c.markSeen(runner.ID)
147			return ports.FetchJobResult{}, nil
148		case <-ctx.Done():
149			return ports.FetchJobResult{}, ctx.Err()
150		case <-c.ctx.Done():
151			return ports.FetchJobResult{RetryAfter: time.Second}, nil
152		}
153		// A removed runner's poll ends with unauthenticated.
154		if _, err := c.cfg.Store.GetRunner(ctx, runner.ID); errors.Is(err, domain.ErrNotFound) {
155			return ports.FetchJobResult{}, domain.ErrUnauthenticated
156		}
157	}
158}
159
160func checkPollID(id string) error {
161	if len(id) > 64 {
162		return fmt.Errorf("%w: poll_id is at most 64 characters", domain.ErrInvalid)
163	}
164	for _, r := range id {
165		if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' {
166			return fmt.Errorf("%w: poll_id has letters, digits, '_' and '-' only", domain.ErrInvalid)
167		}
168	}
169	return nil
170}
171
172// offeredLabels are the labels of one poll: all those of the runner, or the
173// subset the call names.
174func offeredLabels(r domain.CIRunner, asked []string) ([]string, error) {
175	if len(asked) == 0 {
176		return r.Labels, nil
177	}
178	var out []string
179	for _, l := range asked {
180		l = strings.ToLower(l)
181		if !slices.Contains(r.Labels, l) {
182			return nil, fmt.Errorf("%w: label %q is not a label of the runner", domain.ErrInvalid, l)
183		}
184		out = append(out, l)
185	}
186	return out, nil
187}
188
189// repeatPoll answers a retried poll with the assignment the first call made,
190// as long as it has not been acknowledged or cancelled.
191func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID string) (*ports.RunnerJob, bool) {
192	c.mu.Lock()
193	rec, ok := c.polls[runner.ID]
194	c.mu.Unlock()
195	if !ok || rec.pollID != pollID {
196		return nil, false
197	}
198	var rid int64
199	var ra int32
200	var id string
201	if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil {
202		return nil, false
203	}
204	var out *ports.RunnerJob
205	err := c.withRun(ctx, rid, ra, func(rs *runState) error {
206		j := rs.find(id)
207		if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208			return errNoJob
209		}
210		out = c.runnerJob(rs, *j, rec.token, rec.ghToken)
211		return nil
212	})
213	return out, err == nil && out != nil
214}
215
216// runnerJob is the job as the runner gets it.
217func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob {
218	return &ports.RunnerJob{
219		Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220		WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
221	}
222}
223
224// assignTo gives the oldest queued job that matches the labels to the runner,
225// or errNoJob. The assignment lock makes the choice and the write one step.
226func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []string, pollID string) (*ports.RunnerJob, error) {
227	c.assign.Lock()
228	defer c.assign.Unlock()
229	ready, err := c.cfg.Store.ListReadyJobs(ctx, 0)
230	if err != nil {
231		return nil, err
232	}
233	for _, cand := range ready {
234		if !domain.LabelsMatch(cand.Labels, offered) {
235			continue
236		}
237		var out *ports.RunnerJob
238		err := c.withRun(ctx, cand.RunID, cand.RunAttempt, func(rs *runState) error {
239			j := rs.find(cand.ID)
240			if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested {
241				return errNoJob
242			}
243			token := newToken(domain.JobTokenPrefix)
244			ghToken := rs.issueRepoToken(j)
245			j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
246			j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
247			j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
248			rs.touch(j)
249			if err := rs.flush(ctx); err != nil {
250				return err
251			}
252			out = c.runnerJob(rs, *j, token, ghToken)
253			if pollID != "" {
254				c.mu.Lock()
255				c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}
256				c.mu.Unlock()
257			}
258			return nil
259		})
260		switch {
261		case err == nil:
262			log := zerowrap.FromCtx(ctx)
263			log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned")
264			return out, nil
265		case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):
266			continue // taken or gone meanwhile
267		default:
268			return nil, err
269		}
270	}
271	return nil, errNoJob
272}
273
274// --- the calls of a job token ---
275
276// jobFor authenticates a job token and checks that it is the one of jobID.
277func (c *CI) jobFor(ctx context.Context, secret, jobID string) (domain.CIJob, error) {
278	if !domain.IsJobToken(secret) {
279		return domain.CIJob{}, domain.ErrUnauthenticated
280	}
281	j, err := c.cfg.Store.FindJobByToken(ctx, domain.HashTokenSecret(secret), c.now())
282	if errors.Is(err, domain.ErrNotFound) {
283		return domain.CIJob{}, domain.ErrUnauthenticated
284	}
285	if err != nil {
286		return domain.CIJob{}, err
287	}
288	if j.ID != jobID {
289		return domain.CIJob{}, fmt.Errorf("%w: this token is the token of another job", domain.ErrForbidden)
290	}
291	return j, nil
292}
293
294func endedError(j domain.CIJob) error {
295	return &domain.JobEndedError{Conclusion: j.Conclusion, Message: j.Error}
296}
297
298// lockedJob runs fn with the lock of the run and the job read again.
299func (c *CI) lockedJob(ctx context.Context, j domain.CIJob, fn func(cur *domain.CIJob) error) error {
300	lock := c.runLock(j.RunID)
301	lock.Lock()
302	defer lock.Unlock()
303	cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
304	if err != nil {
305		return err
306	}
307	if err := sameAssignment(j, cur); err != nil {
308		return err
309	}
310	return fn(&cur)
311}
312
313// sameAssignment checks, under the lock of the run, that the job a token was
314// found for is still given under that token: an assignment cancelled and given
315// again since has a new one, and the old runner is unauthenticated. A job that
316// has ended answers with its end instead (the callers do).
317func sameAssignment(was, cur domain.CIJob) error {
318	if !cur.Ended() && cur.TokenHash != was.TokenHash {
319		return domain.ErrUnauthenticated
320	}
321	return nil
322}
323
324// saveJob writes one job (its steps changed) with the annotations that change
325// with it, and publishes JobUpdated. The status of the run does not depend on
326// steps.
327func (c *CI) saveJob(ctx context.Context, j *domain.CIJob, anns ...ports.CIStepAnnotations) error {
328	v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}, Annotations: anns})
329	if err != nil {
330		return fmt.Errorf("write job %s: %w", j.ID, err)
331	}
332	j.Version = v
333	if repo, err := c.cfg.Meta.Get(ctx, j.Repo); err == nil {
334		lines, _ := c.cfg.Logs.Count(ctx, logRef(*j))
335		c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: *j, Lines: lines, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
336	}
337	c.jobs.notify(jobRef(*j))
338	return nil
339}
340
341func logRef(j domain.CIJob) ports.CILogRef {
342	return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
343}
344
345// UpdateJob applies the steps and the annotations of the request whole or not
346// at all. The annotations of each step they name replace those stored, whether
347// the step is completed or not.
348func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error {
349	j, err := c.jobFor(ctx, secret, jobID)
350	if err != nil {
351		return err
352	}
353	if j.Ended() {
354		return endedError(j)
355	}
356	if err := checkStepUpdates(steps); err != nil {
357		return err
358	}
359	if err := domain.CheckAnnotations(annotations); err != nil {
360		return err
361	}
362	return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
363		if cur.Ended() {
364			return endedError(*cur)
365		}
366		next, changed, err := applySteps(cur.Steps, steps)
367		if err != nil {
368			return err
369		}
370		writes, err := c.annotationWrites(ctx, *cur, annotations)
371		if err != nil {
372			return err
373		}
374		if !changed && len(writes) == 0 {
375			return nil
376		}
377		cur.Steps = next
378		return c.saveJob(ctx, cur, writes...)
379	})
380}
381
382// checkStepUpdates validates the shape of the steps of a request.
383func checkStepUpdates(steps []ports.StepUpdate) error {
384	if len(steps) > domain.CIMaxSteps {
385		return fmt.Errorf("%w: at most %d steps", domain.ErrInvalid, domain.CIMaxSteps)
386	}
387	for _, s := range steps {
388		if s.Number < 1 || s.Number > domain.CIMaxSteps {
389			return fmt.Errorf("%w: step number %d is not between 1 and %d", domain.ErrInvalid, s.Number, domain.CIMaxSteps)
390		}
391		switch s.State {
392		case domain.CIStepPending:
393			if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion {
394				return fmt.Errorf("%w: step %d is not completed: it has no conclusion or outcome", domain.ErrInvalid, s.Number)
395			}
396		case domain.CIStepInProgress:
397			if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion || s.StartedAt.IsZero() {
398				return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion and outcome are not allowed", domain.ErrInvalid, s.Number)
399			}
400		case domain.CIStepCompleted:
401			switch s.Conclusion {
402			case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped:
403			default:
404				return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
405			}
406			switch s.Outcome {
407			case domain.CINoConclusion, domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped:
408			default:
409				return fmt.Errorf("%w: step %d is completed: its outcome must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
410			}
411			if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped {
412				return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number)
413			}
414			if s.CompletedAt.IsZero() {
415				return fmt.Errorf("%w: step %d is completed: completed_at is required", domain.ErrInvalid, s.Number)
416			}
417		default:
418			return fmt.Errorf("%w: step %d has no state", domain.ErrInvalid, s.Number)
419		}
420	}
421	return nil
422}
423
424// applySteps returns the steps after the updates, and whether anything
425// changed. The input is not modified.
426func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep, bool, error) {
427	out := slices.Clone(have)
428	changed := false
429	for _, u := range ups {
430		name := truncateRunes(u.Name, domain.CIMaxStepName)
431		i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number })
432		if i < 0 {
433			out = append(out, domain.CIStep{
434				Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, Outcome: u.Outcome,
435				StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
436			})
437			changed = true
438			continue
439		}
440		cur := out[i]
441		next := cur
442		next.Name, next.State, next.Conclusion, next.Outcome = name, u.State, u.Conclusion, u.Outcome
443		next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt
444		if cur.Name != "" && name == "" {
445			next.Name = cur.Name // an empty name never erases one
446		}
447		switch {
448		case cur.State == domain.CIStepCompleted:
449			same := next.State == cur.State && next.Conclusion == cur.Conclusion && effectiveOutcome(next) == effectiveOutcome(cur) && next.Name == cur.Name &&
450				next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt)
451			if !same {
452				return nil, false, fmt.Errorf("%w: step %d is completed: it cannot change", domain.ErrPreconditionFailed, u.Number)
453			}
454			continue
455		case cur.State == domain.CIStepInProgress && next.State == domain.CIStepPending:
456			return nil, false, fmt.Errorf("%w: step %d cannot go from in progress back to pending", domain.ErrPreconditionFailed, u.Number)
457		}
458		if !stepsEqual(cur, next) {
459			out[i], changed = next, true
460		}
461	}
462	slices.SortFunc(out, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) })
463	return out, changed, nil
464}
465
466// effectiveOutcome is the outcome of a step, read as its conclusion when the
467// runner did not report one.
468func effectiveOutcome(s domain.CIStep) domain.CIConclusion {
469	if s.Outcome == domain.CINoConclusion {
470		return s.Conclusion
471	}
472	return s.Outcome
473}
474
475func stepsEqual(a, b domain.CIStep) bool {
476	return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion && a.Outcome == b.Outcome &&
477		a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines
478}
479
480// lastLineStep returns the highest step number that has lines, 0 for none.
481func lastLineStep(steps []domain.CIStep) int32 {
482	var last int32
483	for _, s := range steps {
484		if s.HasLines {
485			last = max(last, s.Number)
486		}
487	}
488	return last
489}
490
491// checkLines validates the shape of the lines of an AppendLog request.
492func checkLines(step int32, offset int64, lines []domain.CILogLine) error {
493	if step < 1 || step > domain.CIMaxSteps {
494		return fmt.Errorf("%w: step %d is not between 1 and %d", domain.ErrInvalid, step, domain.CIMaxSteps)
495	}
496	if offset < 0 {
497		return fmt.Errorf("%w: negative offset", domain.ErrInvalid)
498	}
499	if len(lines) > domain.CIMaxLogLines {
500		return fmt.Errorf("%w: at most %d lines per request", domain.ErrInvalid, domain.CIMaxLogLines)
501	}
502	total := 0
503	for i, l := range lines {
504		if strings.ContainsRune(l.Text, '\n') || !utf8.ValidString(l.Text) {
505			return fmt.Errorf("%w: line %d has a line feed or is not valid UTF-8", domain.ErrInvalid, i)
506		}
507		if l.Time.IsZero() {
508			return fmt.Errorf("%w: line %d has no time", domain.ErrInvalid, i)
509		}
510		if l.Continued && offset == 0 && i == 0 {
511			return fmt.Errorf("%w: the first line of a job cannot be continued", domain.ErrInvalid)
512		}
513		total += len(l.Text)
514	}
515	if total > domain.CIMaxLogChunkBytes {
516		return fmt.Errorf("%w: at most %d bytes of text per request", domain.ErrInvalid, domain.CIMaxLogChunkBytes)
517	}
518	return nil
519}
520
521// AppendLog appends lines of a step to the log of the job; see runner.proto
522// for the rules of offsets. The metadata store is written only when a step
523// gets its first line.
524func (c *CI) AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error) {
525	j, err := c.jobFor(ctx, secret, jobID)
526	if err != nil {
527		return 0, err
528	}
529	if j.Ended() {
530		return 0, endedError(j)
531	}
532	if err := checkLines(step, offset, lines); err != nil {
533		return 0, err
534	}
535	var next int64
536	limitHit := false
537	err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
538		if cur.Ended() {
539			return endedError(*cur)
540		}
541		ref := logRef(*cur)
542		count, err := c.cfg.Logs.Count(ctx, ref)
543		if err != nil {
544			return err
545		}
546		if offset > count {
547			return &ports.ErrCILogGap{Count: count}
548		}
549		skip := count - offset
550		if skip >= int64(len(lines)) {
551			next = count // everything is stored already
552			return nil
553		}
554		last := lastLineStep(cur.Steps)
555		if step < last {
556			return fmt.Errorf("%w: step %d is below step %d, which already has lines", domain.ErrPreconditionFailed, step, last)
557		}
558		if first := lines[skip]; first.Continued && count > 0 && step != last {
559			return fmt.Errorf("%w: a continued line must be in the step of the line before it", domain.ErrPreconditionFailed)
560		}
561		n, added, err := c.cfg.Logs.Append(ctx, ref, offset, lines, domain.CIMaxLogBytes)
562		if errors.Is(err, ports.ErrCILogLimit) {
563			limitHit = true
564			return nil
565		}
566		if err != nil {
567			return err
568		}
569		next = n
570		if added == 0 {
571			return nil
572		}
573		return c.startStepLines(ctx, cur, step, count, lines[skip].Time)
574	})
575	if err != nil {
576		return 0, err
577	}
578	if limitHit {
579		return 0, c.stopForLogLimit(ctx, j)
580	}
581	return next, nil
582}
583
584// startStepLines records where the lines of a step begin, creating the step
585// when UpdateJob did not announce it.
586func (c *CI) startStepLines(ctx context.Context, cur *domain.CIJob, step int32, firstLine int64, at time.Time) error {
587	i := slices.IndexFunc(cur.Steps, func(s domain.CIStep) bool { return s.Number == step })
588	switch {
589	case i < 0:
590		cur.Steps = append(cur.Steps, domain.CIStep{
591			Number: step, State: domain.CIStepInProgress, StartedAt: at, HasLines: true, LogStart: firstLine,
592		})
593		slices.SortFunc(cur.Steps, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) })
594	case cur.Steps[i].HasLines:
595		return nil
596	default:
597		cur.Steps[i].HasLines, cur.Steps[i].LogStart = true, firstLine
598	}
599	return c.saveJob(ctx, cur)
600}
601
602// stopForLogLimit ends a job whose log reached its limit: a last line says
603// so, the job fails, and the call answers like any call after the end.
604func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
605	var ended *domain.JobEndedError
606	err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
607		cur := rs.find(j.ID)
608		if cur == nil {
609			return domain.ErrNotFound
610		}
611		if cur.Ended() {
612			ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}
613			return nil
614		}
615		if err := sameAssignment(j, *cur); err != nil {
616			return err
617		}
618		_, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}})
619		rs.end(cur, domain.CIFailure, domain.CILogLimitMessage)
620		ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage}
621		if err := rs.advance(ctx); err != nil {
622			return err
623		}
624		return rs.flush(ctx)
625	})
626	if err != nil {
627		return err
628	}
629	if ended == nil {
630		return domain.ErrNotFound // a typed nil would be taken for an error
631	}
632	return ended
633}
634
635// Heartbeat tells the server that the runner is alive. The first one
636// acknowledges the assignment; the others only move the deadline.
637func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
638	j, err := c.jobFor(ctx, secret, jobID)
639	if err != nil {
640		return err
641	}
642	if j.Ended() {
643		return endedError(j)
644	}
645	// Everything is done under the lock of the run: the acknowledgement, and
646	// the note of the heartbeat, which must not be written once the job ended
647	// (end() forgets it under the same lock).
648	return c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
649		cur := rs.find(j.ID)
650		switch {
651		case cur == nil:
652			return domain.ErrUnauthenticated
653		case cur.Ended():
654			return endedError(*cur)
655		case cur.TokenHash != j.TokenHash:
656			return domain.ErrUnauthenticated // the assignment was cancelled and given again
657		}
658		now := c.now()
659		if cur.Phase == domain.CIPhaseAssigned {
660			cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0
661			rs.touch(cur)
662			if err := rs.flush(ctx); err != nil {
663				return err
664			}
665			c.forgetPoll(jobRef(*cur)) // the token it holds is the runner's now
666		}
667		c.mu.Lock()
668		c.heartbeats[jobRef(*cur)] = now
669		c.seen[cur.RunnerID] = now
670		c.mu.Unlock()
671		return nil
672	})
673}
674
675// forgetPoll drops the remembered assignment (and its plaintext token) of a
676// job: after the acknowledgement or the cancel of the assignment, a retried
677// poll has nothing to repeat.
678func (c *CI) forgetPoll(ref string) {
679	c.mu.Lock()
680	defer c.mu.Unlock()
681	for id, p := range c.polls {
682		if p.ref == ref {
683			delete(c.polls, id)
684		}
685	}
686}
687
688// WatchJob streams what the runner must react to: the cancel request (the
689// first message when one is already pending), keepalives, and the end.
690func (c *CI) WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(ports.JobSignal) error) error {
691	j, err := c.jobFor(ctx, secret, jobID)
692	if err != nil {
693		return err
694	}
695	if j.Ended() {
696		return endedError(j)
697	}
698	ref := jobRef(j)
699	keepalive := time.NewTicker(c.cfg.Keepalive)
700	defer keepalive.Stop()
701	cancelSent := false
702	if ready != nil {
703		ready()
704	}
705	for {
706		wake := c.jobs.wait(ref)
707		cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
708		if err != nil {
709			return err
710		}
711		if cur.TokenHash != j.TokenHash && !cur.Ended() {
712			return domain.ErrUnauthenticated // given again
713		}
714		if cur.Ended() {
715			return send(ports.JobSignal{Ended: &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}})
716		}
717		if cur.CancelReason != domain.CICancelNone && !cancelSent {
718			cancelSent = true
719			if err := send(ports.JobSignal{Cancel: &ports.JobCancel{
720				Reason: cur.CancelReason, Message: cur.CancelMessage, RequestedAt: cur.CancelRequestedAt,
721			}}); err != nil {
722				return err
723			}
724		}
725		select {
726		case <-wake:
727		case <-keepalive.C:
728			if err := send(ports.JobSignal{Keepalive: true}); err != nil {
729				return err
730			}
731		case <-ctx.Done():
732			return nil
733		case <-c.ctx.Done():
734			return nil
735		}
736	}
737}
738
739// CompleteJob ends a job, once. A later call returns the stored result.
740func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error) {
741	j, err := c.jobFor(ctx, secret, params.JobID)
742	if err != nil {
743		return ports.CompleteJobResult{}, err
744	}
745	if j.Ended() {
746		return ports.CompleteJobResult{Conclusion: j.Conclusion, Error: j.Error}, nil
747	}
748	switch params.Conclusion {
749	case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CITimedOut:
750	default:
751		return ports.CompleteJobResult{}, fmt.Errorf("%w: a runner reports success, failure, cancelled or timed_out", domain.ErrInvalid)
752	}
753	if params.RunnerShutdown && params.Conclusion != domain.CICancelled {
754		return ports.CompleteJobResult{}, fmt.Errorf("%w: runner_shutdown goes with cancelled", domain.ErrInvalid)
755	}
756	if err := domain.CheckOutputs(params.Outputs); err != nil {
757		return ports.CompleteJobResult{}, err
758	}
759	if err := checkStepUpdates(params.Steps); err != nil {
760		return ports.CompleteJobResult{}, err
761	}
762	if err := domain.CheckAnnotations(params.Annotations); err != nil {
763		return ports.CompleteJobResult{}, err
764	}
765	var res ports.CompleteJobResult
766	err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
767		cur := rs.find(j.ID)
768		if cur == nil {
769			return domain.ErrNotFound
770		}
771		if cur.Ended() { // ended by the server since
772			res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error}
773			return nil
774		}
775		if err := sameAssignment(j, *cur); err != nil {
776			return err
777		}
778		steps, _, err := applySteps(cur.Steps, params.Steps)
779		if err != nil {
780			return err
781		}
782		writes, err := c.annotationWrites(ctx, *cur, params.Annotations)
783		if err != nil {
784			return err
785		}
786		rs.annotations = append(rs.annotations, writes...)
787		cur.Steps = steps
788		cur.Outputs = params.Outputs
789		msg := ""
790		if params.RunnerShutdown {
791			msg = domain.CIShutdownMessage
792		}
793		rs.end(cur, params.Conclusion, msg)
794		if err := rs.advance(ctx); err != nil {
795			return err
796		}
797		res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error, Applied: true}
798		return rs.flush(ctx)
799	})
800	if err != nil {
801		return ports.CompleteJobResult{}, err
802	}
803	if res.Applied {
804		log := zerowrap.FromCtx(ctx)
805		log.Info().Str("job_id", j.ID).Int64("run_id", j.RunID).Str("conclusion", string(res.Conclusion)).Msg("job completed")
806	}
807	return res, nil
808}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close