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