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 // Name is GitHub's name of the run: the evaluated run-name when the
165 // workflow has one, else WorkflowName. WorkflowName stays the name of the
166 // workflow (github.workflow, the checks).
167 Name string
168 Title string
169 Event string
170 Ref string
171 Sha string
172 Actor string
173 Status CIStatus
174 Conclusion CIConclusion
175 Error string
176 CreatedAt time.Time
177 StartedAt time.Time
178 CompletedAt time.Time
179 CancelRequested bool
180 CancelRequestedAt time.Time
181 // Version is the version of the stored record, which grows at every
182 // change and never goes back.
183 Version uint64
184}
185
186// CIRunSource is what a run was planned from: the workflow file at the sha of
187// the run, and the "github" context without the parts that depend on the job
188// and the attempt.
189type CIRunSource struct {
190 RunID int64
191 Repo RepoName
192 WorkflowYAML string
193 GithubJSON string
194}
195
196// CIStep is a step of a job with where its lines are in the job log.
197type CIStep struct {
198 Number int32
199 Name string
200 State CIStepState
201 Conclusion CIConclusion
202 StartedAt time.Time
203 CompletedAt time.Time
204 LogStart int64
205 // HasLines is set once a line of the step reached the log; LogStart is
206 // then the number of its first line. LogEnd is not stored: see StepRanges.
207 HasLines bool
208}
209
210// CIJob is one job instance (one matrix combination) in one attempt.
211type CIJob struct {
212 ID string
213 RunID int64
214 // Attempt is the attempt in which the job executed, RunAttempt the attempt
215 // of the run the record belongs to: they differ for a job kept by a re-run
216 // of the failed jobs.
217 Attempt int32
218 RunAttempt int32
219 Repo RepoName
220 Key string
221 Name string
222 Phase CIJobPhase
223 Conclusion CIConclusion
224 Error string
225 Labels []string
226 Needs []string
227 RunnerID string
228 RunnerName string
229 Isolation CIIsolation
230 Steps []CIStep
231
232 CreatedAt time.Time
233 StartedAt time.Time
234 CompletedAt time.Time
235
236 // Index is the position of the job in the workflow file, Combo the
237 // position of the instance in its matrix.
238 Index int
239 Combo int
240 // Planned is set once runs-on, the name and the contexts are evaluated.
241 // A job that is not planned has not been expanded yet.
242 Planned bool
243 MatrixJSON string
244 // ContextsJSON is the JSON object of contexts given to the runner, computed
245 // when the job is planned.
246 ContextsJSON string
247 // ComboTotal is the number of instances of the job's matrix.
248 ComboTotal int
249 // TimeoutMinutes is the timeout of the job, at least 1.
250 TimeoutMinutes int32
251 FailFast bool
252 // ContinueOnError is the job's continue-on-error, evaluated when the job is
253 // planned: its failure leaves the run, its siblings and the jobs that
254 // need it unaffected (needs.<job>.result is success). The conclusion of
255 // the job itself stays failure.
256 ContinueOnError bool
257 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
258 MaxParallel int
259 Outputs map[string]string
260
261 // TokenHash is the hash of the job token of the current assignment; zero
262 // when there is none.
263 TokenHash TokenHash
264 TokenValidUntil time.Time
265 ReadyAt time.Time
266 AssignedAt time.Time
267 AckedAt time.Time
268 // AssignCancels counts the assignments cancelled in a row.
269 AssignCancels int
270
271 CancelReason CICancelReason
272 CancelMessage string
273 CancelRequestedAt time.Time
274
275 Version uint64
276}
277
278// Status is the status owners see: a job is queued until its runner
279// acknowledged it.
280func (j CIJob) Status() CIStatus {
281 switch j.Phase {
282 case CIPhaseRunning:
283 return CIInProgress
284 case CIPhaseEnded:
285 return CICompleted
286 }
287 return CIQueued
288}
289
290// Active reports whether the job is given to a runner and not over.
291func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
292
293// Ended reports whether the job is over.
294func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
295
296// HasToken reports whether a job token is set.
297func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
298
299// CICheck is the result of one job on one commit.
300type CICheck struct {
301 ID string
302 Repo RepoName
303 Sha string
304 Name string
305 Status CIStatus
306 Conclusion CIConclusion
307 RunID int64
308 JobID string
309 Attempt int32
310 WorkflowName string
311 CreatedAt time.Time
312 StartedAt time.Time
313 CompletedAt time.Time
314 Version uint64
315}
316
317// CIRunner is a registered CI runner. TokenHash is never sent to clients.
318type CIRunner struct {
319 ID string
320 Name string
321 Labels []string
322 Isolation CIIsolation
323 Version string
324 Arch string
325 OS string
326 RegisteredAt time.Time
327 TokenHash TokenHash
328}
329
330// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
331const (
332 RunnerTokenPrefix = "ermiterun_"
333 JobTokenPrefix = "ermitejob_"
334 // CITokenRandomLen is the length of the base64url part of both.
335 CITokenRandomLen = 43
336)
337
338// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
339func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
340func IsJobToken(s string) bool { return hasTokenShape(s, JobTokenPrefix) }
341
342func hasTokenShape(s, prefix string) bool {
343 rest, ok := strings.CutPrefix(s, prefix)
344 if !ok || len(rest) != CITokenRandomLen {
345 return false
346 }
347 for _, r := range rest {
348 switch {
349 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
350 default:
351 return false
352 }
353 }
354 return true
355}
356
357// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
358// every attempt of the run.
359func NewJobID(runID int64, index, combo int) string {
360 return fmt.Sprintf("%d-%d-%d", runID, index, combo)
361}
362
363// ParseJobID returns the run of a job id, or ok false when s is not one.
364func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
365 parts := strings.Split(s, "-")
366 if len(parts) != 3 {
367 return 0, 0, 0, false
368 }
369 r, err1 := strconv.ParseInt(parts[0], 10, 64)
370 i, err2 := strconv.Atoi(parts[1])
371 c, err3 := strconv.Atoi(parts[2])
372 if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
373 return 0, 0, 0, false
374 }
375 return r, i, c, true
376}
377
378var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
379
380// ParseRunnerName validates a runner name.
381func ParseRunnerName(s string) error {
382 if !labelRE.MatchString(s) {
383 return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
384 }
385 return nil
386}
387
388// ParseRunnerLabels validates labels and returns them lowercased, without
389// duplicates, in order of first appearance.
390func ParseRunnerLabels(labels []string) ([]string, error) {
391 if len(labels) > CIMaxLabels {
392 return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
393 }
394 var out []string
395 for _, l := range labels {
396 if !labelRE.MatchString(l) {
397 return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
398 }
399 l = strings.ToLower(l)
400 if !slices.Contains(out, l) {
401 out = append(out, l)
402 }
403 }
404 return out, nil
405}
406
407// RunnerLabels returns the labels a runner matches: the ones it chose plus
408// the implicit "self-hosted", its os and its arch, lowercase and sorted.
409func RunnerLabels(chosen []string, os, arch string) []string {
410 out := slices.Clone(chosen)
411 for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
412 if !slices.Contains(out, l) {
413 out = append(out, l)
414 }
415 }
416 slices.Sort(out)
417 return out
418}
419
420// LabelsMatch reports whether every wanted label is among have, ignoring case.
421func LabelsMatch(want, have []string) bool {
422 for _, w := range want {
423 if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
424 return false
425 }
426 }
427 return true
428}
429
430var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
431
432// CheckOutputs validates the outputs of a job: names, case-insensitive
433// uniqueness and the 1 MiB limits.
434func CheckOutputs(outputs map[string]string) error {
435 seen := make(map[string]bool, len(outputs))
436 total := 0
437 for k, v := range outputs {
438 if !outputNameRE.MatchString(k) {
439 return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
440 }
441 lk := strings.ToLower(k)
442 if seen[lk] {
443 return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
444 }
445 seen[lk] = true
446 if len(v) > CIMaxOutputBytes {
447 return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
448 }
449 total += len(k) + len(v)
450 }
451 if total > CIMaxOutputBytes {
452 return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
453 }
454 return nil
455}
456
457// JobEndedError is returned for a call that needs a running job when the job
458// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
459// detail.
460type JobEndedError struct {
461 Conclusion CIConclusion
462 Message string
463}
464
465// Error implements error.
466func (e *JobEndedError) Error() string {
467 return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
468}
469
470// Is makes errors.Is(err, ErrPreconditionFailed) true.
471func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
472
473// IsJobEnded returns the JobEndedError in err.
474func IsJobEnded(err error) (*JobEndedError, bool) {
475 var e *JobEndedError
476 if errors.As(err, &e) {
477 return e, true
478 }
479 return nil, false
480}
481
482// ErrOutOfRange means an offset is beyond what the server holds.
483var ErrOutOfRange = errors.New("out of range")
484
485// CILogLine is one line of a job log.
486type CILogLine struct {
487 Time time.Time
488 // Text has no line feed.
489 Text string
490 // Continued marks a piece of a line that was too long to send whole.
491 Continued bool
492}
493
494// StepRange is where the lines of a step are in the job log.
495type StepRange struct {
496 Start, End int64
497}
498
499// StepRanges returns the range of every step of the job for a log of lines
500// lines: a step with lines ends where the next step with lines begins (the
501// last ends at the end of the log); a step without lines is empty, placed
502// where the previous step ends.
503func StepRanges(steps []CIStep, lines int64) []StepRange {
504 out := make([]StepRange, len(steps))
505 var pos int64
506 for i, s := range steps {
507 if !s.HasLines {
508 out[i] = StepRange{Start: pos, End: pos}
509 continue
510 }
511 end := lines
512 for _, n := range steps[i+1:] {
513 if n.HasLines {
514 end = n.LogStart
515 break
516 }
517 }
518 out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
519 pos = out[i].End
520 }
521 return out
522}
523
524// StepOfLine returns the number of the step a log line belongs to, 0 when no
525// step has that line.
526func StepOfLine(steps []CIStep, line int64) int32 {
527 var found int32
528 for _, s := range steps {
529 if s.HasLines && s.LogStart <= line {
530 found = s.Number
531 }
532 }
533 return found
534}
535
536// RunUpdated is published when a CI run is created or changes. It carries the
537// visibility and the creation time of the repository, like PushEvent.
538type RunUpdated struct {
539 Run CIRun
540 At time.Time
541 Visibility Visibility
542 CreatedAt time.Time
543}
544
545// EventKind implements Event.
546func (RunUpdated) EventKind() string { return "ci.run_updated" }
547
548// JobUpdated is published when a CI job is created or changes (status,
549// conclusion, runner or steps; never per log line). Lines is the number of
550// lines of the job log at that time, for the end of the step in progress.
551type JobUpdated struct {
552 Job CIJob
553 Lines int64
554 At time.Time
555 Visibility Visibility
556 CreatedAt time.Time
557}
558
559// EventKind implements Event.
560func (JobUpdated) EventKind() string { return "ci.job_updated" }
561
562// CheckState is the combined state of the checks of a commit.
563type CheckState string
564
565// Combined check states.
566const (
567 // CheckStateNone: the commit has no check.
568 CheckStateNone CheckState = ""
569 // CheckStatePending: a check is queued or in progress, none has failed.
570 CheckStatePending CheckState = "pending"
571 // CheckStateFailure: a check failed (or needs an action), whatever the others.
572 CheckStateFailure CheckState = "failure"
573 // CheckStateSuccess: every check passed.
574 CheckStateSuccess CheckState = "success"
575)
576
577// CombineChecks reduces the checks of a commit to one state, the one the
578// merge rule uses too: a failed check makes the commit failing even while
579// others are pending; else a pending check makes it pending; else it is a
580// success.
581func CombineChecks(checks []CICheck) CheckState {
582 if len(checks) == 0 {
583 return CheckStateNone
584 }
585 pending := false
586 for _, c := range checks {
587 switch {
588 case c.Status != CICompleted:
589 pending = true
590 case !c.Conclusion.PassesMerge():
591 return CheckStateFailure
592 }
593 }
594 if pending {
595 return CheckStatePending
596 }
597 return CheckStateSuccess
598}