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