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