1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 kv "bnema.dev/ermite-kv"
15 "google.golang.org/protobuf/proto"
16
17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Key layout of CI. Repository names hold no '/', so a prefix ending in "/"
23// never matches another repository; ids are zero-padded so that keys sort
24// numerically. Job records are keyed by run, attempt of the run and job id.
25//
26// run/<repo>/<id:020d> CIRun
27// runsrc/<id:020d> CIRunSource
28// job/<run:020d>/<attempt:010d>/<id> CIJob
29// check/<repo>/<sha>/<check id> CICheck
30// runseq Sequence: last run id
31// runnum/<repo>/<workflow path> Sequence: last run number
32// runner/<id>, runnername/<name>, runnertoken/<hash>
33//
34// Indexes, derived from the job and run records by every write:
35//
36// ciopen/<id:020d> run not completed (value: repo)
37// ciready/<ready_at:020d>/<job key> job waiting for a runner
38// ciactive/<job key> job assigned or running
39// jobtoken/<hash> job token (value: job key)
40// concur/<repo>/<key(group)> concurrency group (ci_concurrency.go)
41// cireftip/<repo>/<ref> who moves a branch or tag to its object (ci_tip.go)
42//
43// The job repository tokens and the action resolutions are in ci_token.go, the
44// annotations in ci_annotation.go.
45const (
46 runPrefix = "run/"
47 runSrcPrefix = "runsrc/"
48 jobPrefix = "job/"
49 checkPrefix = "check/"
50 runSeqKey = "runseq"
51 runNumPrefix = "runnum/"
52 runnerPrefix = "runner/"
53 runnerName = "runnername/"
54 runnerTokPref = "runnertoken/"
55 openPrefix = "ciopen/"
56 readyPrefix = "ciready/"
57 activePrefix = "ciactive/"
58 jobTokPrefix = "jobtoken/"
59 idFormat = "%020d"
60)
61
62// CI is the ermite-kv backed ports.CIStore. It shares the database of the
63// Store it comes from.
64type CI struct {
65 db *kv.DB
66}
67
68var _ ports.CIStore = (*CI)(nil)
69
70// CI returns the CI store over the same database. It is valid until the Store
71// is closed.
72func (s *Store) CI() *CI { return &CI{db: s.db} }
73
74func runKey(repo domain.RepoName, id int64) []byte {
75 return []byte(runPrefix + repo.String() + "/" + fmt.Sprintf(idFormat, id))
76}
77func runsPrefix(repo domain.RepoName) string { return runPrefix + repo.String() + "/" }
78func runSrcKey(id int64) []byte { return []byte(runSrcPrefix + fmt.Sprintf(idFormat, id)) }
79func openKey(id int64) []byte { return []byte(openPrefix + fmt.Sprintf(idFormat, id)) }
80func jobsPrefix(runID int64) string { return jobPrefix + fmt.Sprintf(idFormat, runID) + "/" }
81func jobAttemptPrefix(runID int64, attempt int32) string {
82 return jobsPrefix(runID) + fmt.Sprintf("%010d", attempt) + "/"
83}
84func jobRef(runID int64, attempt int32, id string) string {
85 return jobAttemptPrefix(runID, attempt) + id
86}
87func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
88func checksPrefix(repo domain.RepoName, sha string) string {
89 return checkPrefix + repo.String() + "/" + sha + "/"
90}
91func checkKey(repo domain.RepoName, sha, id string) []byte {
92 return []byte(checksPrefix(repo, sha) + id)
93}
94func runNumKey(repo domain.RepoName, workflow string) []byte {
95 return []byte(runNumPrefix + repo.String() + "/" + workflow)
96}
97func readyKey(at time.Time, ref string) []byte {
98 return []byte(readyPrefix + fmt.Sprintf(idFormat, at.UnixNano()) + "/" + ref)
99}
100func activeKey(ref string) []byte { return []byte(activePrefix + ref) }
101func jobTokenKey(h domain.TokenHash) []byte {
102 return []byte(jobTokPrefix + h.String())
103}
104
105// --- encoding ---
106
107func encodeStatus(s domain.CIStatus) storagepb.CIStatus {
108 switch s {
109 case domain.CIQueued:
110 return storagepb.CIStatus_CI_STATUS_QUEUED
111 case domain.CIInProgress:
112 return storagepb.CIStatus_CI_STATUS_IN_PROGRESS
113 case domain.CICompleted:
114 return storagepb.CIStatus_CI_STATUS_COMPLETED
115 case domain.CIPending:
116 return storagepb.CIStatus_CI_STATUS_PENDING
117 }
118 return storagepb.CIStatus_CI_STATUS_UNSPECIFIED
119}
120
121func decodeStatus(s storagepb.CIStatus) domain.CIStatus {
122 switch s {
123 case storagepb.CIStatus_CI_STATUS_IN_PROGRESS:
124 return domain.CIInProgress
125 case storagepb.CIStatus_CI_STATUS_COMPLETED:
126 return domain.CICompleted
127 case storagepb.CIStatus_CI_STATUS_PENDING:
128 return domain.CIPending
129 }
130 return domain.CIQueued
131}
132
133var conclusions = []struct {
134 d domain.CIConclusion
135 p storagepb.CIConclusion
136}{
137 {domain.CISuccess, storagepb.CIConclusion_CI_CONCLUSION_SUCCESS},
138 {domain.CIFailure, storagepb.CIConclusion_CI_CONCLUSION_FAILURE},
139 {domain.CICancelled, storagepb.CIConclusion_CI_CONCLUSION_CANCELLED},
140 {domain.CISkipped, storagepb.CIConclusion_CI_CONCLUSION_SKIPPED},
141 {domain.CITimedOut, storagepb.CIConclusion_CI_CONCLUSION_TIMED_OUT},
142 {domain.CINeutral, storagepb.CIConclusion_CI_CONCLUSION_NEUTRAL},
143 {domain.CIActionRequired, storagepb.CIConclusion_CI_CONCLUSION_ACTION_REQUIRED},
144}
145
146func encodeConclusion(c domain.CIConclusion) storagepb.CIConclusion {
147 for _, e := range conclusions {
148 if e.d == c {
149 return e.p
150 }
151 }
152 return storagepb.CIConclusion_CI_CONCLUSION_UNSPECIFIED
153}
154
155func decodeConclusion(c storagepb.CIConclusion) domain.CIConclusion {
156 for _, e := range conclusions {
157 if e.p == c {
158 return e.d
159 }
160 }
161 return domain.CINoConclusion
162}
163
164var phases = []struct {
165 d domain.CIJobPhase
166 p storagepb.CIJobPhase
167}{
168 {domain.CIPhaseWaiting, storagepb.CIJobPhase_CI_JOB_PHASE_WAITING},
169 {domain.CIPhaseReady, storagepb.CIJobPhase_CI_JOB_PHASE_READY},
170 {domain.CIPhaseAssigned, storagepb.CIJobPhase_CI_JOB_PHASE_ASSIGNED},
171 {domain.CIPhaseRunning, storagepb.CIJobPhase_CI_JOB_PHASE_RUNNING},
172 {domain.CIPhaseEnded, storagepb.CIJobPhase_CI_JOB_PHASE_ENDED},
173}
174
175func encodePhase(p domain.CIJobPhase) (storagepb.CIJobPhase, error) {
176 for _, e := range phases {
177 if e.d == p {
178 return e.p, nil
179 }
180 }
181 return 0, fmt.Errorf("invalid job phase %q", p)
182}
183
184func decodePhase(p storagepb.CIJobPhase) domain.CIJobPhase {
185 for _, e := range phases {
186 if e.p == p {
187 return e.d
188 }
189 }
190 return domain.CIPhaseWaiting
191}
192
193var isolations = []struct {
194 d domain.CIIsolation
195 p storagepb.CIIsolation
196}{
197 {domain.CIIsolationVM, storagepb.CIIsolation_CI_ISOLATION_VM},
198 {domain.CIIsolationContainerSysbox, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_SYSBOX},
199 {domain.CIIsolationContainerPrivileged, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_PRIVILEGED},
200}
201
202func encodeIsolation(i domain.CIIsolation) storagepb.CIIsolation {
203 for _, e := range isolations {
204 if e.d == i {
205 return e.p
206 }
207 }
208 return storagepb.CIIsolation_CI_ISOLATION_UNSPECIFIED
209}
210
211func decodeIsolation(i storagepb.CIIsolation) domain.CIIsolation {
212 for _, e := range isolations {
213 if e.p == i {
214 return e.d
215 }
216 }
217 return domain.CIIsolationNone
218}
219
220var stepStates = []struct {
221 d domain.CIStepState
222 p storagepb.CIStepState
223}{
224 {domain.CIStepPending, storagepb.CIStepState_CI_STEP_STATE_PENDING},
225 {domain.CIStepInProgress, storagepb.CIStepState_CI_STEP_STATE_IN_PROGRESS},
226 {domain.CIStepCompleted, storagepb.CIStepState_CI_STEP_STATE_COMPLETED},
227}
228
229func encodeStepState(s domain.CIStepState) storagepb.CIStepState {
230 for _, e := range stepStates {
231 if e.d == s {
232 return e.p
233 }
234 }
235 return storagepb.CIStepState_CI_STEP_STATE_UNSPECIFIED
236}
237
238func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
239 for _, e := range stepStates {
240 if e.p == s {
241 return e.d
242 }
243 }
244 return domain.CIStepPending
245}
246
247var cancelReasons = []struct {
248 d domain.CICancelReason
249 p storagepb.CICancelReason
250}{
251 {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
252 {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
253 {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
254}
255
256func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
257 for _, e := range cancelReasons {
258 if e.d == r {
259 return e.p
260 }
261 }
262 return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
263}
264
265func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
266 for _, e := range cancelReasons {
267 if e.p == r {
268 return e.d
269 }
270 }
271 return domain.CICancelNone
272}
273
274func encodeRun(r domain.CIRun) ([]byte, error) {
275 if r.Repo.IsZero() {
276 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
277 }
278 return mustMarshal(&storagepb.CIRun{
279 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
280 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
281 Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, PullNumber: r.PullNumber, Actor: r.Actor,
282 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
283 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
284 CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,
285 Concurrency: encodeConcurrency(r.Concurrency),
286 }), nil
287}
288
289func decodeRun(item kv.Item) (domain.CIRun, error) {
290 var pb storagepb.CIRun
291 if err := proto.Unmarshal(item.Value, &pb); err != nil {
292 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
293 }
294 repo, err := domain.ParseRepoName(pb.GetRepo())
295 if err != nil {
296 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
297 }
298 name := pb.GetName()
299 if name == "" {
300 name = pb.GetWorkflowName() // stored before the field existed
301 }
302 return domain.CIRun{
303 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
304 WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
305 Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), PullNumber: pb.GetPullNumber(), Actor: pb.GetActor(),
306 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
307 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
308 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
309 Trusted: pb.GetTrusted(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version,
310 }, nil
311}
312
313// encodeJob serializes a job for storage.
314//
315//hexcheck:ignore suspicious-business-logic-in-adapter encodes a job as protobuf, rejecting an empty repository and omitting an absent token hash
316func encodeJob(j domain.CIJob) ([]byte, error) {
317 if j.Repo.IsZero() {
318 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
319 }
320 phase, err := encodePhase(j.Phase)
321 if err != nil {
322 return nil, err
323 }
324 steps := make([]*storagepb.CIStep, 0, len(j.Steps))
325 for _, s := range j.Steps {
326 steps = append(steps, &storagepb.CIStep{
327 Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
328 StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
329 LogStart: s.LogStart, HasLines: s.HasLines, Outcome: encodeConclusion(s.Outcome),
330 })
331 }
332 pb := &storagepb.CIJob{
333 Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
334 Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
335 Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
336 Isolation: encodeIsolation(j.Isolation), Steps: steps,
337 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
338 Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
339 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
340 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
341 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
342 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
343 CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions,
344 Concurrency: encodeConcurrency(j.Concurrency), Scope: j.Scope, CallId: j.CallID, Call: encodeCall(j.Call),
345 }
346 if j.HasToken() {
347 pb.TokenHash = j.TokenHash[:]
348 }
349 return mustMarshal(pb), nil
350}
351
352func decodeJob(item kv.Item) (domain.CIJob, error) {
353 var pb storagepb.CIJob
354 if err := proto.Unmarshal(item.Value, &pb); err != nil {
355 return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
356 }
357 repo, err := domain.ParseRepoName(pb.GetRepo())
358 if err != nil {
359 return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
360 }
361 j := domain.CIJob{
362 ID: pb.GetId(), RunID: pb.GetRunId(), Attempt: pb.GetAttempt(), RunAttempt: pb.GetRunAttempt(), Repo: repo,
363 Key: pb.GetKey(), Name: pb.GetName(), Phase: decodePhase(pb.GetPhase()), Conclusion: decodeConclusion(pb.GetConclusion()),
364 Error: pb.GetError(), Labels: pb.GetLabels(), Needs: pb.GetNeeds(), RunnerID: pb.GetRunnerId(), RunnerName: pb.GetRunnerName(),
365 Isolation: decodeIsolation(pb.GetIsolation()),
366 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
367 Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
368 ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), ContinueOnError: pb.GetContinueOnError(),
369 MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
370 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
371 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
372 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
373 Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version,
374 Scope: pb.GetScope(), CallID: pb.GetCallId(), Call: decodeCall(pb.GetCall()),
375 }
376 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
377 copy(j.TokenHash[:], pb.GetTokenHash())
378 for _, s := range pb.GetSteps() {
379 j.Steps = append(j.Steps, domain.CIStep{
380 Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
381 StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
382 LogStart: s.GetLogStart(), HasLines: s.GetHasLines(), Outcome: decodeConclusion(s.GetOutcome()),
383 })
384 }
385 return j, nil
386}
387
388func encodeCheck(c domain.CICheck) []byte {
389 return mustMarshal(&storagepb.CICheck{
390 Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
391 Conclusion: encodeConclusion(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
392 WorkflowName: c.WorkflowName, CreatedAt: encodeTime(c.CreatedAt), StartedAt: encodeTime(c.StartedAt),
393 CompletedAt: encodeTime(c.CompletedAt),
394 })
395}
396
397func decodeCheck(item kv.Item) (domain.CICheck, error) {
398 var pb storagepb.CICheck
399 if err := proto.Unmarshal(item.Value, &pb); err != nil {
400 return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
401 }
402 repo, err := domain.ParseRepoName(pb.GetRepo())
403 if err != nil {
404 return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
405 }
406 return domain.CICheck{
407 ID: pb.GetId(), Repo: repo, Sha: pb.GetSha(), Name: pb.GetName(), Status: decodeStatus(pb.GetStatus()),
408 Conclusion: decodeConclusion(pb.GetConclusion()), RunID: pb.GetRunId(), JobID: pb.GetJobId(), Attempt: pb.GetAttempt(),
409 WorkflowName: pb.GetWorkflowName(), CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()),
410 CompletedAt: decodeTime(pb.GetCompletedAt()), Version: item.Version,
411 }, nil
412}
413
414func encodeRunner(r domain.CIRunner) []byte {
415 return mustMarshal(&storagepb.CIRunner{
416 Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: encodeIsolation(r.Isolation), Version: r.Version,
417 Arch: r.Arch, Os: r.OS, RegisteredAt: encodeTime(r.RegisteredAt), TokenHash: r.TokenHash[:],
418 })
419}
420
421func decodeRunner(val []byte) (domain.CIRunner, error) {
422 var pb storagepb.CIRunner
423 if err := proto.Unmarshal(val, &pb); err != nil {
424 return domain.CIRunner{}, fmt.Errorf("decode runner: %w", err)
425 }
426 r := domain.CIRunner{
427 ID: pb.GetId(), Name: pb.GetName(), Labels: pb.GetLabels(), Isolation: decodeIsolation(pb.GetIsolation()),
428 Version: pb.GetVersion(), Arch: pb.GetArch(), OS: pb.GetOs(), RegisteredAt: decodeTime(pb.GetRegisteredAt()),
429 }
430 copy(r.TokenHash[:], pb.GetTokenHash())
431 return r, nil
432}
433
434// --- writes ---
435
436// stage adds the preconditions and writes of one record to the batch.
437func stage(b *kv.Batch, key, val []byte, version uint64) {
438 if version == 0 {
439 b.RequireAbsent(key)
440 } else {
441 b.RequireVersion(key, version)
442 }
443 b.Put(key, val)
444}
445
446// runIndexes stages the open-run index of the run.
447func runIndexes(b *kv.Batch, r domain.CIRun) {
448 if r.Status == domain.CICompleted {
449 b.Delete(openKey(r.ID))
450 return
451 }
452 b.Put(openKey(r.ID), []byte(r.Repo.String()))
453}
454
455// jobIndexes stages the ready, active and token indexes of the job. old is
456// the job as stored before, if any: its token index goes when the token
457// changed.
458func (c *CI) jobIndexes(b *kv.Batch, j domain.CIJob, old *domain.CIJob) {
459 ref := jobRef(j.RunID, j.RunAttempt, j.ID)
460 if j.Phase == domain.CIPhaseReady {
461 b.Put(readyKey(j.ReadyAt, ref), nil)
462 } else {
463 b.Delete(readyKey(j.ReadyAt, ref))
464 }
465 if old != nil && old.Phase == domain.CIPhaseReady && (j.Phase != domain.CIPhaseReady || !old.ReadyAt.Equal(j.ReadyAt)) {
466 b.Delete(readyKey(old.ReadyAt, ref))
467 }
468 if j.Active() {
469 b.Put(activeKey(ref), nil)
470 } else {
471 b.Delete(activeKey(ref))
472 }
473 if old != nil && old.HasToken() && old.TokenHash != j.TokenHash {
474 b.Delete(jobTokenKey(old.TokenHash))
475 }
476 if j.HasToken() {
477 b.Put(jobTokenKey(j.TokenHash), []byte(ref))
478 }
479}
480
481// guardRepo is guardRepoKey on the database of the CI store, for the writes that
482// create keys under a repository.
483func (c *CI) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error {
484 return guardRepoKey(ctx, c.db, b, repo)
485}
486
487// stageRecords stages the writes of w. guarded collects the repositories that
488// need guarding.
489func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (guarded map[domain.RepoName]bool, err error) {
490 guarded = map[domain.RepoName]bool{}
491 for _, r := range w.Runs {
492 val, err := encodeRun(r)
493 if err != nil {
494 return nil, err
495 }
496 stage(b, runKey(r.Repo, r.ID), val, r.Version)
497 runIndexes(b, r)
498 if r.Version == 0 {
499 guarded[r.Repo] = true
500 }
501 }
502 for _, j := range w.Jobs {
503 val, err := encodeJob(j)
504 if err != nil {
505 return nil, err
506 }
507 key := jobKey(j.RunID, j.RunAttempt, j.ID)
508 var old *domain.CIJob
509 if j.Version != 0 {
510 item, ok := c.db.Get(ctx, key)
511 if !ok {
512 return nil, domain.ErrConflict
513 }
514 if o, err := decodeJob(item); err == nil {
515 old = &o
516 }
517 }
518 stage(b, key, val, j.Version)
519 c.jobIndexes(b, j, old)
520 if j.Version == 0 {
521 guarded[j.Repo] = true
522 }
523 }
524 c.stageRepoTokens(ctx, b, w, guarded)
525 c.stageAnnotations(ctx, b, w, guarded)
526 // Checks are derived from the jobs, written in the same batch and under
527 // the lock of their run: they are upserted without a precondition.
528 for _, ck := range w.Checks {
529 b.Put(checkKey(ck.Repo, ck.Sha, ck.ID), encodeCheck(ck))
530 }
531 return guarded, nil
532}
533
534// Apply writes the records in one batch.
535func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
536 var b kv.Batch
537 guarded, err := c.stageRecords(ctx, &b, w)
538 if err != nil {
539 return 0, err
540 }
541 for repo := range guarded {
542 if err := c.guardRepo(ctx, &b, repo); err != nil {
543 return 0, err
544 }
545 }
546 v, err := c.db.Apply(ctx, &b)
547 if errors.Is(err, kv.ErrConflict) {
548 return 0, domain.ErrConflict
549 }
550 if err != nil {
551 return 0, fmt.Errorf("write CI records: %w", err)
552 }
553 return v, nil
554}
555
556func (c *CI) readSeq(ctx context.Context, key []byte) (last int64, item kv.Item, ok bool, err error) {
557 item, ok = c.db.Get(ctx, key)
558 if !ok {
559 return 0, kv.Item{}, false, nil
560 }
561 var seq storagepb.Sequence
562 if err := proto.Unmarshal(item.Value, &seq); err != nil {
563 return 0, kv.Item{}, false, fmt.Errorf("decode sequence %s: %w", key, err)
564 }
565 return seq.GetLast(), item, true, nil
566}
567
568func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) {
569 if ok {
570 b.RequireVersion(key, item.Version)
571 } else {
572 b.RequireAbsent(key)
573 }
574 b.Put(key, mustMarshal(&storagepb.Sequence{Last: last}))
575}
576
577const createRunTries = 20
578
579// CreateRun takes the next run id and number and stores the draft. The batch
580// requires the workflow's disabled key absent, so a disable that races the
581// caller's check fails the batch and is reported as domain.ErrWorkflowDisabled.
582//
583//hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides
584func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
585 offKey := workflowOffKey(repo, workflowPath)
586 for range createRunTries {
587 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
588 if err != nil {
589 return ports.CIRunDraft{}, err
590 }
591 numKey := runNumKey(repo, workflowPath)
592 lastNum, numItem, numOK, err := c.readSeq(ctx, numKey)
593 if err != nil {
594 return ports.CIRunDraft{}, err
595 }
596 draft := build(lastID+1, lastNum+1)
597 var b kv.Batch
598 stageSeq(&b, []byte(runSeqKey), idItem, idOK, lastID+1)
599 stageSeq(&b, numKey, numItem, numOK, lastNum+1)
600 w := ports.CIWrite{Runs: []domain.CIRun{draft.Run}, Jobs: draft.Jobs, Checks: draft.Checks}
601 guarded, err := c.stageRecords(ctx, &b, w)
602 if err != nil {
603 return ports.CIRunDraft{}, err
604 }
605 src := mustMarshal(&storagepb.CIRunSource{
606 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
607 PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID,
608 })
609 b.RequireAbsent(runSrcKey(draft.Run.ID))
610 b.Put(runSrcKey(draft.Run.ID), src)
611 b.RequireAbsent(offKey)
612 for r := range guarded {
613 if err := c.guardRepo(ctx, &b, r); err != nil {
614 return ports.CIRunDraft{}, err
615 }
616 }
617 v, err := c.db.Apply(ctx, &b)
618 if errors.Is(err, kv.ErrConflict) {
619 // Translate a failed disabled-workflow precondition; other conflicts retry.
620 if _, off := c.db.Get(ctx, offKey); off {
621 return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
622 }
623 continue
624 }
625 if err != nil {
626 return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, err)
627 }
628 draft.Run.Version = v
629 for i := range draft.Jobs {
630 draft.Jobs[i].Version = v
631 }
632 for i := range draft.Checks {
633 draft.Checks[i].Version = v
634 }
635 return draft, nil
636 }
637 return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, domain.ErrConflict)
638}
639
640// --- reads ---
641
642// GetRun reads run/<repo>/<id>.
643func (c *CI) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) {
644 item, ok := c.db.Get(ctx, runKey(repo, id))
645 if !ok || id < 1 {
646 return domain.CIRun{}, domain.ErrNotFound
647 }
648 return decodeRun(item)
649}
650
651// GetRunSource reads runsrc/<id>.
652func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) {
653 item, ok := c.db.Get(ctx, runSrcKey(runID))
654 if !ok || runID < 1 {
655 return domain.CIRunSource{}, domain.ErrNotFound
656 }
657 var pb storagepb.CIRunSource
658 if err := proto.Unmarshal(item.Value, &pb); err != nil {
659 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
660 }
661 repo, err := domain.ParseRepoName(pb.GetRepo())
662 if err != nil {
663 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
664 }
665 return domain.CIRunSource{
666 RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(),
667 Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
668 }, nil
669}
670
671// ListRuns returns the runs of the repository, newest first.
672func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
673 start := []byte(runsPrefix(repo))
674 end := []byte(runPrefix + repo.String() + "0") // '/' + 1
675 if before > 0 {
676 end = runKey(repo, before)
677 }
678 // The snapshot iterates in key order only: keep the last limit items in a
679 // ring and decode only those, so the cost of a page does not grow with the
680 // decoded history of the repository.
681 type entry struct {
682 key []byte
683 item kv.Item
684 }
685 var ring []entry
686 next := 0
687 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
688 if limit <= 0 || len(ring) < limit {
689 ring = append(ring, entry{key, item})
690 continue
691 }
692 ring[next] = entry{key, item}
693 next = (next + 1) % limit
694 }
695 ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
696 all := make([]domain.CIRun, 0, len(ordered))
697 for i := len(ordered) - 1; i >= 0; i-- {
698 r, err := decodeRun(ordered[i].item)
699 if err != nil {
700 return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
701 }
702 all = append(all, r)
703 }
704 return all, nil
705}
706
707// RunIDs returns the ids of the runs of the repository.
708func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
709 var ids []int64
710 prefix := runsPrefix(repo)
711 for key := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
712 id, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
713 if err != nil {
714 return nil, fmt.Errorf("decode %s: %w", key, err)
715 }
716 ids = append(ids, id)
717 }
718 return ids, nil
719}
720
721// ListOpenRuns returns the runs that have not completed.
722func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
723 snap := c.db.Snapshot(ctx)
724 var out []domain.CIRun
725 for key, item := range snap.Prefix(ctx, []byte(openPrefix)) {
726 id, err := strconv.ParseInt(strings.TrimPrefix(string(key), openPrefix), 10, 64)
727 if err != nil {
728 return nil, fmt.Errorf("decode %s: %w", key, err)
729 }
730 repo, err := domain.ParseRepoName(string(item.Value))
731 if err != nil {
732 return nil, err
733 }
734 it, ok := snap.Get(ctx, runKey(repo, id))
735 if !ok {
736 continue
737 }
738 r, err := decodeRun(it)
739 if err != nil {
740 return nil, err
741 }
742 out = append(out, r)
743 }
744 return out, nil
745}
746
747// GetJob reads one job record.
748func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
749 item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
750 if !ok {
751 return domain.CIJob{}, domain.ErrNotFound
752 }
753 return decodeJob(item)
754}
755
756// ListJobs returns the jobs of a run at an attempt, ordered by index then
757// combination.
758func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
759 var out []domain.CIJob
760 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
761 j, err := decodeJob(item)
762 if err != nil {
763 return nil, fmt.Errorf("decode %s: %w", key, err)
764 }
765 out = append(out, j)
766 }
767 slices.SortFunc(out, func(a, b domain.CIJob) int {
768 if a.Index != b.Index {
769 return a.Index - b.Index
770 }
771 return a.Combo - b.Combo
772 })
773 return out, nil
774}
775
776func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domain.CIJob, error) {
777 item, ok := snap.Get(ctx, []byte(ref))
778 if !ok {
779 return domain.CIJob{}, domain.ErrNotFound
780 }
781 return decodeJob(item)
782}
783
784// FindJobByToken resolves the token index, then reads the job.
785func (c *CI) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) {
786 snap := c.db.Snapshot(ctx)
787 idx, ok := snap.Get(ctx, jobTokenKey(hash))
788 if !ok {
789 return domain.CIJob{}, domain.ErrNotFound
790 }
791 j, err := c.jobByRef(snap, ctx, string(idx.Value))
792 if err != nil {
793 return domain.CIJob{}, err
794 }
795 if j.TokenHash != hash || (!j.TokenValidUntil.IsZero() && !now.Before(j.TokenValidUntil)) {
796 return domain.CIJob{}, domain.ErrNotFound
797 }
798 return j, nil
799}
800
801// ListReadyJobs returns the jobs waiting for a runner, oldest first.
802func (c *CI) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) {
803 snap := c.db.Snapshot(ctx)
804 var out []domain.CIJob
805 for key := range snap.Prefix(ctx, []byte(readyPrefix)) {
806 ref := string(key)[len(readyPrefix)+len(fmt.Sprintf(idFormat, 0))+1:]
807 j, err := c.jobByRef(snap, ctx, ref)
808 if errors.Is(err, domain.ErrNotFound) {
809 continue
810 }
811 if err != nil {
812 return nil, err
813 }
814 out = append(out, j)
815 if limit > 0 && len(out) >= limit {
816 break
817 }
818 }
819 return out, nil
820}
821
822// ListActiveJobs returns the jobs given to a runner and not over.
823func (c *CI) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
824 snap := c.db.Snapshot(ctx)
825 var out []domain.CIJob
826 for key := range snap.Prefix(ctx, []byte(activePrefix)) {
827 j, err := c.jobByRef(snap, ctx, strings.TrimPrefix(string(key), activePrefix))
828 if errors.Is(err, domain.ErrNotFound) {
829 continue
830 }
831 if err != nil {
832 return nil, err
833 }
834 out = append(out, j)
835 }
836 return out, nil
837}
838
839// ListChecks returns the checks of a commit ordered by creation time then
840// name.
841func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) {
842 var out []domain.CICheck
843 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(checksPrefix(repo, sha))) {
844 ck, err := decodeCheck(item)
845 if err != nil {
846 return nil, false, fmt.Errorf("decode %s: %w", key, err)
847 }
848 out = append(out, ck)
849 }
850 slices.SortStableFunc(out, func(a, b domain.CICheck) int {
851 if d := a.CreatedAt.Compare(b.CreatedAt); d != 0 {
852 return d
853 }
854 return strings.Compare(a.Name, b.Name)
855 })
856 more := limit > 0 && len(out) > limit
857 if more {
858 out = out[:limit]
859 }
860 return out, more, nil
861}
862
863// --- retention and deletion ---
864
865// runKeys lists every key of a run except its record: source, jobs, indexes.
866func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
867 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
868 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
869 keys = append(keys, annotationKeys(ctx, snap, runID)...)
870 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
871 keys = append(keys, slices.Clone(key))
872 if j, err := decodeJob(item); err == nil {
873 ref := jobRef(j.RunID, j.RunAttempt, j.ID)
874 keys = append(keys, readyKey(j.ReadyAt, ref), activeKey(ref))
875 if j.HasToken() {
876 keys = append(keys, jobTokenKey(j.TokenHash))
877 }
878 }
879 }
880 return keys
881}
882
883// ciKeys lists every key of the snapshot that belongs to the repository's CI
884// runs, checks, numbers and artifacts.
885func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
886 c := &CI{}
887 var keys [][]byte
888 for key, item := range snap.Prefix(ctx, []byte(runsPrefix(repo))) {
889 if r, err := decodeRun(item); err == nil {
890 keys = append(keys, c.runKeys(ctx, snap, repo, r.ID)...)
891 }
892 keys = append(keys, slices.Clone(key))
893 }
894 keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
895 keys = append(keys, artifactKeys(ctx, snap, repo)...)
896 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} {
897 for k := range snap.Prefix(ctx, []byte(p)) {
898 keys = append(keys, slices.Clone(k))
899 }
900 }
901 return keys
902}
903
904const sweepChunk = 500
905
906// Sweep removes the runs completed before the time.
907func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
908 snap := c.db.Snapshot(ctx)
909 var ids []int64
910 var keys [][]byte
911 for _, rp := range []string{runPrefix} {
912 for _, item := range snap.Prefix(ctx, []byte(rp)) {
913 r, err := decodeRun(item)
914 if err != nil {
915 return nil, err
916 }
917 if r.Status != domain.CICompleted || r.CompletedAt.IsZero() || !r.CompletedAt.Before(before) {
918 continue
919 }
920 ids = append(ids, r.ID)
921 keys = append(keys, c.runKeys(ctx, snap, r.Repo, r.ID)...)
922 for ck := range snap.Prefix(ctx, []byte(checkPrefix+r.Repo.String()+"/"+r.CheckSha()+"/")) {
923 // A check belongs to the run only while it is the latest one
924 // for its job: leave it when another run replaced it.
925 if it, ok := snap.Get(ctx, ck); ok {
926 if chk, err := decodeCheck(it); err == nil && chk.RunID == r.ID {
927 keys = append(keys, slices.Clone(ck))
928 }
929 }
930 }
931 }
932 }
933 for len(keys) > 0 {
934 n := min(sweepChunk, len(keys))
935 var b kv.Batch
936 for _, k := range keys[:n] {
937 b.Delete(k)
938 }
939 if _, err := c.db.Apply(ctx, &b); err != nil {
940 return nil, fmt.Errorf("sweep CI runs: %w", err)
941 }
942 keys = keys[n:]
943 }
944 return ids, nil
945}
946
947// --- runners ---
948
949func runnerKey(id string) []byte { return []byte(runnerPrefix + id) }
950func runnerNameKey(n string) []byte { return []byte(runnerName + strings.ToLower(n)) }
951func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
952
953// CreateRunner stores the runner, its name index and its token index.
954func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
955 for range createRunTries {
956 var b kv.Batch
957 var removed []domain.CIRunner
958 if idx, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
959 if !replace {
960 return nil, domain.ErrAlreadyExists
961 }
962 old, err := c.GetRunner(ctx, string(idx.Value))
963 if err != nil && !errors.Is(err, domain.ErrNotFound) {
964 return nil, err
965 }
966 b.RequireVersion(runnerNameKey(r.Name), idx.Version)
967 if err == nil {
968 removed = append(removed, old)
969 b.Delete(runnerKey(old.ID))
970 b.Delete(runnerTokenKey(old.TokenHash))
971 }
972 } else {
973 b.RequireAbsent(runnerNameKey(r.Name))
974 }
975 b.RequireAbsent(runnerKey(r.ID))
976 b.RequireAbsent(runnerTokenKey(r.TokenHash))
977 b.Put(runnerKey(r.ID), encodeRunner(r))
978 b.Put(runnerNameKey(r.Name), []byte(r.ID))
979 b.Put(runnerTokenKey(r.TokenHash), []byte(r.ID))
980 _, err := c.db.Apply(ctx, &b)
981 if errors.Is(err, kv.ErrConflict) {
982 if !replace {
983 if _, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
984 return nil, domain.ErrAlreadyExists
985 }
986 }
987 continue
988 }
989 if err != nil {
990 return nil, fmt.Errorf("create runner %q: %w", r.Name, err)
991 }
992 return removed, nil
993 }
994 return nil, fmt.Errorf("create runner %q: %w", r.Name, domain.ErrConflict)
995}
996
997// GetRunner reads runner/<id>.
998func (c *CI) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) {
999 item, ok := c.db.Get(ctx, runnerKey(id))
1000 if !ok || id == "" || strings.Contains(id, "/") {
1001 return domain.CIRunner{}, domain.ErrNotFound
1002 }
1003 return decodeRunner(item.Value)
1004}
1005
1006// FindRunnerByToken resolves the token index, then reads the runner.
1007func (c *CI) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) {
1008 snap := c.db.Snapshot(ctx)
1009 idx, ok := snap.Get(ctx, runnerTokenKey(hash))
1010 if !ok {
1011 return domain.CIRunner{}, domain.ErrNotFound
1012 }
1013 item, ok := snap.Get(ctx, runnerKey(string(idx.Value)))
1014 if !ok {
1015 return domain.CIRunner{}, domain.ErrNotFound
1016 }
1017 r, err := decodeRunner(item.Value)
1018 if err != nil {
1019 return domain.CIRunner{}, err
1020 }
1021 if r.TokenHash != hash {
1022 return domain.CIRunner{}, fmt.Errorf("runner %q: token index does not match the stored hash", r.ID)
1023 }
1024 return r, nil
1025}
1026
1027// ListRunners returns every runner ordered by name.
1028func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) {
1029 var out []domain.CIRunner
1030 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(runnerPrefix)) {
1031 r, err := decodeRunner(item.Value)
1032 if err != nil {
1033 return nil, fmt.Errorf("decode %s: %w", key, err)
1034 }
1035 out = append(out, r)
1036 }
1037 slices.SortFunc(out, func(a, b domain.CIRunner) int {
1038 return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
1039 })
1040 return out, nil
1041}
1042
1043// SetRunnerVersion records the version a runner runs now; an unchanged version
1044// is not written.
1045func (c *CI) SetRunnerVersion(ctx context.Context, id, version string) error {
1046 for range createRunTries {
1047 item, ok := c.db.Get(ctx, runnerKey(id))
1048 if !ok || id == "" || strings.Contains(id, "/") {
1049 return domain.ErrNotFound
1050 }
1051 r, err := decodeRunner(item.Value)
1052 if err != nil {
1053 return err
1054 }
1055 if r.Version == version {
1056 return nil
1057 }
1058 r.Version = version
1059 var b kv.Batch
1060 b.RequireVersion(runnerKey(id), item.Version)
1061 b.Put(runnerKey(id), encodeRunner(r))
1062 _, err = c.db.Apply(ctx, &b)
1063 if errors.Is(err, kv.ErrConflict) {
1064 continue
1065 }
1066 if err != nil {
1067 return fmt.Errorf("set runner %q version: %w", id, err)
1068 }
1069 return nil
1070 }
1071 return fmt.Errorf("set runner %q version: %w", id, domain.ErrConflict)
1072}
1073
1074// RemoveRunner removes the runner, its name and its token.
1075func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) {
1076 for range createRunTries {
1077 item, ok := c.db.Get(ctx, runnerKey(id))
1078 if !ok || id == "" || strings.Contains(id, "/") {
1079 return domain.CIRunner{}, domain.ErrNotFound
1080 }
1081 r, err := decodeRunner(item.Value)
1082 if err != nil {
1083 return domain.CIRunner{}, err
1084 }
1085 var b kv.Batch
1086 b.RequireVersion(runnerKey(id), item.Version)
1087 b.Delete(runnerKey(id))
1088 b.Delete(runnerNameKey(r.Name))
1089 b.Delete(runnerTokenKey(r.TokenHash))
1090 _, err = c.db.Apply(ctx, &b)
1091 if errors.Is(err, kv.ErrConflict) {
1092 continue
1093 }
1094 if err != nil {
1095 return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, err)
1096 }
1097 return r, nil
1098 }
1099 return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict)
1100}
1101
1102func encodeCall(c *domain.CICall) *storagepb.CICall {
1103 if c == nil {
1104 return nil
1105 }
1106 return &storagepb.CICall{
1107 Uses: c.Uses, Path: c.Path, Repository: c.Repository, Ref: c.Ref, Sha: c.Sha, Yaml: c.YAML, InputsJson: c.InputsJSON,
1108 Secrets: c.Secrets, Inherit: c.Inherit, Depth: int32(c.Depth), Expanded: c.Expanded, Concurrency: encodeConcurrency(c.Concurrency), Admitted: c.Admitted, SecretCtx: c.SecretCtx, //nolint:gosec // at most 4
1109 }
1110}
1111
1112func decodeCall(pb *storagepb.CICall) *domain.CICall {
1113 if pb == nil {
1114 return nil
1115 }
1116 return &domain.CICall{
1117 Uses: pb.GetUses(), Path: pb.GetPath(), Repository: pb.GetRepository(), Ref: pb.GetRef(), Sha: pb.GetSha(), YAML: pb.GetYaml(),
1118 InputsJSON: pb.GetInputsJson(), Secrets: pb.GetSecrets(), Inherit: pb.GetInherit(), Depth: int(pb.GetDepth()), Expanded: pb.GetExpanded(), Concurrency: decodeConcurrency(pb.GetConcurrency()),
1119 Admitted: pb.GetAdmitted(), SecretCtx: pb.GetSecretCtx(),
1120 }
1121}