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