1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "fmt"
8 "regexp"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13)
14
15// CIStatus is where a run, a job or a check is in its life (GitHub's status).
16type CIStatus string
17
18// Statuses.
19const (
20 CIQueued CIStatus = "queued"
21 CIInProgress CIStatus = "in_progress"
22 CICompleted CIStatus = "completed"
23)
24
25// CIConclusion is how a run, a job, a step or a check ended, with GitHub's
26// values. It is empty until the status is completed.
27type CIConclusion string
28
29// Conclusions.
30const (
31 CINoConclusion CIConclusion = ""
32 CISuccess CIConclusion = "success"
33 CIFailure CIConclusion = "failure"
34 CICancelled CIConclusion = "cancelled"
35 CISkipped CIConclusion = "skipped"
36 CITimedOut CIConclusion = "timed_out"
37 CINeutral CIConclusion = "neutral"
38 CIActionRequired CIConclusion = "action_required"
39)
40
41// PassesMerge reports whether a check that ended this way lets a merge go
42// through under FailingChecksBlock: success, skipped and neutral do.
43func (c CIConclusion) PassesMerge() bool {
44 return c == CISuccess || c == CISkipped || c == CINeutral
45}
46
47// IsFailure reports whether the conclusion is a failure or a timeout.
48func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut }
49
50// CIStepState is where a step is in its life.
51type CIStepState string
52
53// Step states.
54const (
55 CIStepPending CIStepState = "pending"
56 CIStepInProgress CIStepState = "in_progress"
57 CIStepCompleted CIStepState = "completed"
58)
59
60// CIJobPhase is where a job is for the server, finer than the status owners
61// see.
62type CIJobPhase string
63
64// Job phases.
65const (
66 // CIPhaseWaiting: waits for the jobs it needs or for a max-parallel slot.
67 CIPhaseWaiting CIJobPhase = "waiting"
68 // CIPhaseReady: planned, waits for a runner.
69 CIPhaseReady CIJobPhase = "ready"
70 // CIPhaseAssigned: given to a runner that has not acknowledged it.
71 CIPhaseAssigned CIJobPhase = "assigned"
72 // CIPhaseRunning: acknowledged by its runner.
73 CIPhaseRunning CIJobPhase = "running"
74 // CIPhaseEnded: over.
75 CIPhaseEnded CIJobPhase = "ended"
76)
77
78// CIIsolation is how a runner isolates jobs.
79type CIIsolation string
80
81// Isolations.
82const (
83 CIIsolationNone CIIsolation = ""
84 CIIsolationVM CIIsolation = "vm"
85 CIIsolationContainerSysbox CIIsolation = "container-sysbox"
86 CIIsolationContainerPrivileged CIIsolation = "container-privileged"
87)
88
89// CICancelReason says why a job is asked to stop.
90type CICancelReason string
91
92// Cancel reasons.
93const (
94 CICancelNone CICancelReason = ""
95 CICancelUser CICancelReason = "user"
96 CICancelFailFast CICancelReason = "fail-fast"
97 CICancelTimeout CICancelReason = "timeout"
98)
99
100// Limits and durations of the CI protocol (runner.proto).
101const (
102 // CIMaxLogBytes is the size of a job log, as stored (512 MiB).
103 CIMaxLogBytes = 512 << 20
104 // CIMaxOutputBytes bounds one output and all outputs together (1 MiB).
105 CIMaxOutputBytes = 1 << 20
106 // CIMaxLogLines and CIMaxLogChunkBytes bound one AppendLog request.
107 CIMaxLogLines = 1000
108 CIMaxLogChunkBytes = 64 << 10
109 // CIMaxSteps bounds the steps of a job.
110 CIMaxSteps = 1000
111 // CIMaxStepName bounds a step name, in characters.
112 CIMaxStepName = 1024
113 // CIMaxLabels and CIMaxLabelLen bound the labels of a runner.
114 CIMaxLabels = 64
115 CIMaxLabelLen = 64
116 // CIDefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
117 CIDefaultTimeoutMinutes = 360
118 // CIMaxAssignCancels is how many times in a row an assignment may be
119 // cancelled before the job fails.
120 CIMaxAssignCancels = 3
121 // CIRetentionDays is the retention of runs, jobs, checks and logs.
122 CIRetentionDays = 90
123)
124
125// Durations of the CI protocol.
126const (
127 // CIAckTimeout is how long a runner has to send its first heartbeat.
128 CIAckTimeout = 30 * time.Second
129 // CIHeartbeatDeadline is how long a running job may go without heartbeat.
130 CIHeartbeatDeadline = 2 * time.Minute
131 // CICancelGrace is how long a runner has to conclude after a cancel request.
132 CICancelGrace = 60 * time.Second
133 // CITimeoutSlack is added to timeout-minutes by the server.
134 CITimeoutSlack = 60 * time.Second
135 // CIQueueTimeout is how long a job waits for a runner.
136 CIQueueTimeout = 24 * time.Hour
137 // CITokenGrace is how long a job token is recognised after the job ended.
138 CITokenGrace = 24 * time.Hour
139)
140
141// Messages of jobs ended by the server, GitHub's where it has them.
142const (
143 // CIQueueTimeoutMessage is GitHub's message for a job that no runner took.
144 CIQueueTimeoutMessage = "The job was not acquired by Runner of type self-hosted even after multiple attempts"
145 // CIShutdownMessage is GitHub's message for a runner that stops.
146 CIShutdownMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled."
147 // CILogLimitMessage ends a log that reached its limit.
148 CILogLimitMessage = "The log of this job reached the limit of 512 MiB; the job is stopped."
149)
150
151// CILostMessage is GitHub's message for a runner that stopped sending heartbeats.
152func CILostMessage(runner string) string {
153 return "The self-hosted runner: " + runner + " lost communication with the server. Verify the machine is running and has a healthy network connection. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error."
154}
155
156// CIRun is one execution of one workflow file for one event.
157type CIRun struct {
158 Repo RepoName
159 ID int64
160 Number int64
161 Attempt int32
162 WorkflowPath string
163 WorkflowName string
164 Title string
165 Event string
166 Ref string
167 Sha string
168 Actor string
169 Status CIStatus
170 Conclusion CIConclusion
171 Error string
172 CreatedAt time.Time
173 StartedAt time.Time
174 CompletedAt time.Time
175 CancelRequested bool
176 CancelRequestedAt time.Time
177 // Version is the version of the stored record, which grows at every
178 // change and never goes back.
179 Version uint64
180}
181
182// CIRunSource is what a run was planned from: the workflow file at the sha of
183// the run, and the "github" context without the parts that depend on the job
184// and the attempt.
185type CIRunSource struct {
186 RunID int64
187 Repo RepoName
188 WorkflowYAML string
189 GithubJSON string
190}
191
192// CIStep is a step of a job with where its lines are in the job log.
193type CIStep struct {
194 Number int32
195 Name string
196 State CIStepState
197 Conclusion CIConclusion
198 StartedAt time.Time
199 CompletedAt time.Time
200 LogStart int64
201 // HasLines is set once a line of the step reached the log; LogStart is
202 // then the number of its first line. LogEnd is not stored: see StepRanges.
203 HasLines bool
204}
205
206// CIJob is one job instance (one matrix combination) in one attempt.
207type CIJob struct {
208 ID string
209 RunID int64
210 // Attempt is the attempt in which the job executed, RunAttempt the attempt
211 // of the run the record belongs to: they differ for a job kept by a re-run
212 // of the failed jobs.
213 Attempt int32
214 RunAttempt int32
215 Repo RepoName
216 Key string
217 Name string
218 Phase CIJobPhase
219 Conclusion CIConclusion
220 Error string
221 Labels []string
222 Needs []string
223 RunnerID string
224 RunnerName string
225 Isolation CIIsolation
226 Steps []CIStep
227
228 CreatedAt time.Time
229 StartedAt time.Time
230 CompletedAt time.Time
231
232 // Index is the position of the job in the workflow file, Combo the
233 // position of the instance in its matrix.
234 Index int
235 Combo int
236 // Planned is set once runs-on, the name and the contexts are evaluated.
237 // A job that is not planned has not been expanded yet.
238 Planned bool
239 MatrixJSON string
240 // ContextsJSON is the JSON object of contexts given to the runner, computed
241 // when the job is planned.
242 ContextsJSON string
243 // ComboTotal is the number of instances of the job's matrix.
244 ComboTotal int
245 // TimeoutMinutes is the timeout of the job, at least 1.
246 TimeoutMinutes int32
247 FailFast bool
248 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
249 MaxParallel int
250 Outputs map[string]string
251
252 // TokenHash is the hash of the job token of the current assignment; zero
253 // when there is none.
254 TokenHash TokenHash
255 TokenValidUntil time.Time
256 ReadyAt time.Time
257 AssignedAt time.Time
258 AckedAt time.Time
259 // AssignCancels counts the assignments cancelled in a row.
260 AssignCancels int
261
262 CancelReason CICancelReason
263 CancelMessage string
264 CancelRequestedAt time.Time
265
266 Version uint64
267}
268
269// Status is the status owners see: a job is queued until its runner
270// acknowledged it.
271func (j CIJob) Status() CIStatus {
272 switch j.Phase {
273 case CIPhaseRunning:
274 return CIInProgress
275 case CIPhaseEnded:
276 return CICompleted
277 }
278 return CIQueued
279}
280
281// Active reports whether the job is given to a runner and not over.
282func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
283
284// Ended reports whether the job is over.
285func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
286
287// HasToken reports whether a job token is set.
288func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
289
290// CICheck is the result of one job on one commit.
291type CICheck struct {
292 ID string
293 Repo RepoName
294 Sha string
295 Name string
296 Status CIStatus
297 Conclusion CIConclusion
298 RunID int64
299 JobID string
300 Attempt int32
301 WorkflowName string
302 CreatedAt time.Time
303 StartedAt time.Time
304 CompletedAt time.Time
305 Version uint64
306}
307
308// CIRunner is a registered CI runner. TokenHash is never sent to clients.
309type CIRunner struct {
310 ID string
311 Name string
312 Labels []string
313 Isolation CIIsolation
314 Version string
315 Arch string
316 OS string
317 RegisteredAt time.Time
318 TokenHash TokenHash
319}
320
321// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
322const (
323 RunnerTokenPrefix = "ermiterun_"
324 JobTokenPrefix = "ermitejob_"
325 // CITokenRandomLen is the length of the base64url part of both.
326 CITokenRandomLen = 43
327)
328
329// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
330func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
331func IsJobToken(s string) bool { return hasTokenShape(s, JobTokenPrefix) }
332
333func hasTokenShape(s, prefix string) bool {
334 rest, ok := strings.CutPrefix(s, prefix)
335 if !ok || len(rest) != CITokenRandomLen {
336 return false
337 }
338 for _, r := range rest {
339 switch {
340 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
341 default:
342 return false
343 }
344 }
345 return true
346}
347
348// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
349// every attempt of the run.
350func NewJobID(runID int64, index, combo int) string {
351 return fmt.Sprintf("%d-%d-%d", runID, index, combo)
352}
353
354// ParseJobID returns the run of a job id, or ok false when s is not one.
355func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
356 parts := strings.Split(s, "-")
357 if len(parts) != 3 {
358 return 0, 0, 0, false
359 }
360 r, err1 := strconv.ParseInt(parts[0], 10, 64)
361 i, err2 := strconv.Atoi(parts[1])
362 c, err3 := strconv.Atoi(parts[2])
363 if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
364 return 0, 0, 0, false
365 }
366 return r, i, c, true
367}
368
369var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
370
371// ParseRunnerName validates a runner name.
372func ParseRunnerName(s string) error {
373 if !labelRE.MatchString(s) {
374 return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
375 }
376 return nil
377}
378
379// ParseRunnerLabels validates labels and returns them lowercased, without
380// duplicates, in order of first appearance.
381func ParseRunnerLabels(labels []string) ([]string, error) {
382 if len(labels) > CIMaxLabels {
383 return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
384 }
385 var out []string
386 for _, l := range labels {
387 if !labelRE.MatchString(l) {
388 return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
389 }
390 l = strings.ToLower(l)
391 if !slices.Contains(out, l) {
392 out = append(out, l)
393 }
394 }
395 return out, nil
396}
397
398// RunnerLabels returns the labels a runner matches: the ones it chose plus
399// the implicit "self-hosted", its os and its arch, lowercase and sorted.
400func RunnerLabels(chosen []string, os, arch string) []string {
401 out := slices.Clone(chosen)
402 for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
403 if !slices.Contains(out, l) {
404 out = append(out, l)
405 }
406 }
407 slices.Sort(out)
408 return out
409}
410
411// LabelsMatch reports whether every wanted label is among have, ignoring case.
412func LabelsMatch(want, have []string) bool {
413 for _, w := range want {
414 if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
415 return false
416 }
417 }
418 return true
419}
420
421var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
422
423// CheckOutputs validates the outputs of a job: names, case-insensitive
424// uniqueness and the 1 MiB limits.
425func CheckOutputs(outputs map[string]string) error {
426 seen := make(map[string]bool, len(outputs))
427 total := 0
428 for k, v := range outputs {
429 if !outputNameRE.MatchString(k) {
430 return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
431 }
432 lk := strings.ToLower(k)
433 if seen[lk] {
434 return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
435 }
436 seen[lk] = true
437 if len(v) > CIMaxOutputBytes {
438 return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
439 }
440 total += len(k) + len(v)
441 }
442 if total > CIMaxOutputBytes {
443 return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
444 }
445 return nil
446}
447
448// JobEndedError is returned for a call that needs a running job when the job
449// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
450// detail.
451type JobEndedError struct {
452 Conclusion CIConclusion
453 Message string
454}
455
456// Error implements error.
457func (e *JobEndedError) Error() string {
458 return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
459}
460
461// Is makes errors.Is(err, ErrPreconditionFailed) true.
462func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
463
464// IsJobEnded returns the JobEndedError in err.
465func IsJobEnded(err error) (*JobEndedError, bool) {
466 var e *JobEndedError
467 if errors.As(err, &e) {
468 return e, true
469 }
470 return nil, false
471}
472
473// ErrOutOfRange means an offset is beyond what the server holds.
474var ErrOutOfRange = errors.New("out of range")
475
476// CILogLine is one line of a job log.
477type CILogLine struct {
478 Time time.Time
479 // Text has no line feed.
480 Text string
481 // Continued marks a piece of a line that was too long to send whole.
482 Continued bool
483}
484
485// StepRange is where the lines of a step are in the job log.
486type StepRange struct {
487 Start, End int64
488}
489
490// StepRanges returns the range of every step of the job for a log of lines
491// lines: a step with lines ends where the next step with lines begins (the
492// last ends at the end of the log); a step without lines is empty, placed
493// where the previous step ends.
494func StepRanges(steps []CIStep, lines int64) []StepRange {
495 out := make([]StepRange, len(steps))
496 var pos int64
497 for i, s := range steps {
498 if !s.HasLines {
499 out[i] = StepRange{Start: pos, End: pos}
500 continue
501 }
502 end := lines
503 for _, n := range steps[i+1:] {
504 if n.HasLines {
505 end = n.LogStart
506 break
507 }
508 }
509 out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
510 pos = out[i].End
511 }
512 return out
513}
514
515// StepOfLine returns the number of the step a log line belongs to, 0 when no
516// step has that line.
517func StepOfLine(steps []CIStep, line int64) int32 {
518 var found int32
519 for _, s := range steps {
520 if s.HasLines && s.LogStart <= line {
521 found = s.Number
522 }
523 }
524 return found
525}
526
527// RunUpdated is published when a CI run is created or changes. It carries the
528// visibility and the creation time of the repository, like PushEvent.
529type RunUpdated struct {
530 Run CIRun
531 At time.Time
532 Visibility Visibility
533 CreatedAt time.Time
534}
535
536// EventKind implements Event.
537func (RunUpdated) EventKind() string { return "ci.run_updated" }
538
539// JobUpdated is published when a CI job is created or changes (status,
540// conclusion, runner or steps; never per log line). Lines is the number of
541// lines of the job log at that time, for the end of the step in progress.
542type JobUpdated struct {
543 Job CIJob
544 Lines int64
545 At time.Time
546 Visibility Visibility
547 CreatedAt time.Time
548}
549
550// EventKind implements Event.
551func (JobUpdated) EventKind() string { return "ci.job_updated" }
552
553// CheckState is the combined state of the checks of a commit.
554type CheckState string
555
556// Combined check states.
557const (
558 // CheckStateNone: the commit has no check.
559 CheckStateNone CheckState = ""
560 // CheckStatePending: a check is queued or in progress, none has failed.
561 CheckStatePending CheckState = "pending"
562 // CheckStateFailure: a check failed (or needs an action), whatever the others.
563 CheckStateFailure CheckState = "failure"
564 // CheckStateSuccess: every check passed.
565 CheckStateSuccess CheckState = "success"
566)
567
568// CombineChecks reduces the checks of a commit to one state, the one the
569// merge rule uses too: a failed check makes the commit failing even while
570// others are pending; else a pending check makes it pending; else it is a
571// success.
572func CombineChecks(checks []CICheck) CheckState {
573 if len(checks) == 0 {
574 return CheckStateNone
575 }
576 pending := false
577 for _, c := range checks {
578 switch {
579 case c.Status != CICompleted:
580 pending = true
581 case !c.Conclusion.PassesMerge():
582 return CheckStateFailure
583 }
584 }
585 if pending {
586 return CheckStatePending
587 }
588 return CheckStateSuccess
589}