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