// SPDX-License-Identifier: AGPL-3.0-or-later package domain import ( "errors" "fmt" "regexp" "slices" "strconv" "strings" "time" ) // CIStatus is where a run, a job or a check is in its life (GitHub's status). type CIStatus string // Statuses. const ( CIQueued CIStatus = "queued" CIInProgress CIStatus = "in_progress" CICompleted CIStatus = "completed" // CIPending is a job or a check held back by a concurrency group: another // entry of the group runs, or a newer one is waiting. Never a run's status. CIPending CIStatus = "pending" ) // CIConclusion is how a run, a job, a step or a check ended, with GitHub's // values. It is empty until the status is completed. type CIConclusion string // Conclusions. const ( CINoConclusion CIConclusion = "" CISuccess CIConclusion = "success" CIFailure CIConclusion = "failure" CICancelled CIConclusion = "cancelled" CISkipped CIConclusion = "skipped" CITimedOut CIConclusion = "timed_out" CINeutral CIConclusion = "neutral" CIActionRequired CIConclusion = "action_required" ) // PassesMerge reports whether a check that ended this way lets a merge go // through under FailingChecksBlock: success, skipped and neutral do. func (c CIConclusion) PassesMerge() bool { return c == CISuccess || c == CISkipped || c == CINeutral } // IsFailure reports whether the conclusion is a failure or a timeout. func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut } // CIStepState is where a step is in its life. type CIStepState string // Step states. const ( CIStepPending CIStepState = "pending" CIStepInProgress CIStepState = "in_progress" CIStepCompleted CIStepState = "completed" ) // CIJobPhase is where a job is for the server, finer than the status owners // see. type CIJobPhase string // Job phases. const ( // CIPhaseWaiting: waits for the jobs it needs or for a max-parallel slot. CIPhaseWaiting CIJobPhase = "waiting" // CIPhaseReady: planned, waits for a runner. CIPhaseReady CIJobPhase = "ready" // CIPhaseAssigned: given to a runner that has not acknowledged it. CIPhaseAssigned CIJobPhase = "assigned" // CIPhaseRunning: acknowledged by its runner. CIPhaseRunning CIJobPhase = "running" // CIPhaseEnded: over. CIPhaseEnded CIJobPhase = "ended" ) // CIIsolation is how a runner isolates jobs. type CIIsolation string // Isolations. const ( CIIsolationNone CIIsolation = "" CIIsolationVM CIIsolation = "vm" CIIsolationContainerSysbox CIIsolation = "container-sysbox" CIIsolationContainerPrivileged CIIsolation = "container-privileged" ) // CICancelReason says why a job is asked to stop. type CICancelReason string // Cancel reasons. const ( CICancelNone CICancelReason = "" CICancelUser CICancelReason = "user" CICancelFailFast CICancelReason = "fail-fast" CICancelTimeout CICancelReason = "timeout" ) // Limits and durations of the CI protocol (runner.proto). const ( // CIMaxLogBytes is the size of a job log, as stored (512 MiB). CIMaxLogBytes = 512 << 20 // CIMaxOutputBytes bounds one output and all outputs together (1 MiB). CIMaxOutputBytes = 1 << 20 // CIMaxLogLines and CIMaxLogChunkBytes bound one AppendLog request. CIMaxLogLines = 1000 CIMaxLogChunkBytes = 64 << 10 // CIMaxSteps bounds the steps of a job. CIMaxSteps = 1000 // CIMaxStepName bounds a step name, in characters. CIMaxStepName = 1024 // CIMaxLabels and CIMaxLabelLen bound the labels of a runner. CIMaxLabels = 64 CIMaxLabelLen = 64 // CIDefaultTimeoutMinutes is the timeout of a job without timeout-minutes. CIDefaultTimeoutMinutes = 360 // CIMaxAssignCancels is how many times in a row an assignment may be // cancelled before the job fails. CIMaxAssignCancels = 3 // CIRetentionDays is the retention of runs, jobs, checks and logs. CIRetentionDays = 90 ) // Durations of the CI protocol. const ( // CIAckTimeout is how long a runner has to send its first heartbeat. CIAckTimeout = 30 * time.Second // CIHeartbeatDeadline is how long a running job may go without heartbeat. CIHeartbeatDeadline = 2 * time.Minute // CICancelGrace is how long a runner has to conclude after a cancel request. CICancelGrace = 60 * time.Second // CITimeoutSlack is added to timeout-minutes by the server. CITimeoutSlack = 60 * time.Second // CIQueueTimeout is how long a job waits for a runner. CIQueueTimeout = 24 * time.Hour // CITokenGrace is how long a job token is recognised after the job ended. CITokenGrace = 24 * time.Hour ) // Messages of jobs ended by the server, GitHub's where it has them. const ( // CIQueueTimeoutMessage is GitHub's message for a job that no runner took. CIQueueTimeoutMessage = "The job was not acquired by Runner of type self-hosted even after multiple attempts" // CIShutdownMessage is GitHub's message for a runner that stops. CIShutdownMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled." // CILogLimitMessage ends a log that reached its limit. CILogLimitMessage = "The log of this job reached the limit of 512 MiB; the job is stopped." ) // CILostMessage is GitHub's message for a runner that stopped sending heartbeats. func CILostMessage(runner string) string { 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." } // CIRun is one execution of one workflow file for one event. type CIRun struct { Repo RepoName ID int64 Number int64 Attempt int32 WorkflowPath string WorkflowName string // Name is GitHub's name of the run: the evaluated run-name when the // workflow has one, else WorkflowName. WorkflowName stays the name of the // workflow (github.workflow, the checks). Name string Title string Event string Ref string Sha string // HeadSha is the head of the merge request of a pull_request run, whose Sha // is the test merge commit github.sha shows; empty for the other events. The // checks of the run attach to it (CheckSha). HeadSha string Actor string Status CIStatus Conclusion CIConclusion Error string CreatedAt time.Time StartedAt time.Time CompletedAt time.Time CancelRequested bool CancelRequestedAt time.Time // Trusted is set when the actor of the run is the owner (TrustedActor): // only then do its jobs receive secrets and a write permission. A re-run // keeps or lowers it, never raises it (RerunTrusted). False for a run // stored before the field existed: the safe reading. Trusted bool // Concurrency is the workflow-level concurrency of the latest attempt. Concurrency CIConcurrency // Version is the version of the stored record, which grows at every // change and never goes back. Version uint64 } // CheckSha is the commit the checks of the run attach to: the head of the merge // request for a pull_request run (as on GitHub, where the checks of the test // merge show on the head), the commit of the run otherwise. func (r CIRun) CheckSha() string { if r.HeadSha != "" { return r.HeadSha } return r.Sha } // CIRunSource is what a run was planned from: the workflow file at the sha of // the run, and the "github" context without the parts that depend on the job // and the attempt. type CIRunSource struct { RunID int64 Repo RepoName WorkflowYAML string GithubJSON string // Pusher is who pushed the commit: what the actions of other repositories // of the server may be resolved on behalf of. Pusher CIPusher } // CIStep is a step of a job with where its lines are in the job log. type CIStep struct { Number int32 Name string State CIStepState Conclusion CIConclusion // Outcome is how the step ended before continue-on-error was applied // ("steps..outcome"). Empty for a step that was not completed by a // runner that reports it: read it as Conclusion. Outcome CIConclusion StartedAt time.Time CompletedAt time.Time LogStart int64 // HasLines is set once a line of the step reached the log; LogStart is // then the number of its first line. LogEnd is not stored: see StepRanges. HasLines bool } // CIJob is one job instance (one matrix combination) in one attempt. type CIJob struct { ID string RunID int64 // Attempt is the attempt in which the job executed, RunAttempt the attempt // of the run the record belongs to: they differ for a job kept by a re-run // of the failed jobs. Attempt int32 RunAttempt int32 Repo RepoName Key string Name string Phase CIJobPhase Conclusion CIConclusion Error string Labels []string Needs []string RunnerID string RunnerName string Isolation CIIsolation Steps []CIStep CreatedAt time.Time StartedAt time.Time CompletedAt time.Time // Index is the position of the job in the workflow file, Combo the // position of the instance in its matrix. Index int Combo int // Planned is set once runs-on, the name and the contexts are evaluated. // A job that is not planned has not been expanded yet. Planned bool MatrixJSON string // ContextsJSON is the JSON object of contexts given to the runner, computed // when the job is planned. ContextsJSON string // ComboTotal is the number of instances of the job's matrix. ComboTotal int // TimeoutMinutes is the timeout of the job, at least 1. TimeoutMinutes int32 FailFast bool // ContinueOnError is the job's continue-on-error, evaluated when the job is // planned: its failure leaves the run, its siblings and the jobs that // need it unaffected (needs..result is success). The conclusion of // the job itself stays failure. ContinueOnError bool // Environment is the name of the environment of the job (its evaluated // "environment:"), "" for none. Set when the job is planned; the secrets // and variables of the environment are read when the job is assigned. Environment string // Permissions are the permissions of the job's repository token as the // workflow and the job ask for them (ResolvePermissions), key to level; // set when the job is planned. Empty: the default (a job planned before // they were stored). The trust rule of the run is applied when the token // is issued (TokenPermissions), not here. Permissions map[string]string // MaxParallel is the max-parallel of the strategy, 0 for no limit. MaxParallel int Outputs map[string]string // Concurrency is the concurrency group of the job: its own, evaluated when // the job is planned, else the one of its run (FromRun). Concurrency CIConcurrency // Scope is the id of the record of the job that called the reusable workflow // this job belongs to, "" for a job of the run's own workflow. CallID is the // id of the job in that called workflow (Key is "/"). Scope string CallID string // Call is set on the record of a job that calls a reusable workflow: it never // runs, it ends when the jobs of its scope have. Call *CICall // TokenHash is the hash of the job token of the current assignment; zero // when there is none. TokenHash TokenHash TokenValidUntil time.Time ReadyAt time.Time AssignedAt time.Time AckedAt time.Time // AssignCancels counts the assignments cancelled in a row. AssignCancels int CancelReason CICancelReason CancelMessage string CancelRequestedAt time.Time Version uint64 } // CICall is what a job calling a reusable workflow resolved (CI-3, D7). type CICall struct { Uses string // Path is the file in its repository, Repository "owner/repo" for github.com // or the name on this server, Ref what the workflow wrote ("" for a local // call), Sha the commit the call resolved to. Path, Repository, Ref, Sha string YAML string // InputsJSON is the inputs context of the called workflow. InputsJSON string // Secrets holds the names (upper case) of the secrets of the called workflow // that the calling workflow's "secrets:" gives, with empty values: the // expressions are read from the pinned calling workflow when a job is // assigned. Inherit: "secrets: inherit". Secrets map[string]string Inherit bool // Depth is 1 for a call from the run's own workflow. Depth int // Expanded: the jobs of the called workflow exist. Expanded bool // Concurrency is the workflow-level group of the called workflow. The call // holds it, with the group of the calling job (CIJob.Concurrency), until it // ends; its jobs wait for both. Concurrency CIConcurrency // Admitted: the call may start, the max-parallel of the calling job's // strategy allows it. Admitted bool // SecretCtx is the JSON of the other contexts the Secrets expressions read. SecretCtx string } // Status is the status owners see: a job is queued until its runner // acknowledged it. func (j CIJob) Status() CIStatus { switch j.Phase { case CIPhaseRunning: return CIInProgress case CIPhaseEnded: return CICompleted case CIPhaseWaiting: if j.Concurrency.State == CIConcurrencyPending { return CIPending } if j.Call != nil && j.Call.Concurrency.State == CIConcurrencyPending { return CIPending } if j.Call != nil && j.Call.Expanded && j.Call.Admitted { return CIInProgress } } return CIQueued } // Active reports whether the job is given to a runner and not over. func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning } // Ended reports whether the job is over. func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded } // HasToken reports whether a job token is set. func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} } // CICheck is the result of one job on one commit. type CICheck struct { ID string Repo RepoName Sha string Name string Status CIStatus Conclusion CIConclusion RunID int64 JobID string Attempt int32 WorkflowName string CreatedAt time.Time StartedAt time.Time CompletedAt time.Time Version uint64 } // CIRunner is a registered CI runner. TokenHash is never sent to clients. type CIRunner struct { ID string Name string Labels []string Isolation CIIsolation Version string Arch string OS string RegisteredAt time.Time TokenHash TokenHash } // RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs. const ( RunnerTokenPrefix = "ermiterun_" JobTokenPrefix = "ermitejob_" // CITokenRandomLen is the length of the base64url part of both. CITokenRandomLen = 43 ) // IsRunnerToken and IsJobToken report whether s has the shape of the secret. func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) } func IsJobToken(s string) bool { return hasTokenShape(s, JobTokenPrefix) } func hasTokenShape(s, prefix string) bool { rest, ok := strings.CutPrefix(s, prefix) if !ok || len(rest) != CITokenRandomLen { return false } for _, r := range rest { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': default: return false } } return true } // NewJobID names a job instance: "--". It is the same in // every attempt of the run. func NewJobID(runID int64, index, combo int) string { return fmt.Sprintf("%d-%d-%d", runID, index, combo) } // ParseJobID returns the run of a job id, or ok false when s is not one. func ParseJobID(s string) (runID int64, index, combo int, ok bool) { parts := strings.Split(s, "-") if len(parts) != 3 { return 0, 0, 0, false } r, err1 := strconv.ParseInt(parts[0], 10, 64) i, err2 := strconv.Atoi(parts[1]) c, err3 := strconv.Atoi(parts[2]) if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s { return 0, 0, 0, false } return r, i, c, true } var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) // ParseRunnerName validates a runner name. func ParseRunnerName(s string) error { if !labelRE.MatchString(s) { return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid) } return nil } // ParseRunnerLabels validates labels and returns them lowercased, without // duplicates, in order of first appearance. func ParseRunnerLabels(labels []string) ([]string, error) { if len(labels) > CIMaxLabels { return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels) } var out []string for _, l := range labels { if !labelRE.MatchString(l) { return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen) } l = strings.ToLower(l) if !slices.Contains(out, l) { out = append(out, l) } } return out, nil } // RunnerLabels returns the labels a runner matches: the ones it chose plus // the implicit "self-hosted", its os and its arch, lowercase and sorted. func RunnerLabels(chosen []string, os, arch string) []string { out := slices.Clone(chosen) for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} { if !slices.Contains(out, l) { out = append(out, l) } } slices.Sort(out) return out } // LabelsMatch reports whether every wanted label is among have, ignoring case. func LabelsMatch(want, have []string) bool { for _, w := range want { if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) { return false } } return true } var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`) // CheckOutputs validates the outputs of a job: names, case-insensitive // uniqueness and the 1 MiB limits. func CheckOutputs(outputs map[string]string) error { seen := make(map[string]bool, len(outputs)) total := 0 for k, v := range outputs { if !outputNameRE.MatchString(k) { return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k) } lk := strings.ToLower(k) if seen[lk] { return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k) } seen[lk] = true if len(v) > CIMaxOutputBytes { return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k) } total += len(k) + len(v) } if total > CIMaxOutputBytes { return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid) } return nil } // JobEndedError is returned for a call that needs a running job when the job // has ended. It matches ErrPreconditionFailed; the API adds the JobEnded // detail. type JobEndedError struct { Conclusion CIConclusion Message string } // Error implements error. func (e *JobEndedError) Error() string { return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion) } // Is makes errors.Is(err, ErrPreconditionFailed) true. func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed } // IsJobEnded returns the JobEndedError in err. func IsJobEnded(err error) (*JobEndedError, bool) { var e *JobEndedError if errors.As(err, &e) { return e, true } return nil, false } // ErrOutOfRange means an offset is beyond what the server holds. var ErrOutOfRange = errors.New("out of range") // CILogLine is one line of a job log. type CILogLine struct { Time time.Time // Text has no line feed. Text string // Continued marks a piece of a line that was too long to send whole. Continued bool } // StepRange is where the lines of a step are in the job log. type StepRange struct { Start, End int64 } // StepRanges returns the range of every step of the job for a log of lines // lines: a step with lines ends where the next step with lines begins (the // last ends at the end of the log); a step without lines is empty, placed // where the previous step ends. func StepRanges(steps []CIStep, lines int64) []StepRange { out := make([]StepRange, len(steps)) var pos int64 for i, s := range steps { if !s.HasLines { out[i] = StepRange{Start: pos, End: pos} continue } end := lines for _, n := range steps[i+1:] { if n.HasLines { end = n.LogStart break } } out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)} pos = out[i].End } return out } // StepOfLine returns the number of the step a log line belongs to, 0 when no // step has that line. func StepOfLine(steps []CIStep, line int64) int32 { var found int32 for _, s := range steps { if s.HasLines && s.LogStart <= line { found = s.Number } } return found } // RunUpdated is published when a CI run is created or changes. It carries the // visibility and the creation time of the repository, like PushEvent. type RunUpdated struct { Run CIRun At time.Time Visibility Visibility CreatedAt time.Time } // EventKind implements Event. func (RunUpdated) EventKind() string { return "ci.run_updated" } // JobUpdated is published when a CI job is created or changes (status, // conclusion, runner or steps; never per log line). Lines is the number of // lines of the job log at that time, for the end of the step in progress. type JobUpdated struct { Job CIJob Lines int64 At time.Time Visibility Visibility CreatedAt time.Time } // EventKind implements Event. func (JobUpdated) EventKind() string { return "ci.job_updated" } // CheckState is the combined state of the checks of a commit. type CheckState string // Combined check states. const ( // CheckStateNone: the commit has no check. CheckStateNone CheckState = "" // CheckStatePending: a check is queued or in progress, none has failed. CheckStatePending CheckState = "pending" // CheckStateFailure: a check failed (or needs an action), whatever the others. CheckStateFailure CheckState = "failure" // CheckStateSuccess: every check passed. CheckStateSuccess CheckState = "success" ) // CombineChecks reduces the checks of a commit to one state, the one the // merge rule uses too: a failed check makes the commit failing even while // others are pending; else a pending check makes it pending; else it is a // success. func CombineChecks(checks []CICheck) CheckState { if len(checks) == 0 { return CheckStateNone } pending := false for _, c := range checks { switch { case c.Status != CICompleted: pending = true case !c.Conclusion.PassesMerge(): return CheckStateFailure } } if pending { return CheckStatePending } return CheckStateSuccess }