Merge pull request 'feat(ci): GitHub Actions-compatible runs, runners and checks' (#15) from feat/ci-server into main
131 files · +20260 -454
M README.md +15 -2
| @@ -27,6 +27,8 @@ ermite serve | |||
| 27 | 27 | | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` | | |
| 28 | 28 | | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges | | |
| 29 | 29 | | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges | | |
| 30 | + | | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names | | |
| 31 | + | | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour | | |
| 30 | 32 | ||
| 31 | 33 | `ermite version` prints the version (and the server's when `ERMITE_URL` is set). | |
| 32 | 34 | ||
| @@ -37,11 +39,14 @@ The same binary drives a running server over its API. It reads `ERMITE_URL` (the | |||
| 37 | 39 | ```sh | |
| 38 | 40 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | |
| 39 | 41 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | |
| 40 | - | ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once | |
| 42 | + | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once | |
| 41 | 43 | ermite token list | revoke <id> | |
| 42 | 44 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | |
| 43 | 45 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | |
| 44 | 46 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | |
| 47 | + | ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]] | |
| 48 | + | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | |
| 49 | + | ermite runner list | remove <id> | |
| 45 | 50 | ermite web login # prints a one-time sign-in link (60 s) | |
| 46 | 51 | ermite web logout --all # signs every browser out | |
| 47 | 52 | ermite signing-key show # public key and allowed_signers line of squash commits | |
| @@ -51,7 +56,7 @@ ermite watch [repo] [--json] # follows events, reconnects and resumes | |||
| 51 | 56 | ||
| 52 | 57 | An agent skill describing the CLI and the forge lives in [`skills/ermite`](skills/ermite/SKILL.md); link or copy that folder into your agent's skills directory. | |
| 53 | 58 | ||
| 54 | - | `--json` on `repo list|show|create|edit`, `token create|list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server. | |
| 59 | + | `--json` on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server. | |
| 55 | 60 | ||
| 56 | 61 | ## Git over HTTP | |
| 57 | 62 | ||
| @@ -66,6 +71,14 @@ Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; th | |||
| 66 | 71 | ||
| 67 | 72 | A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch). | |
| 68 | 73 | ||
| 74 | + | ## CI | |
| 75 | + | ||
| 76 | + | ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check. | |
| 77 | + | ||
| 78 | + | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | |
| 79 | + | ||
| 80 | + | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature the runner does not support yet (`uses:` steps, `container:`, `services:`, `environment:`, `concurrency`, `continue-on-error`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | |
| 81 | + | ||
| 69 | 82 | ## Web sign-in | |
| 70 | 83 | ||
| 71 | 84 | `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs `https` in `ERMITE_PUBLIC_URL` (except on localhost). Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. `ermite web logout --all`, or revoking a token with every scope, ends every session. | |
A gen/ermite/storage/ci.pb.go +1603 -0 generated file: collapsed
M go.mod +2 -1
| @@ -12,7 +12,8 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | - | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 | |
| 15 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 16 | + | bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7 | |
| 16 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 17 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 18 | 19 | connectrpc.com/connect v1.21.0 | |
M go.sum +4 -2
| @@ -1,5 +1,7 @@ | |||
| 1 | - | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 h1:Udj6Rkvt7tDymoIgmbKA30vao5Nv6VJaa78BBrbypxo= | |
| 2 | - | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 1 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 h1:3TBeF1oKAan41P2AiU1KLGYc4ZTG5KweCe5klMMIkxk= | |
| 2 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | + | bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7 h1:siActAyQQ3bKqA2dJFV7ZASsmV4LOvGAsIo9z0DmDyg= | |
| 4 | + | bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 3 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| 4 | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | |
| 5 | 7 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw= | |
A internal/adapters/cilogs/cilogs.go +371 -0
| @@ -0,0 +1,371 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package cilogs implements ports.CILogStore with append-only files under | |
| 4 | + | // $ERMITE_DATA_DIR/ci/logs/<run id>/<job id>/<attempt>.log. | |
| 5 | + | // | |
| 6 | + | // A file holds one line per log line, in GitHub's raw log shape: an RFC 3339 | |
| 7 | + | // timestamp with nanoseconds, a space, then the text, behind a one-character | |
| 8 | + | // marker: "-" for a line, "+" for a piece that continues the line before it | |
| 9 | + | // (it keeps its own line number): | |
| 10 | + | // | |
| 11 | + | // - 2026-10-12T10:00:00.123456789Z text | |
| 12 | + | // - 2026-10-12T10:00:00.123456789Z more text of the same line | |
| 13 | + | // | |
| 14 | + | // The file is the truth for bytes and line counts: the metadata store is never | |
| 15 | + | // written per chunk. Counts are cached per open log and rebuilt from the file | |
| 16 | + | // after a restart. | |
| 17 | + | package cilogs | |
| 18 | + | ||
| 19 | + | import ( | |
| 20 | + | "bufio" | |
| 21 | + | "bytes" | |
| 22 | + | "context" | |
| 23 | + | "errors" | |
| 24 | + | "fmt" | |
| 25 | + | "io" | |
| 26 | + | "io/fs" | |
| 27 | + | "os" | |
| 28 | + | "path/filepath" | |
| 29 | + | "strconv" | |
| 30 | + | "sync" | |
| 31 | + | "time" | |
| 32 | + | ||
| 33 | + | "bnema.dev/ermite/internal/domain" | |
| 34 | + | "bnema.dev/ermite/internal/ports" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // Dir is the directory of the logs under the data directory. | |
| 38 | + | func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "logs") } | |
| 39 | + | ||
| 40 | + | // timeLayout is RFC 3339 with nanoseconds, always in UTC and with all nine | |
| 41 | + | // digits, so that every prefix has the same length. | |
| 42 | + | const timeLayout = "2006-01-02T15:04:05.000000000Z" | |
| 43 | + | ||
| 44 | + | // prefixLen is the length of "- " + timestamp + " ". | |
| 45 | + | const prefixLen = 2 + len(timeLayout) + 1 | |
| 46 | + | ||
| 47 | + | // Store is the file-backed log store. | |
| 48 | + | type Store struct { | |
| 49 | + | root string | |
| 50 | + | ||
| 51 | + | mu sync.Mutex | |
| 52 | + | logs map[ports.CILogRef]*logState | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | var _ ports.CILogStore = (*Store)(nil) | |
| 56 | + | ||
| 57 | + | // logState is what is known of one log file. | |
| 58 | + | type logState struct { | |
| 59 | + | mu sync.Mutex | |
| 60 | + | loaded bool | |
| 61 | + | count int64 | |
| 62 | + | size int64 | |
| 63 | + | offsets []int64 // byte offset of every 1024th line: index i is line i*1024 | |
| 64 | + | changed chan struct{} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | const offsetEvery = 1024 | |
| 68 | + | ||
| 69 | + | // New returns a store rooted at dir, which need not exist yet. | |
| 70 | + | func New(dir string) *Store { | |
| 71 | + | return &Store{root: dir, logs: make(map[ports.CILogRef]*logState)} | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (s *Store) path(ref ports.CILogRef) string { | |
| 75 | + | return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt))+".log") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (s *Store) state(ref ports.CILogRef) *logState { | |
| 79 | + | s.mu.Lock() | |
| 80 | + | defer s.mu.Unlock() | |
| 81 | + | st, ok := s.logs[ref] | |
| 82 | + | if !ok { | |
| 83 | + | st = &logState{changed: make(chan struct{})} | |
| 84 | + | s.logs[ref] = st | |
| 85 | + | } | |
| 86 | + | return st | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // validRef rejects a reference that could escape the directory. | |
| 90 | + | func validRef(ref ports.CILogRef) bool { | |
| 91 | + | if ref.RunID < 1 || ref.Attempt < 1 { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | _, _, _, ok := domain.ParseJobID(ref.JobID) | |
| 95 | + | return ok | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // load reads the file to learn its line count and size, once. The caller | |
| 99 | + | // holds st.mu. | |
| 100 | + | func (s *Store) load(ref ports.CILogRef, st *logState) error { | |
| 101 | + | if st.loaded { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | f, err := os.Open(s.path(ref)) | |
| 105 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 106 | + | st.loaded = true | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | if err != nil { | |
| 110 | + | return fmt.Errorf("open log: %w", err) | |
| 111 | + | } | |
| 112 | + | defer func() { _ = f.Close() }() | |
| 113 | + | r := bufio.NewReaderSize(f, 1<<16) | |
| 114 | + | var count, pos int64 | |
| 115 | + | for { | |
| 116 | + | line, err := r.ReadBytes('\n') | |
| 117 | + | if len(line) > 0 && line[len(line)-1] == '\n' { | |
| 118 | + | if count%offsetEvery == 0 { | |
| 119 | + | st.offsets = append(st.offsets, pos) | |
| 120 | + | } | |
| 121 | + | count++ | |
| 122 | + | pos += int64(len(line)) | |
| 123 | + | } else if len(line) > 0 { | |
| 124 | + | // A torn last line from a crash: cut it, it was never acknowledged. | |
| 125 | + | if terr := os.Truncate(s.path(ref), pos); terr != nil { | |
| 126 | + | return fmt.Errorf("repair log: %w", terr) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if err != nil { | |
| 130 | + | if errors.Is(err, io.EOF) { | |
| 131 | + | break | |
| 132 | + | } | |
| 133 | + | return fmt.Errorf("read log: %w", err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | st.count, st.size, st.loaded = count, pos, true | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func encodeLine(l domain.CILogLine) []byte { | |
| 141 | + | b := make([]byte, 0, prefixLen+len(l.Text)+1) | |
| 142 | + | if l.Continued { | |
| 143 | + | b = append(b, '+', ' ') | |
| 144 | + | } else { | |
| 145 | + | b = append(b, '-', ' ') | |
| 146 | + | } | |
| 147 | + | b = l.Time.UTC().AppendFormat(b, timeLayout) | |
| 148 | + | b = append(b, ' ') | |
| 149 | + | b = append(b, l.Text...) | |
| 150 | + | return append(b, '\n') | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func decodeLine(b []byte) (domain.CILogLine, error) { | |
| 154 | + | b = bytes.TrimSuffix(b, []byte{'\n'}) | |
| 155 | + | if len(b) < prefixLen || (b[0] != '-' && b[0] != '+') { | |
| 156 | + | return domain.CILogLine{}, errors.New("malformed log line") | |
| 157 | + | } | |
| 158 | + | t, err := time.Parse(timeLayout, string(b[2:2+len(timeLayout)])) | |
| 159 | + | if err != nil { | |
| 160 | + | return domain.CILogLine{}, fmt.Errorf("malformed log time: %w", err) | |
| 161 | + | } | |
| 162 | + | return domain.CILogLine{Time: t, Text: string(b[prefixLen:]), Continued: b[0] == '+'}, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Append appends lines at offset. | |
| 166 | + | func (s *Store) Append(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error) { | |
| 167 | + | return s.append(ctx, ref, offset, lines, limit, false) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // AppendForced appends lines whatever the limit. | |
| 171 | + | func (s *Store) AppendForced(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error) { | |
| 172 | + | next, _, err := s.append(ctx, ref, -1, lines, 0, true) | |
| 173 | + | return next, err | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (s *Store) append(_ context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64, force bool) (int64, int, error) { | |
| 177 | + | if !validRef(ref) { | |
| 178 | + | return 0, 0, domain.ErrNotFound | |
| 179 | + | } | |
| 180 | + | st := s.state(ref) | |
| 181 | + | st.mu.Lock() | |
| 182 | + | defer st.mu.Unlock() | |
| 183 | + | if err := s.load(ref, st); err != nil { | |
| 184 | + | return 0, 0, err | |
| 185 | + | } | |
| 186 | + | if offset < 0 { | |
| 187 | + | offset = st.count | |
| 188 | + | } | |
| 189 | + | if offset > st.count { | |
| 190 | + | return 0, 0, &ports.ErrCILogGap{Count: st.count} | |
| 191 | + | } | |
| 192 | + | skip := st.count - offset | |
| 193 | + | if skip >= int64(len(lines)) { | |
| 194 | + | return st.count, 0, nil | |
| 195 | + | } | |
| 196 | + | fresh := lines[skip:] | |
| 197 | + | var buf bytes.Buffer | |
| 198 | + | for _, l := range fresh { | |
| 199 | + | buf.Write(encodeLine(l)) | |
| 200 | + | } | |
| 201 | + | if !force && st.size+int64(buf.Len()) > limit { | |
| 202 | + | return st.count, 0, ports.ErrCILogLimit | |
| 203 | + | } | |
| 204 | + | if err := os.MkdirAll(filepath.Dir(s.path(ref)), 0o700); err != nil { | |
| 205 | + | return 0, 0, fmt.Errorf("create log directory: %w", err) | |
| 206 | + | } | |
| 207 | + | f, err := os.OpenFile(s.path(ref), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600) | |
| 208 | + | if err != nil { | |
| 209 | + | return 0, 0, fmt.Errorf("open log: %w", err) | |
| 210 | + | } | |
| 211 | + | n, werr := f.Write(buf.Bytes()) | |
| 212 | + | cerr := f.Close() | |
| 213 | + | if werr != nil || cerr != nil { | |
| 214 | + | // Cut what was written so that the file keeps whole lines. | |
| 215 | + | _ = os.Truncate(s.path(ref), st.size) | |
| 216 | + | return 0, 0, fmt.Errorf("write log: %w", errors.Join(werr, cerr)) | |
| 217 | + | } | |
| 218 | + | pos := st.size | |
| 219 | + | for i, l := range fresh { | |
| 220 | + | if (st.count+int64(i))%offsetEvery == 0 { | |
| 221 | + | st.offsets = append(st.offsets, pos) | |
| 222 | + | } | |
| 223 | + | pos += int64(len(encodeLine(l))) | |
| 224 | + | } | |
| 225 | + | st.count += int64(len(fresh)) | |
| 226 | + | st.size += int64(n) | |
| 227 | + | close(st.changed) | |
| 228 | + | st.changed = make(chan struct{}) | |
| 229 | + | return st.count, len(fresh), nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Count returns the number of lines held. | |
| 233 | + | func (s *Store) Count(_ context.Context, ref ports.CILogRef) (int64, error) { | |
| 234 | + | if !validRef(ref) { | |
| 235 | + | return 0, nil | |
| 236 | + | } | |
| 237 | + | st := s.state(ref) | |
| 238 | + | st.mu.Lock() | |
| 239 | + | defer st.mu.Unlock() | |
| 240 | + | if err := s.load(ref, st); err != nil { | |
| 241 | + | return 0, err | |
| 242 | + | } | |
| 243 | + | return st.count, nil | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Read returns lines from line number from. | |
| 247 | + | func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) { | |
| 248 | + | if !validRef(ref) || from < 0 { | |
| 249 | + | return nil, domain.ErrNotFound | |
| 250 | + | } | |
| 251 | + | st := s.state(ref) | |
| 252 | + | st.mu.Lock() | |
| 253 | + | if err := s.load(ref, st); err != nil { | |
| 254 | + | st.mu.Unlock() | |
| 255 | + | return nil, err | |
| 256 | + | } | |
| 257 | + | count, size := st.count, st.size | |
| 258 | + | var start int64 | |
| 259 | + | var first int64 | |
| 260 | + | if idx := from / offsetEvery; idx < int64(len(st.offsets)) { | |
| 261 | + | start, first = st.offsets[idx], idx*offsetEvery | |
| 262 | + | } | |
| 263 | + | st.mu.Unlock() | |
| 264 | + | if from > count { | |
| 265 | + | return nil, domain.ErrOutOfRange | |
| 266 | + | } | |
| 267 | + | if _, err := os.Stat(s.path(ref)); errors.Is(err, fs.ErrNotExist) { | |
| 268 | + | if from == 0 && count == 0 { | |
| 269 | + | return nil, domain.ErrNotFound | |
| 270 | + | } | |
| 271 | + | return nil, domain.ErrNotFound | |
| 272 | + | } | |
| 273 | + | if from == count { | |
| 274 | + | return nil, nil | |
| 275 | + | } | |
| 276 | + | f, err := os.Open(s.path(ref)) | |
| 277 | + | if err != nil { | |
| 278 | + | return nil, fmt.Errorf("open log: %w", err) | |
| 279 | + | } | |
| 280 | + | defer func() { _ = f.Close() }() | |
| 281 | + | // Never read past the size seen under the lock: a concurrent append is | |
| 282 | + | // for the next call. | |
| 283 | + | r := bufio.NewReaderSize(io.NewSectionReader(f, start, size-start), 1<<16) | |
| 284 | + | var out []domain.CILogLine | |
| 285 | + | total := 0 | |
| 286 | + | for n := first; n < count; n++ { | |
| 287 | + | raw, err := r.ReadBytes('\n') | |
| 288 | + | if err != nil { | |
| 289 | + | return nil, fmt.Errorf("read log: %w", err) | |
| 290 | + | } | |
| 291 | + | if n < from { | |
| 292 | + | continue | |
| 293 | + | } | |
| 294 | + | l, err := decodeLine(raw) | |
| 295 | + | if err != nil { | |
| 296 | + | return nil, fmt.Errorf("log line %d: %w", n, err) | |
| 297 | + | } | |
| 298 | + | if len(out) > 0 && (len(out) >= maxLines || total+len(l.Text) > maxBytes) { | |
| 299 | + | break | |
| 300 | + | } | |
| 301 | + | total += len(l.Text) | |
| 302 | + | out = append(out, l) | |
| 303 | + | } | |
| 304 | + | return out, nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Changed returns a channel closed at the next append. | |
| 308 | + | func (s *Store) Changed(ref ports.CILogRef) <-chan struct{} { | |
| 309 | + | st := s.state(ref) | |
| 310 | + | st.mu.Lock() | |
| 311 | + | defer st.mu.Unlock() | |
| 312 | + | return st.changed | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // Reset empties the log. | |
| 316 | + | func (s *Store) Reset(_ context.Context, ref ports.CILogRef) error { | |
| 317 | + | if !validRef(ref) { | |
| 318 | + | return nil | |
| 319 | + | } | |
| 320 | + | st := s.state(ref) | |
| 321 | + | st.mu.Lock() | |
| 322 | + | defer st.mu.Unlock() | |
| 323 | + | if err := os.Remove(s.path(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) { | |
| 324 | + | return fmt.Errorf("remove log: %w", err) | |
| 325 | + | } | |
| 326 | + | st.loaded, st.count, st.size, st.offsets = true, 0, 0, nil | |
| 327 | + | close(st.changed) | |
| 328 | + | st.changed = make(chan struct{}) | |
| 329 | + | return nil | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // RemoveRuns deletes the logs of the runs. | |
| 333 | + | func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error { | |
| 334 | + | var errs []error | |
| 335 | + | for _, id := range runIDs { | |
| 336 | + | if id < 1 { | |
| 337 | + | continue | |
| 338 | + | } | |
| 339 | + | if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil { | |
| 340 | + | errs = append(errs, err) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | s.mu.Lock() | |
| 344 | + | for ref := range s.logs { | |
| 345 | + | for _, id := range runIDs { | |
| 346 | + | if ref.RunID == id { | |
| 347 | + | delete(s.logs, ref) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | s.mu.Unlock() | |
| 352 | + | return errors.Join(errs...) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // RunIDs lists the runs that have a log directory. | |
| 356 | + | func (s *Store) RunIDs(_ context.Context) ([]int64, error) { | |
| 357 | + | entries, err := os.ReadDir(s.root) | |
| 358 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 359 | + | return nil, nil | |
| 360 | + | } | |
| 361 | + | if err != nil { | |
| 362 | + | return nil, fmt.Errorf("list logs: %w", err) | |
| 363 | + | } | |
| 364 | + | var ids []int64 | |
| 365 | + | for _, e := range entries { | |
| 366 | + | if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() { | |
| 367 | + | ids = append(ids, id) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | return ids, nil | |
| 371 | + | } | |
A internal/adapters/cilogs/cilogs_test.go +238 -0
| @@ -0,0 +1,238 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package cilogs_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/adapters/cilogs" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | var ( | |
| 22 | + | ref0 = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1} | |
| 23 | + | t0 = time.Date(2026, 10, 12, 10, 0, 0, 123456789, time.UTC) | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | func lines(texts ...string) []domain.CILogLine { | |
| 27 | + | out := make([]domain.CILogLine, len(texts)) | |
| 28 | + | for i, s := range texts { | |
| 29 | + | out[i] = domain.CILogLine{Time: t0.Add(time.Duration(i) * time.Millisecond), Text: s} | |
| 30 | + | } | |
| 31 | + | return out | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const big = 1 << 30 | |
| 35 | + | ||
| 36 | + | func TestAppendAndReadKeepTextTimeAndContinuation(t *testing.T) { | |
| 37 | + | ctx := context.Background() | |
| 38 | + | s := cilogs.New(t.TempDir()) | |
| 39 | + | in := lines("##[group]Run echo hi", "hi", "##[endgroup]") | |
| 40 | + | in = append(in, domain.CILogLine{Time: t0, Text: "second half", Continued: true}) | |
| 41 | + | next, added, err := s.Append(ctx, ref0, 0, in, big) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, int64(4), next) | |
| 44 | + | assert.Equal(t, 4, added) | |
| 45 | + | ||
| 46 | + | got, err := s.Read(ctx, ref0, 0, 100, 1<<20) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Len(t, got, 4) | |
| 49 | + | assert.Equal(t, "##[group]Run echo hi", got[0].Text) | |
| 50 | + | assert.True(t, got[0].Time.Equal(t0), "nanosecond precision is kept: %v", got[0].Time) | |
| 51 | + | assert.True(t, got[3].Continued) | |
| 52 | + | assert.False(t, got[2].Continued) | |
| 53 | + | ||
| 54 | + | tail, err := s.Read(ctx, ref0, 2, 100, 1<<20) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | assert.Equal(t, "##[endgroup]", tail[0].Text) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestAppendIsIdempotentByOffset(t *testing.T) { | |
| 60 | + | ctx := context.Background() | |
| 61 | + | s := cilogs.New(t.TempDir()) | |
| 62 | + | _, _, err := s.Append(ctx, ref0, 0, lines("a", "b", "c"), big) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | ||
| 65 | + | // A retry of the same request changes nothing. | |
| 66 | + | next, added, err := s.Append(ctx, ref0, 0, lines("a", "b", "c"), big) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Equal(t, int64(3), next) | |
| 69 | + | assert.Zero(t, added) | |
| 70 | + | ||
| 71 | + | // A request that overlaps appends only its new tail. | |
| 72 | + | next, added, err = s.Append(ctx, ref0, 1, lines("b", "c", "d"), big) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | assert.Equal(t, int64(4), next) | |
| 75 | + | assert.Equal(t, 1, added) | |
| 76 | + | ||
| 77 | + | // A retry whose lines differ is silently dropped, as the contract says. | |
| 78 | + | next, added, err = s.Append(ctx, ref0, 0, lines("X", "Y"), big) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, int64(4), next) | |
| 81 | + | assert.Zero(t, added) | |
| 82 | + | got, _ := s.Read(ctx, ref0, 0, 10, 1<<20) | |
| 83 | + | assert.Equal(t, "a", got[0].Text) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestAppendAboveTheCountIsAGap(t *testing.T) { | |
| 87 | + | ctx := context.Background() | |
| 88 | + | s := cilogs.New(t.TempDir()) | |
| 89 | + | _, _, err := s.Append(ctx, ref0, 0, lines("a"), big) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | _, _, err = s.Append(ctx, ref0, 5, lines("z"), big) | |
| 92 | + | require.ErrorIs(t, err, domain.ErrOutOfRange) | |
| 93 | + | var gap *ports.ErrCILogGap | |
| 94 | + | require.ErrorAs(t, err, &gap) | |
| 95 | + | assert.Equal(t, int64(1), gap.Count) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestTheLimitRefusesTheWholeRequestUnlessForced(t *testing.T) { | |
| 99 | + | ctx := context.Background() | |
| 100 | + | s := cilogs.New(t.TempDir()) | |
| 101 | + | _, _, err := s.Append(ctx, ref0, 0, lines("aaaa"), 200) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | _, _, err = s.Append(ctx, ref0, 1, lines(strings.Repeat("b", 300)), 200) | |
| 104 | + | require.ErrorIs(t, err, ports.ErrCILogLimit) | |
| 105 | + | n, _ := s.Count(ctx, ref0) | |
| 106 | + | assert.Equal(t, int64(1), n, "nothing of the refused request was appended") | |
| 107 | + | ||
| 108 | + | next, err := s.AppendForced(ctx, ref0, lines("limit reached")) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | assert.Equal(t, int64(2), next) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestReadBoundsLinesAndBytesAndReportsMissingLogs(t *testing.T) { | |
| 114 | + | ctx := context.Background() | |
| 115 | + | s := cilogs.New(t.TempDir()) | |
| 116 | + | _, err := s.Read(ctx, ref0, 0, 10, 100) | |
| 117 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 118 | + | ||
| 119 | + | _, _, err = s.Append(ctx, ref0, 0, lines("12345", "12345", "12345", "12345"), big) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | got, err := s.Read(ctx, ref0, 0, 3, 1<<20) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | assert.Len(t, got, 3) | |
| 124 | + | got, err = s.Read(ctx, ref0, 0, 100, 12) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | assert.Len(t, got, 2, "the text of a batch is bounded") | |
| 127 | + | got, err = s.Read(ctx, ref0, 4, 10, 100) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.Empty(t, got, "reading at the end is empty, not an error") | |
| 130 | + | _, err = s.Read(ctx, ref0, 5, 10, 100) | |
| 131 | + | assert.ErrorIs(t, err, domain.ErrOutOfRange) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestReadFindsLinesDeepInALargeLog(t *testing.T) { | |
| 135 | + | ctx := context.Background() | |
| 136 | + | s := cilogs.New(t.TempDir()) | |
| 137 | + | var all []domain.CILogLine | |
| 138 | + | for i := range 3000 { | |
| 139 | + | all = append(all, domain.CILogLine{Time: t0, Text: "line " + itoa(i)}) | |
| 140 | + | } | |
| 141 | + | for off := 0; off < len(all); off += 1000 { | |
| 142 | + | _, _, err := s.Append(ctx, ref0, int64(off), all[off:off+1000], big) | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | } | |
| 145 | + | got, err := s.Read(ctx, ref0, 2500, 2, 1<<20) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | require.Len(t, got, 2) | |
| 148 | + | assert.Equal(t, "line 2500", got[0].Text) | |
| 149 | + | assert.Equal(t, "line 2501", got[1].Text) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func itoa(n int) string { | |
| 153 | + | if n == 0 { | |
| 154 | + | return "0" | |
| 155 | + | } | |
| 156 | + | var b []byte | |
| 157 | + | for n > 0 { | |
| 158 | + | b = append([]byte{byte('0' + n%10)}, b...) | |
| 159 | + | n /= 10 | |
| 160 | + | } | |
| 161 | + | return string(b) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestCountSurvivesARestartAndAChangedChannelFiresOnAppend(t *testing.T) { | |
| 165 | + | ctx := context.Background() | |
| 166 | + | dir := t.TempDir() | |
| 167 | + | s := cilogs.New(dir) | |
| 168 | + | ch := s.Changed(ref0) | |
| 169 | + | _, _, err := s.Append(ctx, ref0, 0, lines("a", "b"), big) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | select { | |
| 172 | + | case <-ch: | |
| 173 | + | default: | |
| 174 | + | t.Fatal("Changed did not fire on append") | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | s2 := cilogs.New(dir) | |
| 178 | + | n, err := s2.Count(ctx, ref0) | |
| 179 | + | require.NoError(t, err) | |
| 180 | + | assert.Equal(t, int64(2), n) | |
| 181 | + | _, _, err = s2.Append(ctx, ref0, 2, lines("c"), big) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | got, _ := s2.Read(ctx, ref0, 0, 10, 1<<20) | |
| 184 | + | assert.Len(t, got, 3) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestATornLastLineIsCutOnLoad(t *testing.T) { | |
| 188 | + | ctx := context.Background() | |
| 189 | + | dir := t.TempDir() | |
| 190 | + | s := cilogs.New(dir) | |
| 191 | + | _, _, err := s.Append(ctx, ref0, 0, lines("a", "b"), big) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | path := filepath.Join(dir, "7", "7-0-0", "1.log") | |
| 194 | + | f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | _, err = f.WriteString("- 2026-10-12T10:00:00.1234") | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | require.NoError(t, f.Close()) | |
| 199 | + | ||
| 200 | + | s2 := cilogs.New(dir) | |
| 201 | + | n, err := s2.Count(ctx, ref0) | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | assert.Equal(t, int64(2), n) | |
| 204 | + | _, _, err = s2.Append(ctx, ref0, 2, lines("c"), big) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | got, err := s2.Read(ctx, ref0, 0, 10, 1<<20) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | assert.Len(t, got, 3) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestRemoveRunsAndResetAndRunIDs(t *testing.T) { | |
| 212 | + | ctx := context.Background() | |
| 213 | + | s := cilogs.New(t.TempDir()) | |
| 214 | + | other := ports.CILogRef{RunID: 9, JobID: "9-0-0", Attempt: 1} | |
| 215 | + | _, _, _ = s.Append(ctx, ref0, 0, lines("a"), big) | |
| 216 | + | _, _, _ = s.Append(ctx, other, 0, lines("b"), big) | |
| 217 | + | ids, err := s.RunIDs(ctx) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | assert.ElementsMatch(t, []int64{7, 9}, ids) | |
| 220 | + | ||
| 221 | + | require.NoError(t, s.Reset(ctx, ref0)) | |
| 222 | + | n, _ := s.Count(ctx, ref0) | |
| 223 | + | assert.Zero(t, n) | |
| 224 | + | ||
| 225 | + | require.NoError(t, s.RemoveRuns(ctx, []int64{9})) | |
| 226 | + | _, err = s.Read(ctx, other, 0, 10, 100) | |
| 227 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestARefThatCouldEscapeTheDirectoryIsRefused(t *testing.T) { | |
| 231 | + | ctx := context.Background() | |
| 232 | + | dir := t.TempDir() | |
| 233 | + | s := cilogs.New(dir) | |
| 234 | + | _, _, err := s.Append(ctx, ports.CILogRef{RunID: 1, JobID: "../../etc", Attempt: 1}, 0, lines("x"), big) | |
| 235 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 236 | + | _, _, err = s.Append(ctx, ports.CILogRef{RunID: 1, JobID: "1-0-0", Attempt: 0}, 0, lines("x"), big) | |
| 237 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 238 | + | } | |
M internal/adapters/client/client.go +5 -0
| @@ -65,6 +65,11 @@ func (c *Client) Repos() ermiteconnect.RepoServiceClient { | |||
| 65 | 65 | return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | // Runs returns the RunService client. | |
| 69 | + | func (c *Client) Runs() ermiteconnect.RunServiceClient { | |
| 70 | + | return ermiteconnect.NewRunServiceClient(c.http, c.base, c.opts...) | |
| 71 | + | } | |
| 72 | + | ||
| 68 | 73 | // Tokens returns the TokenService client. | |
| 69 | 74 | func (c *Client) Tokens() ermiteconnect.TokenServiceClient { | |
| 70 | 75 | return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...) | |
M internal/adapters/client/client_test.go +2 -1
| @@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | + | client.FeatureCIRuns, client.FeatureCIRunners, | |
| 146 | 147 | })) | |
| 147 | 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 148 | 149 | } | |
M internal/adapters/client/features.go +5 -1
| @@ -22,11 +22,15 @@ const ( | |||
| 22 | 22 | FeatureSigningKey = "signing_key" | |
| 23 | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 24 | 24 | FeatureSession = "session" | |
| 25 | + | // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner | |
| 26 | + | // calls (`ermite runner`). | |
| 27 | + | FeatureCIRuns = "ci.runs" | |
| 28 | + | FeatureCIRunners = "ci.runners" | |
| 25 | 29 | ) | |
| 26 | 30 | ||
| 27 | 31 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 28 | 32 | func UsedFeatures() []string { | |
| 29 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession} | |
| 33 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners} | |
| 30 | 34 | } | |
| 31 | 35 | ||
| 32 | 36 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
M internal/adapters/client/scopes.go +5 -1
| @@ -18,10 +18,12 @@ const ( | |||
| 18 | 18 | presetCLI = "cli" | |
| 19 | 19 | presetAgent = "agent" | |
| 20 | 20 | presetCI = "ci" | |
| 21 | + | // presetRunner is for a CI runner host: it only registers runners. | |
| 22 | + | presetRunner = "runner" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | // PresetNames lists the token presets, see PresetScopes. | |
| 24 | - | var PresetNames = []string{presetCLI, presetAgent, presetCI} | |
| 26 | + | var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner} | |
| 25 | 27 | ||
| 26 | 28 | // ScopeName is the name of a scope as users type it and as the docs write | |
| 27 | 29 | // it: SCOPE_REPO_READ is "repo:read". | |
| @@ -75,6 +77,8 @@ func PresetScopes(name string) ([]ermite.Scope, error) { | |||
| 75 | 77 | return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil | |
| 76 | 78 | case presetCI: | |
| 77 | 79 | return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil | |
| 80 | + | case presetRunner: | |
| 81 | + | return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil | |
| 78 | 82 | default: | |
| 79 | 83 | return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", ")) | |
| 80 | 84 | } | |
A internal/adapters/gitcli/changed.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var _ ports.CIGit = (*Git)(nil) | |
| 15 | + | ||
| 16 | + | // emptyTree is the id of the empty tree of a SHA-1 repository, what a diff | |
| 17 | + | // from "nothing" starts at. | |
| 18 | + | const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" | |
| 19 | + | ||
| 20 | + | // ChangedPaths runs `git diff-tree -r --name-only -z` from base to head, with | |
| 21 | + | // renames not detected (a rename lists both paths, as GitHub's push filters | |
| 22 | + | // see it), and stops reading after max paths: truncated tells there were more. | |
| 23 | + | // A zero base diffs against the empty tree. | |
| 24 | + | // | |
| 25 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads the path list git prints; the bound comes from the port | |
| 26 | + | func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) { | |
| 27 | + | if limit < 1 || head.IsZero() { | |
| 28 | + | return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit) | |
| 29 | + | } | |
| 30 | + | dir, err := g.repoDir(repo) | |
| 31 | + | if err != nil { | |
| 32 | + | return nil, false, err | |
| 33 | + | } | |
| 34 | + | from := emptyTree | |
| 35 | + | if !base.IsZero() { | |
| 36 | + | from = base.String() | |
| 37 | + | } | |
| 38 | + | args := readArgs(dir, "diff-tree", "-r", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, head.String()) | |
| 39 | + | var ( | |
| 40 | + | paths []string | |
| 41 | + | truncated bool | |
| 42 | + | ) | |
| 43 | + | err = g.pipe(ctx, args, func(r *bufio.Reader) error { | |
| 44 | + | for { | |
| 45 | + | name, err := r.ReadString(0) | |
| 46 | + | if err != nil { | |
| 47 | + | return nil // the end of the output; a failing exit status is reported by pipe | |
| 48 | + | } | |
| 49 | + | if len(paths) == limit { | |
| 50 | + | truncated = true | |
| 51 | + | return errStop | |
| 52 | + | } | |
| 53 | + | paths = append(paths, name[:len(name)-1]) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, false, fmt.Errorf("changed paths %s..%s in %q: %w", from, head, repo, err) | |
| 58 | + | } | |
| 59 | + | return paths, truncated, nil | |
| 60 | + | } | |
A internal/adapters/gitcli/changed_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestChangedPaths(t *testing.T) { | |
| 16 | + | g, data := newGit(t) | |
| 17 | + | ctx := context.Background() | |
| 18 | + | m := newMergeFixture(t, data) | |
| 19 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 20 | + | ||
| 21 | + | // b..d changed a.txt and added c.txt. | |
| 22 | + | got, truncated, err := g.ChangedPaths(ctx, m.f.Name, m.b, m.d, 300) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | assert.False(t, truncated) | |
| 25 | + | assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, got) | |
| 26 | + | ||
| 27 | + | // A zero base lists every file of the commit, as for a new repository. | |
| 28 | + | got, _, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.b, 300) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | assert.ElementsMatch(t, []string{"a.txt", "b.txt"}, got) | |
| 31 | + | ||
| 32 | + | // No change is an empty list, not an error. | |
| 33 | + | got, truncated, err = g.ChangedPaths(ctx, m.f.Name, m.d, m.d, 300) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | assert.Empty(t, got) | |
| 36 | + | assert.False(t, truncated) | |
| 37 | + | ||
| 38 | + | // A bound cuts the list and says so. | |
| 39 | + | got, truncated, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.d, 2) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.Len(t, got, 2) | |
| 42 | + | assert.True(t, truncated) | |
| 43 | + | got, truncated, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.d, 3) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Len(t, got, 3) | |
| 46 | + | assert.False(t, truncated, "a list of exactly the bound is complete") | |
| 47 | + | ||
| 48 | + | _, _, err = g.ChangedPaths(ctx, m.f.Name, m.b, m.d, 0) | |
| 49 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 50 | + | _, _, err = g.ChangedPaths(ctx, name(t, "missing"), m.b, m.d, 5) | |
| 51 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | _, _, err = g.ChangedPaths(ctx, m.f.Name, m.b, oidOf(t, "1111111111111111111111111111111111111111"), 5) | |
| 53 | + | assert.Error(t, err, "an unknown commit is an error") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) { | |
| 57 | + | g, data := newGit(t) | |
| 58 | + | ctx := context.Background() | |
| 59 | + | m := newMergeFixture(t, data) | |
| 60 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 61 | + | ||
| 62 | + | m.f.Git("checkout", "-q", "feat") | |
| 63 | + | m.f.Write("dir with space/é.txt", "see\n") | |
| 64 | + | m.f.Git("rm", "-q", "c.txt") | |
| 65 | + | d2 := oidOf(t, m.f.Commit("rename")) | |
| 66 | + | got, _, err := g.ChangedPaths(ctx, m.f.Name, m.featTip, d2, 10) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim") | |
| 69 | + | } | |
A internal/adapters/kvstore/ci.go +1028 -0
| @@ -0,0 +1,1028 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 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 | + | const ( | |
| 41 | + | runPrefix = "run/" | |
| 42 | + | runSrcPrefix = "runsrc/" | |
| 43 | + | jobPrefix = "job/" | |
| 44 | + | checkPrefix = "check/" | |
| 45 | + | runSeqKey = "runseq" | |
| 46 | + | runNumPrefix = "runnum/" | |
| 47 | + | runnerPrefix = "runner/" | |
| 48 | + | runnerName = "runnername/" | |
| 49 | + | runnerTokPref = "runnertoken/" | |
| 50 | + | openPrefix = "ciopen/" | |
| 51 | + | readyPrefix = "ciready/" | |
| 52 | + | activePrefix = "ciactive/" | |
| 53 | + | jobTokPrefix = "jobtoken/" | |
| 54 | + | idFormat = "%020d" | |
| 55 | + | ) | |
| 56 | + | ||
| 57 | + | // CI is the ermite-kv backed ports.CIStore. It shares the database of the | |
| 58 | + | // Store it comes from. | |
| 59 | + | type CI struct { | |
| 60 | + | db *kv.DB | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | var _ ports.CIStore = (*CI)(nil) | |
| 64 | + | ||
| 65 | + | // CI returns the CI store over the same database. It is valid until the Store | |
| 66 | + | // is closed. | |
| 67 | + | func (s *Store) CI() *CI { return &CI{db: s.db} } | |
| 68 | + | ||
| 69 | + | func runKey(repo domain.RepoName, id int64) []byte { | |
| 70 | + | return []byte(runPrefix + repo.String() + "/" + fmt.Sprintf(idFormat, id)) | |
| 71 | + | } | |
| 72 | + | func runsPrefix(repo domain.RepoName) string { return runPrefix + repo.String() + "/" } | |
| 73 | + | func runSrcKey(id int64) []byte { return []byte(runSrcPrefix + fmt.Sprintf(idFormat, id)) } | |
| 74 | + | func openKey(id int64) []byte { return []byte(openPrefix + fmt.Sprintf(idFormat, id)) } | |
| 75 | + | func jobsPrefix(runID int64) string { return jobPrefix + fmt.Sprintf(idFormat, runID) + "/" } | |
| 76 | + | func jobAttemptPrefix(runID int64, attempt int32) string { | |
| 77 | + | return jobsPrefix(runID) + fmt.Sprintf("%010d", attempt) + "/" | |
| 78 | + | } | |
| 79 | + | func jobRef(runID int64, attempt int32, id string) string { | |
| 80 | + | return jobAttemptPrefix(runID, attempt) + id | |
| 81 | + | } | |
| 82 | + | func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) } | |
| 83 | + | func checksPrefix(repo domain.RepoName, sha string) string { | |
| 84 | + | return checkPrefix + repo.String() + "/" + sha + "/" | |
| 85 | + | } | |
| 86 | + | func checkKey(repo domain.RepoName, sha, id string) []byte { | |
| 87 | + | return []byte(checksPrefix(repo, sha) + id) | |
| 88 | + | } | |
| 89 | + | func runNumKey(repo domain.RepoName, workflow string) []byte { | |
| 90 | + | return []byte(runNumPrefix + repo.String() + "/" + workflow) | |
| 91 | + | } | |
| 92 | + | func readyKey(at time.Time, ref string) []byte { | |
| 93 | + | return []byte(readyPrefix + fmt.Sprintf(idFormat, at.UnixNano()) + "/" + ref) | |
| 94 | + | } | |
| 95 | + | func activeKey(ref string) []byte { return []byte(activePrefix + ref) } | |
| 96 | + | func jobTokenKey(h domain.TokenHash) []byte { | |
| 97 | + | return []byte(jobTokPrefix + h.String()) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // --- encoding --- | |
| 101 | + | ||
| 102 | + | func encodeStatus(s domain.CIStatus) storagepb.CIStatus { | |
| 103 | + | switch s { | |
| 104 | + | case domain.CIQueued: | |
| 105 | + | return storagepb.CIStatus_CI_STATUS_QUEUED | |
| 106 | + | case domain.CIInProgress: | |
| 107 | + | return storagepb.CIStatus_CI_STATUS_IN_PROGRESS | |
| 108 | + | case domain.CICompleted: | |
| 109 | + | return storagepb.CIStatus_CI_STATUS_COMPLETED | |
| 110 | + | } | |
| 111 | + | return storagepb.CIStatus_CI_STATUS_UNSPECIFIED | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func decodeStatus(s storagepb.CIStatus) domain.CIStatus { | |
| 115 | + | switch s { | |
| 116 | + | case storagepb.CIStatus_CI_STATUS_IN_PROGRESS: | |
| 117 | + | return domain.CIInProgress | |
| 118 | + | case storagepb.CIStatus_CI_STATUS_COMPLETED: | |
| 119 | + | return domain.CICompleted | |
| 120 | + | } | |
| 121 | + | return domain.CIQueued | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | var conclusions = []struct { | |
| 125 | + | d domain.CIConclusion | |
| 126 | + | p storagepb.CIConclusion | |
| 127 | + | }{ | |
| 128 | + | {domain.CISuccess, storagepb.CIConclusion_CI_CONCLUSION_SUCCESS}, | |
| 129 | + | {domain.CIFailure, storagepb.CIConclusion_CI_CONCLUSION_FAILURE}, | |
| 130 | + | {domain.CICancelled, storagepb.CIConclusion_CI_CONCLUSION_CANCELLED}, | |
| 131 | + | {domain.CISkipped, storagepb.CIConclusion_CI_CONCLUSION_SKIPPED}, | |
| 132 | + | {domain.CITimedOut, storagepb.CIConclusion_CI_CONCLUSION_TIMED_OUT}, | |
| 133 | + | {domain.CINeutral, storagepb.CIConclusion_CI_CONCLUSION_NEUTRAL}, | |
| 134 | + | {domain.CIActionRequired, storagepb.CIConclusion_CI_CONCLUSION_ACTION_REQUIRED}, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func encodeConclusion(c domain.CIConclusion) storagepb.CIConclusion { | |
| 138 | + | for _, e := range conclusions { | |
| 139 | + | if e.d == c { | |
| 140 | + | return e.p | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return storagepb.CIConclusion_CI_CONCLUSION_UNSPECIFIED | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func decodeConclusion(c storagepb.CIConclusion) domain.CIConclusion { | |
| 147 | + | for _, e := range conclusions { | |
| 148 | + | if e.p == c { | |
| 149 | + | return e.d | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | return domain.CINoConclusion | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | var phases = []struct { | |
| 156 | + | d domain.CIJobPhase | |
| 157 | + | p storagepb.CIJobPhase | |
| 158 | + | }{ | |
| 159 | + | {domain.CIPhaseWaiting, storagepb.CIJobPhase_CI_JOB_PHASE_WAITING}, | |
| 160 | + | {domain.CIPhaseReady, storagepb.CIJobPhase_CI_JOB_PHASE_READY}, | |
| 161 | + | {domain.CIPhaseAssigned, storagepb.CIJobPhase_CI_JOB_PHASE_ASSIGNED}, | |
| 162 | + | {domain.CIPhaseRunning, storagepb.CIJobPhase_CI_JOB_PHASE_RUNNING}, | |
| 163 | + | {domain.CIPhaseEnded, storagepb.CIJobPhase_CI_JOB_PHASE_ENDED}, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func encodePhase(p domain.CIJobPhase) (storagepb.CIJobPhase, error) { | |
| 167 | + | for _, e := range phases { | |
| 168 | + | if e.d == p { | |
| 169 | + | return e.p, nil | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return 0, fmt.Errorf("invalid job phase %q", p) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func decodePhase(p storagepb.CIJobPhase) domain.CIJobPhase { | |
| 176 | + | for _, e := range phases { | |
| 177 | + | if e.p == p { | |
| 178 | + | return e.d | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return domain.CIPhaseWaiting | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | var isolations = []struct { | |
| 185 | + | d domain.CIIsolation | |
| 186 | + | p storagepb.CIIsolation | |
| 187 | + | }{ | |
| 188 | + | {domain.CIIsolationVM, storagepb.CIIsolation_CI_ISOLATION_VM}, | |
| 189 | + | {domain.CIIsolationContainerSysbox, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_SYSBOX}, | |
| 190 | + | {domain.CIIsolationContainerPrivileged, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_PRIVILEGED}, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func encodeIsolation(i domain.CIIsolation) storagepb.CIIsolation { | |
| 194 | + | for _, e := range isolations { | |
| 195 | + | if e.d == i { | |
| 196 | + | return e.p | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return storagepb.CIIsolation_CI_ISOLATION_UNSPECIFIED | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func decodeIsolation(i storagepb.CIIsolation) domain.CIIsolation { | |
| 203 | + | for _, e := range isolations { | |
| 204 | + | if e.p == i { | |
| 205 | + | return e.d | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | return domain.CIIsolationNone | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | var stepStates = []struct { | |
| 212 | + | d domain.CIStepState | |
| 213 | + | p storagepb.CIStepState | |
| 214 | + | }{ | |
| 215 | + | {domain.CIStepPending, storagepb.CIStepState_CI_STEP_STATE_PENDING}, | |
| 216 | + | {domain.CIStepInProgress, storagepb.CIStepState_CI_STEP_STATE_IN_PROGRESS}, | |
| 217 | + | {domain.CIStepCompleted, storagepb.CIStepState_CI_STEP_STATE_COMPLETED}, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func encodeStepState(s domain.CIStepState) storagepb.CIStepState { | |
| 221 | + | for _, e := range stepStates { | |
| 222 | + | if e.d == s { | |
| 223 | + | return e.p | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | return storagepb.CIStepState_CI_STEP_STATE_UNSPECIFIED | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func decodeStepState(s storagepb.CIStepState) domain.CIStepState { | |
| 230 | + | for _, e := range stepStates { | |
| 231 | + | if e.p == s { | |
| 232 | + | return e.d | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | return domain.CIStepPending | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | var cancelReasons = []struct { | |
| 239 | + | d domain.CICancelReason | |
| 240 | + | p storagepb.CICancelReason | |
| 241 | + | }{ | |
| 242 | + | {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER}, | |
| 243 | + | {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST}, | |
| 244 | + | {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT}, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason { | |
| 248 | + | for _, e := range cancelReasons { | |
| 249 | + | if e.d == r { | |
| 250 | + | return e.p | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason { | |
| 257 | + | for _, e := range cancelReasons { | |
| 258 | + | if e.p == r { | |
| 259 | + | return e.d | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return domain.CICancelNone | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func encodeRun(r domain.CIRun) ([]byte, error) { | |
| 266 | + | if r.Repo.IsZero() { | |
| 267 | + | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 268 | + | } | |
| 269 | + | return mustMarshal(&storagepb.CIRun{ | |
| 270 | + | Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt, | |
| 271 | + | WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, | |
| 272 | + | Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, | |
| 273 | + | Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error, | |
| 274 | + | CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt), | |
| 275 | + | CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), | |
| 276 | + | }), nil | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func decodeRun(item kv.Item) (domain.CIRun, error) { | |
| 280 | + | var pb storagepb.CIRun | |
| 281 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 282 | + | return domain.CIRun{}, fmt.Errorf("decode run: %w", err) | |
| 283 | + | } | |
| 284 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 285 | + | if err != nil { | |
| 286 | + | return domain.CIRun{}, fmt.Errorf("decode run: %w", err) | |
| 287 | + | } | |
| 288 | + | return domain.CIRun{ | |
| 289 | + | Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(), | |
| 290 | + | WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Title: pb.GetTitle(), | |
| 291 | + | Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(), | |
| 292 | + | Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(), | |
| 293 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()), | |
| 294 | + | CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | |
| 295 | + | Version: item.Version, | |
| 296 | + | }, nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func encodeJob(j domain.CIJob) ([]byte, error) { | |
| 300 | + | if j.Repo.IsZero() { | |
| 301 | + | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 302 | + | } | |
| 303 | + | phase, err := encodePhase(j.Phase) | |
| 304 | + | if err != nil { | |
| 305 | + | return nil, err | |
| 306 | + | } | |
| 307 | + | steps := make([]*storagepb.CIStep, 0, len(j.Steps)) | |
| 308 | + | for _, s := range j.Steps { | |
| 309 | + | steps = append(steps, &storagepb.CIStep{ | |
| 310 | + | Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion), | |
| 311 | + | StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt), | |
| 312 | + | LogStart: s.LogStart, HasLines: s.HasLines, | |
| 313 | + | }) | |
| 314 | + | } | |
| 315 | + | pb := &storagepb.CIJob{ | |
| 316 | + | Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(), | |
| 317 | + | Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error, | |
| 318 | + | Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, | |
| 319 | + | Isolation: encodeIsolation(j.Isolation), Steps: steps, | |
| 320 | + | CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt), | |
| 321 | + | 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 | |
| 322 | + | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | |
| 323 | + | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | |
| 324 | + | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| 325 | + | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, | |
| 326 | + | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, | |
| 327 | + | } | |
| 328 | + | if j.HasToken() { | |
| 329 | + | pb.TokenHash = j.TokenHash[:] | |
| 330 | + | } | |
| 331 | + | return mustMarshal(pb), nil | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here | |
| 335 | + | func decodeJob(item kv.Item) (domain.CIJob, error) { | |
| 336 | + | var pb storagepb.CIJob | |
| 337 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 338 | + | return domain.CIJob{}, fmt.Errorf("decode job: %w", err) | |
| 339 | + | } | |
| 340 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 341 | + | if err != nil { | |
| 342 | + | return domain.CIJob{}, fmt.Errorf("decode job: %w", err) | |
| 343 | + | } | |
| 344 | + | j := domain.CIJob{ | |
| 345 | + | ID: pb.GetId(), RunID: pb.GetRunId(), Attempt: pb.GetAttempt(), RunAttempt: pb.GetRunAttempt(), Repo: repo, | |
| 346 | + | Key: pb.GetKey(), Name: pb.GetName(), Phase: decodePhase(pb.GetPhase()), Conclusion: decodeConclusion(pb.GetConclusion()), | |
| 347 | + | Error: pb.GetError(), Labels: pb.GetLabels(), Needs: pb.GetNeeds(), RunnerID: pb.GetRunnerId(), RunnerName: pb.GetRunnerName(), | |
| 348 | + | Isolation: decodeIsolation(pb.GetIsolation()), | |
| 349 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()), | |
| 350 | + | Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(), | |
| 351 | + | ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), | |
| 352 | + | MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(), | |
| 353 | + | TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()), | |
| 354 | + | AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()), | |
| 355 | + | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | |
| 356 | + | Version: item.Version, | |
| 357 | + | } | |
| 358 | + | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | |
| 359 | + | copy(j.TokenHash[:], pb.GetTokenHash()) | |
| 360 | + | for _, s := range pb.GetSteps() { | |
| 361 | + | j.Steps = append(j.Steps, domain.CIStep{ | |
| 362 | + | Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()), | |
| 363 | + | StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()), | |
| 364 | + | LogStart: s.GetLogStart(), HasLines: s.GetHasLines(), | |
| 365 | + | }) | |
| 366 | + | } | |
| 367 | + | return j, nil | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func encodeCheck(c domain.CICheck) []byte { | |
| 371 | + | return mustMarshal(&storagepb.CICheck{ | |
| 372 | + | Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status), | |
| 373 | + | Conclusion: encodeConclusion(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt, | |
| 374 | + | WorkflowName: c.WorkflowName, CreatedAt: encodeTime(c.CreatedAt), StartedAt: encodeTime(c.StartedAt), | |
| 375 | + | CompletedAt: encodeTime(c.CompletedAt), | |
| 376 | + | }) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func decodeCheck(item kv.Item) (domain.CICheck, error) { | |
| 380 | + | var pb storagepb.CICheck | |
| 381 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 382 | + | return domain.CICheck{}, fmt.Errorf("decode check: %w", err) | |
| 383 | + | } | |
| 384 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 385 | + | if err != nil { | |
| 386 | + | return domain.CICheck{}, fmt.Errorf("decode check: %w", err) | |
| 387 | + | } | |
| 388 | + | return domain.CICheck{ | |
| 389 | + | ID: pb.GetId(), Repo: repo, Sha: pb.GetSha(), Name: pb.GetName(), Status: decodeStatus(pb.GetStatus()), | |
| 390 | + | Conclusion: decodeConclusion(pb.GetConclusion()), RunID: pb.GetRunId(), JobID: pb.GetJobId(), Attempt: pb.GetAttempt(), | |
| 391 | + | WorkflowName: pb.GetWorkflowName(), CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), | |
| 392 | + | CompletedAt: decodeTime(pb.GetCompletedAt()), Version: item.Version, | |
| 393 | + | }, nil | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func encodeRunner(r domain.CIRunner) []byte { | |
| 397 | + | return mustMarshal(&storagepb.CIRunner{ | |
| 398 | + | Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: encodeIsolation(r.Isolation), Version: r.Version, | |
| 399 | + | Arch: r.Arch, Os: r.OS, RegisteredAt: encodeTime(r.RegisteredAt), TokenHash: r.TokenHash[:], | |
| 400 | + | }) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func decodeRunner(val []byte) (domain.CIRunner, error) { | |
| 404 | + | var pb storagepb.CIRunner | |
| 405 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 406 | + | return domain.CIRunner{}, fmt.Errorf("decode runner: %w", err) | |
| 407 | + | } | |
| 408 | + | r := domain.CIRunner{ | |
| 409 | + | ID: pb.GetId(), Name: pb.GetName(), Labels: pb.GetLabels(), Isolation: decodeIsolation(pb.GetIsolation()), | |
| 410 | + | Version: pb.GetVersion(), Arch: pb.GetArch(), OS: pb.GetOs(), RegisteredAt: decodeTime(pb.GetRegisteredAt()), | |
| 411 | + | } | |
| 412 | + | copy(r.TokenHash[:], pb.GetTokenHash()) | |
| 413 | + | return r, nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // --- writes --- | |
| 417 | + | ||
| 418 | + | // stage adds the preconditions and writes of one record to the batch. | |
| 419 | + | func stage(b *kv.Batch, key, val []byte, version uint64) { | |
| 420 | + | if version == 0 { | |
| 421 | + | b.RequireAbsent(key) | |
| 422 | + | } else { | |
| 423 | + | b.RequireVersion(key, version) | |
| 424 | + | } | |
| 425 | + | b.Put(key, val) | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // runIndexes stages the open-run index of the run. | |
| 429 | + | func runIndexes(b *kv.Batch, r domain.CIRun) { | |
| 430 | + | if r.Status == domain.CICompleted { | |
| 431 | + | b.Delete(openKey(r.ID)) | |
| 432 | + | return | |
| 433 | + | } | |
| 434 | + | b.Put(openKey(r.ID), []byte(r.Repo.String())) | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // jobIndexes stages the ready, active and token indexes of the job. old is | |
| 438 | + | // the job as stored before, if any: its token index goes when the token | |
| 439 | + | // changed. | |
| 440 | + | func (c *CI) jobIndexes(b *kv.Batch, j domain.CIJob, old *domain.CIJob) { | |
| 441 | + | ref := jobRef(j.RunID, j.RunAttempt, j.ID) | |
| 442 | + | if j.Phase == domain.CIPhaseReady { | |
| 443 | + | b.Put(readyKey(j.ReadyAt, ref), nil) | |
| 444 | + | } else { | |
| 445 | + | b.Delete(readyKey(j.ReadyAt, ref)) | |
| 446 | + | } | |
| 447 | + | if old != nil && old.Phase == domain.CIPhaseReady && (j.Phase != domain.CIPhaseReady || !old.ReadyAt.Equal(j.ReadyAt)) { | |
| 448 | + | b.Delete(readyKey(old.ReadyAt, ref)) | |
| 449 | + | } | |
| 450 | + | if j.Active() { | |
| 451 | + | b.Put(activeKey(ref), nil) | |
| 452 | + | } else { | |
| 453 | + | b.Delete(activeKey(ref)) | |
| 454 | + | } | |
| 455 | + | if old != nil && old.HasToken() && old.TokenHash != j.TokenHash { | |
| 456 | + | b.Delete(jobTokenKey(old.TokenHash)) | |
| 457 | + | } | |
| 458 | + | if j.HasToken() { | |
| 459 | + | b.Put(jobTokenKey(j.TokenHash), []byte(ref)) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | // guardRepo is guardRepoKey on the database of the CI store, for the writes that | |
| 464 | + | // create keys under a repository. | |
| 465 | + | func (c *CI) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error { | |
| 466 | + | return guardRepoKey(ctx, c.db, b, repo) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // stageRecords stages the writes of w. guarded collects the repositories that | |
| 470 | + | // need guarding. | |
| 471 | + | func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (guarded map[domain.RepoName]bool, err error) { | |
| 472 | + | guarded = map[domain.RepoName]bool{} | |
| 473 | + | for _, r := range w.Runs { | |
| 474 | + | val, err := encodeRun(r) | |
| 475 | + | if err != nil { | |
| 476 | + | return nil, err | |
| 477 | + | } | |
| 478 | + | stage(b, runKey(r.Repo, r.ID), val, r.Version) | |
| 479 | + | runIndexes(b, r) | |
| 480 | + | if r.Version == 0 { | |
| 481 | + | guarded[r.Repo] = true | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | for _, j := range w.Jobs { | |
| 485 | + | val, err := encodeJob(j) | |
| 486 | + | if err != nil { | |
| 487 | + | return nil, err | |
| 488 | + | } | |
| 489 | + | key := jobKey(j.RunID, j.RunAttempt, j.ID) | |
| 490 | + | var old *domain.CIJob | |
| 491 | + | if j.Version != 0 { | |
| 492 | + | item, ok := c.db.Get(ctx, key) | |
| 493 | + | if !ok { | |
| 494 | + | return nil, domain.ErrConflict | |
| 495 | + | } | |
| 496 | + | if o, err := decodeJob(item); err == nil { | |
| 497 | + | old = &o | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | stage(b, key, val, j.Version) | |
| 501 | + | c.jobIndexes(b, j, old) | |
| 502 | + | if j.Version == 0 { | |
| 503 | + | guarded[j.Repo] = true | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | // Checks are derived from the jobs, written in the same batch and under | |
| 507 | + | // the lock of their run: they are upserted without a precondition. | |
| 508 | + | for _, ck := range w.Checks { | |
| 509 | + | b.Put(checkKey(ck.Repo, ck.Sha, ck.ID), encodeCheck(ck)) | |
| 510 | + | } | |
| 511 | + | return guarded, nil | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // Apply writes the records in one batch. | |
| 515 | + | // | |
| 516 | + | //hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy | |
| 517 | + | func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) { | |
| 518 | + | var b kv.Batch | |
| 519 | + | guarded, err := c.stageRecords(ctx, &b, w) | |
| 520 | + | if err != nil { | |
| 521 | + | return 0, err | |
| 522 | + | } | |
| 523 | + | for repo := range guarded { | |
| 524 | + | if err := c.guardRepo(ctx, &b, repo); err != nil { | |
| 525 | + | return 0, err | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | v, err := c.db.Apply(ctx, &b) | |
| 529 | + | if errors.Is(err, kv.ErrConflict) { | |
| 530 | + | return 0, domain.ErrConflict | |
| 531 | + | } | |
| 532 | + | if err != nil { | |
| 533 | + | return 0, fmt.Errorf("write CI records: %w", err) | |
| 534 | + | } | |
| 535 | + | return v, nil | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func (c *CI) readSeq(ctx context.Context, key []byte) (last int64, item kv.Item, ok bool, err error) { | |
| 539 | + | item, ok = c.db.Get(ctx, key) | |
| 540 | + | if !ok { | |
| 541 | + | return 0, kv.Item{}, false, nil | |
| 542 | + | } | |
| 543 | + | var seq storagepb.Sequence | |
| 544 | + | if err := proto.Unmarshal(item.Value, &seq); err != nil { | |
| 545 | + | return 0, kv.Item{}, false, fmt.Errorf("decode sequence %s: %w", key, err) | |
| 546 | + | } | |
| 547 | + | return seq.GetLast(), item, true, nil | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) { | |
| 551 | + | if ok { | |
| 552 | + | b.RequireVersion(key, item.Version) | |
| 553 | + | } else { | |
| 554 | + | b.RequireAbsent(key) | |
| 555 | + | } | |
| 556 | + | b.Put(key, mustMarshal(&storagepb.Sequence{Last: last})) | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | const createRunTries = 20 | |
| 560 | + | ||
| 561 | + | // CreateRun takes the next run id and number and stores the draft. | |
| 562 | + | // | |
| 563 | + | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | |
| 564 | + | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 565 | + | for range createRunTries { | |
| 566 | + | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | |
| 567 | + | if err != nil { | |
| 568 | + | return ports.CIRunDraft{}, err | |
| 569 | + | } | |
| 570 | + | numKey := runNumKey(repo, workflowPath) | |
| 571 | + | lastNum, numItem, numOK, err := c.readSeq(ctx, numKey) | |
| 572 | + | if err != nil { | |
| 573 | + | return ports.CIRunDraft{}, err | |
| 574 | + | } | |
| 575 | + | draft := build(lastID+1, lastNum+1) | |
| 576 | + | var b kv.Batch | |
| 577 | + | stageSeq(&b, []byte(runSeqKey), idItem, idOK, lastID+1) | |
| 578 | + | stageSeq(&b, numKey, numItem, numOK, lastNum+1) | |
| 579 | + | w := ports.CIWrite{Runs: []domain.CIRun{draft.Run}, Jobs: draft.Jobs, Checks: draft.Checks} | |
| 580 | + | guarded, err := c.stageRecords(ctx, &b, w) | |
| 581 | + | if err != nil { | |
| 582 | + | return ports.CIRunDraft{}, err | |
| 583 | + | } | |
| 584 | + | src := mustMarshal(&storagepb.CIRunSource{ | |
| 585 | + | RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON, | |
| 586 | + | }) | |
| 587 | + | b.RequireAbsent(runSrcKey(draft.Run.ID)) | |
| 588 | + | b.Put(runSrcKey(draft.Run.ID), src) | |
| 589 | + | for r := range guarded { | |
| 590 | + | if err := c.guardRepo(ctx, &b, r); err != nil { | |
| 591 | + | return ports.CIRunDraft{}, err | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | v, err := c.db.Apply(ctx, &b) | |
| 595 | + | if errors.Is(err, kv.ErrConflict) { | |
| 596 | + | continue | |
| 597 | + | } | |
| 598 | + | if err != nil { | |
| 599 | + | return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, err) | |
| 600 | + | } | |
| 601 | + | draft.Run.Version = v | |
| 602 | + | for i := range draft.Jobs { | |
| 603 | + | draft.Jobs[i].Version = v | |
| 604 | + | } | |
| 605 | + | for i := range draft.Checks { | |
| 606 | + | draft.Checks[i].Version = v | |
| 607 | + | } | |
| 608 | + | return draft, nil | |
| 609 | + | } | |
| 610 | + | return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, domain.ErrConflict) | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // --- reads --- | |
| 614 | + | ||
| 615 | + | // GetRun reads run/<repo>/<id>. | |
| 616 | + | func (c *CI) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) { | |
| 617 | + | item, ok := c.db.Get(ctx, runKey(repo, id)) | |
| 618 | + | if !ok || id < 1 { | |
| 619 | + | return domain.CIRun{}, domain.ErrNotFound | |
| 620 | + | } | |
| 621 | + | return decodeRun(item) | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | // GetRunSource reads runsrc/<id>. | |
| 625 | + | func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) { | |
| 626 | + | item, ok := c.db.Get(ctx, runSrcKey(runID)) | |
| 627 | + | if !ok || runID < 1 { | |
| 628 | + | return domain.CIRunSource{}, domain.ErrNotFound | |
| 629 | + | } | |
| 630 | + | var pb storagepb.CIRunSource | |
| 631 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 632 | + | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | |
| 633 | + | } | |
| 634 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 635 | + | if err != nil { | |
| 636 | + | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | |
| 637 | + | } | |
| 638 | + | return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // ListRuns returns the runs of the repository, newest first. | |
| 642 | + | // | |
| 643 | + | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it | |
| 644 | + | func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) { | |
| 645 | + | start := []byte(runsPrefix(repo)) | |
| 646 | + | end := []byte(runPrefix + repo.String() + "0") // '/' + 1 | |
| 647 | + | if before > 0 { | |
| 648 | + | end = runKey(repo, before) | |
| 649 | + | } | |
| 650 | + | // Collect then reverse: the snapshot iterates in key order only. | |
| 651 | + | var all []domain.CIRun | |
| 652 | + | for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) { | |
| 653 | + | r, err := decodeRun(item) | |
| 654 | + | if err != nil { | |
| 655 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 656 | + | } | |
| 657 | + | all = append(all, r) | |
| 658 | + | } | |
| 659 | + | slices.Reverse(all) | |
| 660 | + | if limit > 0 && len(all) > limit { | |
| 661 | + | all = all[:limit] | |
| 662 | + | } | |
| 663 | + | return all, nil | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // RunIDs returns the ids of the runs of the repository. | |
| 667 | + | func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) { | |
| 668 | + | var ids []int64 | |
| 669 | + | prefix := runsPrefix(repo) | |
| 670 | + | for key := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |
| 671 | + | id, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64) | |
| 672 | + | if err != nil { | |
| 673 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 674 | + | } | |
| 675 | + | ids = append(ids, id) | |
| 676 | + | } | |
| 677 | + | return ids, nil | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | // ListOpenRuns returns the runs that have not completed. | |
| 681 | + | func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) { | |
| 682 | + | snap := c.db.Snapshot(ctx) | |
| 683 | + | var out []domain.CIRun | |
| 684 | + | for key, item := range snap.Prefix(ctx, []byte(openPrefix)) { | |
| 685 | + | id, err := strconv.ParseInt(strings.TrimPrefix(string(key), openPrefix), 10, 64) | |
| 686 | + | if err != nil { | |
| 687 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 688 | + | } | |
| 689 | + | repo, err := domain.ParseRepoName(string(item.Value)) | |
| 690 | + | if err != nil { | |
| 691 | + | return nil, err | |
| 692 | + | } | |
| 693 | + | it, ok := snap.Get(ctx, runKey(repo, id)) | |
| 694 | + | if !ok { | |
| 695 | + | continue | |
| 696 | + | } | |
| 697 | + | r, err := decodeRun(it) | |
| 698 | + | if err != nil { | |
| 699 | + | return nil, err | |
| 700 | + | } | |
| 701 | + | out = append(out, r) | |
| 702 | + | } | |
| 703 | + | return out, nil | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // GetJob reads one job record. | |
| 707 | + | func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | |
| 708 | + | item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID)) | |
| 709 | + | if !ok { | |
| 710 | + | return domain.CIJob{}, domain.ErrNotFound | |
| 711 | + | } | |
| 712 | + | return decodeJob(item) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // ListJobs returns the jobs of a run at an attempt, ordered by index then | |
| 716 | + | // combination. | |
| 717 | + | func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) { | |
| 718 | + | var out []domain.CIJob | |
| 719 | + | for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) { | |
| 720 | + | j, err := decodeJob(item) | |
| 721 | + | if err != nil { | |
| 722 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 723 | + | } | |
| 724 | + | out = append(out, j) | |
| 725 | + | } | |
| 726 | + | slices.SortFunc(out, func(a, b domain.CIJob) int { | |
| 727 | + | if a.Index != b.Index { | |
| 728 | + | return a.Index - b.Index | |
| 729 | + | } | |
| 730 | + | return a.Combo - b.Combo | |
| 731 | + | }) | |
| 732 | + | return out, nil | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domain.CIJob, error) { | |
| 736 | + | item, ok := snap.Get(ctx, []byte(ref)) | |
| 737 | + | if !ok { | |
| 738 | + | return domain.CIJob{}, domain.ErrNotFound | |
| 739 | + | } | |
| 740 | + | return decodeJob(item) | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // FindJobByToken resolves the token index, then reads the job. | |
| 744 | + | func (c *CI) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) { | |
| 745 | + | snap := c.db.Snapshot(ctx) | |
| 746 | + | idx, ok := snap.Get(ctx, jobTokenKey(hash)) | |
| 747 | + | if !ok { | |
| 748 | + | return domain.CIJob{}, domain.ErrNotFound | |
| 749 | + | } | |
| 750 | + | j, err := c.jobByRef(snap, ctx, string(idx.Value)) | |
| 751 | + | if err != nil { | |
| 752 | + | return domain.CIJob{}, err | |
| 753 | + | } | |
| 754 | + | if j.TokenHash != hash || (!j.TokenValidUntil.IsZero() && !now.Before(j.TokenValidUntil)) { | |
| 755 | + | return domain.CIJob{}, domain.ErrNotFound | |
| 756 | + | } | |
| 757 | + | return j, nil | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // ListReadyJobs returns the jobs waiting for a runner, oldest first. | |
| 761 | + | func (c *CI) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) { | |
| 762 | + | snap := c.db.Snapshot(ctx) | |
| 763 | + | var out []domain.CIJob | |
| 764 | + | for key := range snap.Prefix(ctx, []byte(readyPrefix)) { | |
| 765 | + | ref := string(key)[len(readyPrefix)+len(fmt.Sprintf(idFormat, 0))+1:] | |
| 766 | + | j, err := c.jobByRef(snap, ctx, ref) | |
| 767 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 768 | + | continue | |
| 769 | + | } | |
| 770 | + | if err != nil { | |
| 771 | + | return nil, err | |
| 772 | + | } | |
| 773 | + | out = append(out, j) | |
| 774 | + | if limit > 0 && len(out) >= limit { | |
| 775 | + | break | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | return out, nil | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | // ListActiveJobs returns the jobs given to a runner and not over. | |
| 782 | + | func (c *CI) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) { | |
| 783 | + | snap := c.db.Snapshot(ctx) | |
| 784 | + | var out []domain.CIJob | |
| 785 | + | for key := range snap.Prefix(ctx, []byte(activePrefix)) { | |
| 786 | + | j, err := c.jobByRef(snap, ctx, strings.TrimPrefix(string(key), activePrefix)) | |
| 787 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 788 | + | continue | |
| 789 | + | } | |
| 790 | + | if err != nil { | |
| 791 | + | return nil, err | |
| 792 | + | } | |
| 793 | + | out = append(out, j) | |
| 794 | + | } | |
| 795 | + | return out, nil | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | // ListChecks returns the checks of a commit ordered by creation time then | |
| 799 | + | // name. | |
| 800 | + | func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) { | |
| 801 | + | var out []domain.CICheck | |
| 802 | + | for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(checksPrefix(repo, sha))) { | |
| 803 | + | ck, err := decodeCheck(item) | |
| 804 | + | if err != nil { | |
| 805 | + | return nil, false, fmt.Errorf("decode %s: %w", key, err) | |
| 806 | + | } | |
| 807 | + | out = append(out, ck) | |
| 808 | + | } | |
| 809 | + | slices.SortStableFunc(out, func(a, b domain.CICheck) int { | |
| 810 | + | if d := a.CreatedAt.Compare(b.CreatedAt); d != 0 { | |
| 811 | + | return d | |
| 812 | + | } | |
| 813 | + | return strings.Compare(a.Name, b.Name) | |
| 814 | + | }) | |
| 815 | + | more := limit > 0 && len(out) > limit | |
| 816 | + | if more { | |
| 817 | + | out = out[:limit] | |
| 818 | + | } | |
| 819 | + | return out, more, nil | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | // --- retention and deletion --- | |
| 823 | + | ||
| 824 | + | // runKeys lists every key of a run except its record: source, jobs, indexes. | |
| 825 | + | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | |
| 826 | + | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 827 | + | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 828 | + | keys = append(keys, slices.Clone(key)) | |
| 829 | + | if j, err := decodeJob(item); err == nil { | |
| 830 | + | ref := jobRef(j.RunID, j.RunAttempt, j.ID) | |
| 831 | + | keys = append(keys, readyKey(j.ReadyAt, ref), activeKey(ref)) | |
| 832 | + | if j.HasToken() { | |
| 833 | + | keys = append(keys, jobTokenKey(j.TokenHash)) | |
| 834 | + | } | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | return keys | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | // ciKeys lists every key of the snapshot that belongs to the repository's CI | |
| 841 | + | // runs, checks and numbers. | |
| 842 | + | func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 843 | + | c := &CI{} | |
| 844 | + | var keys [][]byte | |
| 845 | + | for key, item := range snap.Prefix(ctx, []byte(runsPrefix(repo))) { | |
| 846 | + | if r, err := decodeRun(item); err == nil { | |
| 847 | + | keys = append(keys, c.runKeys(ctx, snap, repo, r.ID)...) | |
| 848 | + | } | |
| 849 | + | keys = append(keys, slices.Clone(key)) | |
| 850 | + | } | |
| 851 | + | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} { | |
| 852 | + | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 853 | + | keys = append(keys, slices.Clone(k)) | |
| 854 | + | } | |
| 855 | + | } | |
| 856 | + | return keys | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | const sweepChunk = 500 | |
| 860 | + | ||
| 861 | + | // Sweep removes the runs completed before the time. | |
| 862 | + | // | |
| 863 | + | //hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller | |
| 864 | + | func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | |
| 865 | + | snap := c.db.Snapshot(ctx) | |
| 866 | + | var ids []int64 | |
| 867 | + | var keys [][]byte | |
| 868 | + | for _, rp := range []string{runPrefix} { | |
| 869 | + | for _, item := range snap.Prefix(ctx, []byte(rp)) { | |
| 870 | + | r, err := decodeRun(item) | |
| 871 | + | if err != nil { | |
| 872 | + | return nil, err | |
| 873 | + | } | |
| 874 | + | if r.Status != domain.CICompleted || r.CompletedAt.IsZero() || !r.CompletedAt.Before(before) { | |
| 875 | + | continue | |
| 876 | + | } | |
| 877 | + | ids = append(ids, r.ID) | |
| 878 | + | keys = append(keys, c.runKeys(ctx, snap, r.Repo, r.ID)...) | |
| 879 | + | for ck := range snap.Prefix(ctx, []byte(checkPrefix+r.Repo.String()+"/"+r.Sha+"/")) { | |
| 880 | + | // A check belongs to the run only while it is the latest one | |
| 881 | + | // for its job: leave it when another run replaced it. | |
| 882 | + | if it, ok := snap.Get(ctx, ck); ok { | |
| 883 | + | if chk, err := decodeCheck(it); err == nil && chk.RunID == r.ID { | |
| 884 | + | keys = append(keys, slices.Clone(ck)) | |
| 885 | + | } | |
| 886 | + | } | |
| 887 | + | } | |
| 888 | + | } | |
| 889 | + | } | |
| 890 | + | for len(keys) > 0 { | |
| 891 | + | n := min(sweepChunk, len(keys)) | |
| 892 | + | var b kv.Batch | |
| 893 | + | for _, k := range keys[:n] { | |
| 894 | + | b.Delete(k) | |
| 895 | + | } | |
| 896 | + | if _, err := c.db.Apply(ctx, &b); err != nil { | |
| 897 | + | return nil, fmt.Errorf("sweep CI runs: %w", err) | |
| 898 | + | } | |
| 899 | + | keys = keys[n:] | |
| 900 | + | } | |
| 901 | + | return ids, nil | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | // --- runners --- | |
| 905 | + | ||
| 906 | + | func runnerKey(id string) []byte { return []byte(runnerPrefix + id) } | |
| 907 | + | func runnerNameKey(n string) []byte { return []byte(runnerName + strings.ToLower(n)) } | |
| 908 | + | func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) } | |
| 909 | + | ||
| 910 | + | // CreateRunner stores the runner, its name index and its token index. | |
| 911 | + | // | |
| 912 | + | //hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller | |
| 913 | + | func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) { | |
| 914 | + | for range createRunTries { | |
| 915 | + | var b kv.Batch | |
| 916 | + | var removed []domain.CIRunner | |
| 917 | + | if idx, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok { | |
| 918 | + | if !replace { | |
| 919 | + | return nil, domain.ErrAlreadyExists | |
| 920 | + | } | |
| 921 | + | old, err := c.GetRunner(ctx, string(idx.Value)) | |
| 922 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 923 | + | return nil, err | |
| 924 | + | } | |
| 925 | + | b.RequireVersion(runnerNameKey(r.Name), idx.Version) | |
| 926 | + | if err == nil { | |
| 927 | + | removed = append(removed, old) | |
| 928 | + | b.Delete(runnerKey(old.ID)) | |
| 929 | + | b.Delete(runnerTokenKey(old.TokenHash)) | |
| 930 | + | } | |
| 931 | + | } else { | |
| 932 | + | b.RequireAbsent(runnerNameKey(r.Name)) | |
| 933 | + | } | |
| 934 | + | b.RequireAbsent(runnerKey(r.ID)) | |
| 935 | + | b.RequireAbsent(runnerTokenKey(r.TokenHash)) | |
| 936 | + | b.Put(runnerKey(r.ID), encodeRunner(r)) | |
| 937 | + | b.Put(runnerNameKey(r.Name), []byte(r.ID)) | |
| 938 | + | b.Put(runnerTokenKey(r.TokenHash), []byte(r.ID)) | |
| 939 | + | _, err := c.db.Apply(ctx, &b) | |
| 940 | + | if errors.Is(err, kv.ErrConflict) { | |
| 941 | + | if !replace { | |
| 942 | + | if _, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok { | |
| 943 | + | return nil, domain.ErrAlreadyExists | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | continue | |
| 947 | + | } | |
| 948 | + | if err != nil { | |
| 949 | + | return nil, fmt.Errorf("create runner %q: %w", r.Name, err) | |
| 950 | + | } | |
| 951 | + | return removed, nil | |
| 952 | + | } | |
| 953 | + | return nil, fmt.Errorf("create runner %q: %w", r.Name, domain.ErrConflict) | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | // GetRunner reads runner/<id>. | |
| 957 | + | func (c *CI) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) { | |
| 958 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 959 | + | if !ok || id == "" || strings.Contains(id, "/") { | |
| 960 | + | return domain.CIRunner{}, domain.ErrNotFound | |
| 961 | + | } | |
| 962 | + | return decodeRunner(item.Value) | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | // FindRunnerByToken resolves the token index, then reads the runner. | |
| 966 | + | func (c *CI) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) { | |
| 967 | + | snap := c.db.Snapshot(ctx) | |
| 968 | + | idx, ok := snap.Get(ctx, runnerTokenKey(hash)) | |
| 969 | + | if !ok { | |
| 970 | + | return domain.CIRunner{}, domain.ErrNotFound | |
| 971 | + | } | |
| 972 | + | item, ok := snap.Get(ctx, runnerKey(string(idx.Value))) | |
| 973 | + | if !ok { | |
| 974 | + | return domain.CIRunner{}, domain.ErrNotFound | |
| 975 | + | } | |
| 976 | + | r, err := decodeRunner(item.Value) | |
| 977 | + | if err != nil { | |
| 978 | + | return domain.CIRunner{}, err | |
| 979 | + | } | |
| 980 | + | if r.TokenHash != hash { | |
| 981 | + | return domain.CIRunner{}, fmt.Errorf("runner %q: token index does not match the stored hash", r.ID) | |
| 982 | + | } | |
| 983 | + | return r, nil | |
| 984 | + | } | |
| 985 | + | ||
| 986 | + | // ListRunners returns every runner ordered by name. | |
| 987 | + | func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) { | |
| 988 | + | var out []domain.CIRunner | |
| 989 | + | for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(runnerPrefix)) { | |
| 990 | + | r, err := decodeRunner(item.Value) | |
| 991 | + | if err != nil { | |
| 992 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 993 | + | } | |
| 994 | + | out = append(out, r) | |
| 995 | + | } | |
| 996 | + | slices.SortFunc(out, func(a, b domain.CIRunner) int { | |
| 997 | + | return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) | |
| 998 | + | }) | |
| 999 | + | return out, nil | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | // RemoveRunner removes the runner, its name and its token. | |
| 1003 | + | func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) { | |
| 1004 | + | for range createRunTries { | |
| 1005 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 1006 | + | if !ok || id == "" || strings.Contains(id, "/") { | |
| 1007 | + | return domain.CIRunner{}, domain.ErrNotFound | |
| 1008 | + | } | |
| 1009 | + | r, err := decodeRunner(item.Value) | |
| 1010 | + | if err != nil { | |
| 1011 | + | return domain.CIRunner{}, err | |
| 1012 | + | } | |
| 1013 | + | var b kv.Batch | |
| 1014 | + | b.RequireVersion(runnerKey(id), item.Version) | |
| 1015 | + | b.Delete(runnerKey(id)) | |
| 1016 | + | b.Delete(runnerNameKey(r.Name)) | |
| 1017 | + | b.Delete(runnerTokenKey(r.TokenHash)) | |
| 1018 | + | _, err = c.db.Apply(ctx, &b) | |
| 1019 | + | if errors.Is(err, kv.ErrConflict) { | |
| 1020 | + | continue | |
| 1021 | + | } | |
| 1022 | + | if err != nil { | |
| 1023 | + | return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, err) | |
| 1024 | + | } | |
| 1025 | + | return r, nil | |
| 1026 | + | } | |
| 1027 | + | return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict) | |
| 1028 | + | } | |
A internal/adapters/kvstore/ci_test.go +375 -0
| @@ -0,0 +1,375 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ciRepo opens a store holding repository "proj". | |
| 20 | + | func ciRepo(t *testing.T) (*kvstore.Store, *kvstore.CI, domain.RepoName) { | |
| 21 | + | t.Helper() | |
| 22 | + | s := openStore(t, t.TempDir()) | |
| 23 | + | n := name(t, "proj") | |
| 24 | + | require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 25 | + | return s, s.CI(), n | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func draftOf(repo domain.RepoName, workflow string, jobs int) func(id, number int64) ports.CIRunDraft { | |
| 29 | + | return func(id, number int64) ports.CIRunDraft { | |
| 30 | + | d := ports.CIRunDraft{ | |
| 31 | + | Run: domain.CIRun{ | |
| 32 | + | Repo: repo, ID: id, Number: number, Attempt: 1, WorkflowPath: workflow, WorkflowName: "ci", Event: "push", | |
| 33 | + | Ref: "refs/heads/main", Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Status: domain.CIQueued, CreatedAt: created, | |
| 34 | + | }, | |
| 35 | + | Source: domain.CIRunSource{WorkflowYAML: "on: push", GithubJSON: "{}"}, | |
| 36 | + | } | |
| 37 | + | for i := range jobs { | |
| 38 | + | j := domain.CIJob{ | |
| 39 | + | ID: domain.NewJobID(id, i, 0), RunID: id, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build", | |
| 40 | + | Phase: domain.CIPhaseReady, ReadyAt: created.Add(time.Duration(i) * time.Second), CreatedAt: created, | |
| 41 | + | Labels: []string{"self-hosted"}, Index: i, TimeoutMinutes: 360, Planned: true, | |
| 42 | + | } | |
| 43 | + | d.Jobs = append(d.Jobs, j) | |
| 44 | + | d.Checks = append(d.Checks, domain.CICheck{ | |
| 45 | + | ID: j.ID, Repo: repo, Sha: d.Run.Sha, Name: j.Name, Status: domain.CIQueued, RunID: id, JobID: j.ID, Attempt: 1, | |
| 46 | + | WorkflowName: "ci", CreatedAt: created, | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | return d | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestCICreateRunTakesIncreasingIdsAndPerWorkflowNumbers(t *testing.T) { | |
| 54 | + | ctx := context.Background() | |
| 55 | + | _, ci, n := ciRepo(t) | |
| 56 | + | ||
| 57 | + | a, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 1)) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | b, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 1)) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | c, err := ci.CreateRun(ctx, n, ".github/workflows/lint.yml", draftOf(n, ".github/workflows/lint.yml", 0)) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | ||
| 64 | + | assert.Equal(t, []int64{1, 2, 3}, []int64{a.Run.ID, b.Run.ID, c.Run.ID}) | |
| 65 | + | assert.Equal(t, []int64{1, 2, 1}, []int64{a.Run.Number, b.Run.Number, c.Run.Number}) | |
| 66 | + | assert.NotZero(t, a.Run.Version) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestCIConcurrentCreateRunNeverSharesAnId(t *testing.T) { | |
| 70 | + | ctx := context.Background() | |
| 71 | + | _, ci, n := ciRepo(t) | |
| 72 | + | var mu sync.Mutex | |
| 73 | + | ids := map[int64]bool{} | |
| 74 | + | var wg sync.WaitGroup | |
| 75 | + | for range 12 { | |
| 76 | + | wg.Add(1) | |
| 77 | + | go func() { | |
| 78 | + | defer wg.Done() | |
| 79 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 80 | + | if assert.NoError(t, err) { | |
| 81 | + | mu.Lock() | |
| 82 | + | defer mu.Unlock() | |
| 83 | + | assert.False(t, ids[d.Run.ID], "id %d handed out twice", d.Run.ID) | |
| 84 | + | ids[d.Run.ID] = true | |
| 85 | + | } | |
| 86 | + | }() | |
| 87 | + | } | |
| 88 | + | wg.Wait() | |
| 89 | + | assert.Len(t, ids, 12) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestCICreateRunFailsOnAMissingRepository(t *testing.T) { | |
| 93 | + | _, ci, _ := ciRepo(t) | |
| 94 | + | ghost := name(t, "ghost") | |
| 95 | + | _, err := ci.CreateRun(context.Background(), ghost, "w.yml", draftOf(ghost, "w.yml", 1)) | |
| 96 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestCIJobIndexesFollowThePhase(t *testing.T) { | |
| 100 | + | ctx := context.Background() | |
| 101 | + | _, ci, n := ciRepo(t) | |
| 102 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2)) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | ||
| 105 | + | ready, err := ci.ListReadyJobs(ctx, 10) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Len(t, ready, 2) | |
| 108 | + | assert.Equal(t, d.Jobs[0].ID, ready[0].ID, "oldest first") | |
| 109 | + | ||
| 110 | + | open, err := ci.ListOpenRuns(ctx) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | assert.Len(t, open, 1) | |
| 113 | + | ||
| 114 | + | // Assign the first job: it leaves the queue, enters the active set and its | |
| 115 | + | // token becomes findable. | |
| 116 | + | j := ready[0] | |
| 117 | + | hash := domain.HashTokenSecret("ermitejob_x") | |
| 118 | + | j.Phase, j.TokenHash, j.AssignedAt = domain.CIPhaseAssigned, hash, created.Add(time.Minute) | |
| 119 | + | j.TokenValidUntil = created.Add(48 * time.Hour) | |
| 120 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | ||
| 123 | + | ready, _ = ci.ListReadyJobs(ctx, 10) | |
| 124 | + | assert.Len(t, ready, 1) | |
| 125 | + | active, err := ci.ListActiveJobs(ctx) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | require.Len(t, active, 1) | |
| 128 | + | assert.Equal(t, j.ID, active[0].ID) | |
| 129 | + | found, err := ci.FindJobByToken(ctx, hash, created.Add(2*time.Minute)) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | assert.Equal(t, j.ID, found.ID) | |
| 132 | + | _, err = ci.FindJobByToken(ctx, hash, created.Add(72*time.Hour)) | |
| 133 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "an expired token is not found") | |
| 134 | + | ||
| 135 | + | // A new token replaces the old one. | |
| 136 | + | j2, err := ci.GetJob(ctx, j.RunID, j.RunAttempt, j.ID) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | j2.TokenHash = domain.HashTokenSecret("ermitejob_y") | |
| 139 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j2}}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | _, err = ci.FindJobByToken(ctx, hash, created.Add(2*time.Minute)) | |
| 142 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the replaced token is gone") | |
| 143 | + | ||
| 144 | + | // End it. | |
| 145 | + | j3, _ := ci.GetJob(ctx, j.RunID, j.RunAttempt, j.ID) | |
| 146 | + | j3.Phase, j3.Conclusion = domain.CIPhaseEnded, domain.CISuccess | |
| 147 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j3}}) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | active, _ = ci.ListActiveJobs(ctx) | |
| 150 | + | assert.Empty(t, active) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestCIApplyChecksVersions(t *testing.T) { | |
| 154 | + | ctx := context.Background() | |
| 155 | + | _, ci, n := ciRepo(t) | |
| 156 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | ||
| 159 | + | a, err := ci.GetRun(ctx, n, d.Run.ID) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | b := a | |
| 162 | + | a.Status = domain.CIInProgress | |
| 163 | + | _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{a}}) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | b.Status = domain.CICompleted | |
| 166 | + | _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{b}}) | |
| 167 | + | assert.ErrorIs(t, err, domain.ErrConflict, "a write on a stale copy loses") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestCIChecksAreOrderedAndLimited(t *testing.T) { | |
| 171 | + | ctx := context.Background() | |
| 172 | + | _, ci, n := ciRepo(t) | |
| 173 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 3)) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | ||
| 176 | + | checks, more, err := ci.ListChecks(ctx, n, d.Run.Sha, 10) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | assert.Len(t, checks, 3) | |
| 179 | + | assert.False(t, more) | |
| 180 | + | checks, more, err = ci.ListChecks(ctx, n, d.Run.Sha, 2) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | assert.Len(t, checks, 2) | |
| 183 | + | assert.True(t, more) | |
| 184 | + | none, _, err := ci.ListChecks(ctx, n, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 10) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | assert.Empty(t, none) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestCIListRunsNewestFirstAndBounded(t *testing.T) { | |
| 190 | + | ctx := context.Background() | |
| 191 | + | s, ci, n := ciRepo(t) | |
| 192 | + | other := name(t, "other") | |
| 193 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: created})) | |
| 194 | + | _, err := ci.CreateRun(ctx, other, "w.yml", draftOf(other, "w.yml", 0)) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | for range 5 { | |
| 197 | + | _, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0)) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | } | |
| 200 | + | runs, err := ci.ListRuns(ctx, n, 0, 3) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | require.Len(t, runs, 3) | |
| 203 | + | assert.Equal(t, []int64{6, 5, 4}, []int64{runs[0].ID, runs[1].ID, runs[2].ID}) | |
| 204 | + | runs, err = ci.ListRuns(ctx, n, 4, 10) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID}) | |
| 207 | + | ids, err := ci.RunIDs(ctx, n) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | assert.Len(t, ids, 5) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestCIRunnersCreateReplaceFindRemove(t *testing.T) { | |
| 213 | + | ctx := context.Background() | |
| 214 | + | _, ci, _ := ciRepo(t) | |
| 215 | + | h1, h2 := domain.HashTokenSecret("ermiterun_1"), domain.HashTokenSecret("ermiterun_2") | |
| 216 | + | r1 := domain.CIRunner{ID: "r1", Name: "Alpha", Labels: []string{"linux"}, Isolation: domain.CIIsolationVM, TokenHash: h1, RegisteredAt: created} | |
| 217 | + | _, err := ci.CreateRunner(ctx, r1, false) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | ||
| 220 | + | r2 := domain.CIRunner{ID: "r2", Name: "alpha", TokenHash: h2, RegisteredAt: created} | |
| 221 | + | _, err = ci.CreateRunner(ctx, r2, false) | |
| 222 | + | assert.ErrorIs(t, err, domain.ErrAlreadyExists, "names are case-insensitive") | |
| 223 | + | ||
| 224 | + | removed, err := ci.CreateRunner(ctx, r2, true) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | require.Len(t, removed, 1) | |
| 227 | + | assert.Equal(t, "r1", removed[0].ID) | |
| 228 | + | _, err = ci.FindRunnerByToken(ctx, h1) | |
| 229 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "the replaced runner's token stops working") | |
| 230 | + | got, err := ci.FindRunnerByToken(ctx, h2) | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | assert.Equal(t, "r2", got.ID) | |
| 233 | + | ||
| 234 | + | list, err := ci.ListRunners(ctx) | |
| 235 | + | require.NoError(t, err) | |
| 236 | + | assert.Len(t, list, 1) | |
| 237 | + | ||
| 238 | + | _, err = ci.RemoveRunner(ctx, "r2") | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | _, err = ci.RemoveRunner(ctx, "r2") | |
| 241 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 242 | + | _, err = ci.FindRunnerByToken(ctx, h2) | |
| 243 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 244 | + | // The name is free again. | |
| 245 | + | _, err = ci.CreateRunner(ctx, domain.CIRunner{ID: "r3", Name: "alpha", TokenHash: domain.HashTokenSecret("ermiterun_3")}, false) | |
| 246 | + | assert.NoError(t, err) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestCISweepRemovesOldCompletedRunsOnly(t *testing.T) { | |
| 250 | + | ctx := context.Background() | |
| 251 | + | _, ci, n := ciRepo(t) | |
| 252 | + | oldRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 253 | + | require.NoError(t, err) | |
| 254 | + | newRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | openRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 257 | + | require.NoError(t, err) | |
| 258 | + | ||
| 259 | + | for _, tc := range []struct { | |
| 260 | + | d ports.CIRunDraft | |
| 261 | + | at time.Time | |
| 262 | + | }{{oldRun, created}, {newRun, created.Add(100 * 24 * time.Hour)}} { | |
| 263 | + | run, _ := ci.GetRun(ctx, n, tc.d.Run.ID) | |
| 264 | + | run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, tc.at | |
| 265 | + | job, _ := ci.GetJob(ctx, run.ID, 1, tc.d.Jobs[0].ID) | |
| 266 | + | job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess | |
| 267 | + | _, err := ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}}) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | ids, err := ci.Sweep(ctx, created.Add(90*24*time.Hour)) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | assert.Equal(t, []int64{oldRun.Run.ID}, ids) | |
| 274 | + | ||
| 275 | + | _, err = ci.GetRun(ctx, n, oldRun.Run.ID) | |
| 276 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 277 | + | _, err = ci.GetRunSource(ctx, oldRun.Run.ID) | |
| 278 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 279 | + | jobs, _ := ci.ListJobs(ctx, oldRun.Run.ID, 1) | |
| 280 | + | assert.Empty(t, jobs) | |
| 281 | + | _, err = ci.GetRun(ctx, n, newRun.Run.ID) | |
| 282 | + | assert.NoError(t, err) | |
| 283 | + | _, err = ci.GetRun(ctx, n, openRun.Run.ID) | |
| 284 | + | assert.NoError(t, err, "an open run is never swept") | |
| 285 | + | ||
| 286 | + | // Numbers are never reused. | |
| 287 | + | next, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0)) | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | assert.Equal(t, int64(4), next.Run.Number) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestCIRepositoryDeletionRemovesEverythingAndRestartsNumbers(t *testing.T) { | |
| 293 | + | ctx := context.Background() | |
| 294 | + | s, ci, n := ciRepo(t) | |
| 295 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2)) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | _, err = ci.CreateRunner(ctx, domain.CIRunner{ID: "r1", Name: "a", TokenHash: domain.HashTokenSecret("x")}, false) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | ||
| 300 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 301 | + | ||
| 302 | + | _, err = ci.GetRun(ctx, n, d.Run.ID) | |
| 303 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 304 | + | _, err = ci.GetRunSource(ctx, d.Run.ID) | |
| 305 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 306 | + | jobs, _ := ci.ListJobs(ctx, d.Run.ID, 1) | |
| 307 | + | assert.Empty(t, jobs) | |
| 308 | + | checks, _, _ := ci.ListChecks(ctx, n, d.Run.Sha, 10) | |
| 309 | + | assert.Empty(t, checks) | |
| 310 | + | ready, _ := ci.ListReadyJobs(ctx, 10) | |
| 311 | + | assert.Empty(t, ready) | |
| 312 | + | open, _ := ci.ListOpenRuns(ctx) | |
| 313 | + | assert.Empty(t, open) | |
| 314 | + | runners, _ := ci.ListRunners(ctx) | |
| 315 | + | assert.Len(t, runners, 1, "runners do not belong to a repository") | |
| 316 | + | ||
| 317 | + | // A repository created again under the name starts its run numbers at 1, | |
| 318 | + | // while ids are never reused. | |
| 319 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 320 | + | again, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0)) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | assert.Equal(t, int64(1), again.Run.Number) | |
| 323 | + | assert.Equal(t, int64(2), again.Run.ID) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestCIStateSurvivesARestart(t *testing.T) { | |
| 327 | + | ctx := context.Background() | |
| 328 | + | dir := t.TempDir() | |
| 329 | + | s, err := kvstore.Open(ctx, dir) | |
| 330 | + | require.NoError(t, err) | |
| 331 | + | n := name(t, "proj") | |
| 332 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 333 | + | ci := s.CI() | |
| 334 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 335 | + | require.NoError(t, err) | |
| 336 | + | j := d.Jobs[0] | |
| 337 | + | hash := domain.HashTokenSecret("ermitejob_z") | |
| 338 | + | j.Phase, j.TokenHash, j.RunnerID, j.RunnerName = domain.CIPhaseRunning, hash, "r1", "alpha" | |
| 339 | + | j.Steps = []domain.CIStep{{Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, HasLines: true, LogStart: 0}} | |
| 340 | + | j.Outputs = map[string]string{"a": "b"} | |
| 341 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 342 | + | require.NoError(t, err) | |
| 343 | + | require.NoError(t, s.Close(ctx)) | |
| 344 | + | ||
| 345 | + | s2, err := kvstore.Open(ctx, dir) | |
| 346 | + | require.NoError(t, err) | |
| 347 | + | defer func() { _ = s2.Close(ctx) }() | |
| 348 | + | ci2 := s2.CI() | |
| 349 | + | got, err := ci2.FindJobByToken(ctx, hash, time.Time{}) | |
| 350 | + | require.NoError(t, err) | |
| 351 | + | assert.Equal(t, domain.CIPhaseRunning, got.Phase) | |
| 352 | + | assert.Equal(t, "alpha", got.RunnerName) | |
| 353 | + | assert.Equal(t, map[string]string{"a": "b"}, got.Outputs) | |
| 354 | + | require.Len(t, got.Steps, 1) | |
| 355 | + | assert.True(t, got.Steps[0].HasLines) | |
| 356 | + | active, _ := ci2.ListActiveJobs(ctx) | |
| 357 | + | assert.Len(t, active, 1) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func TestCICancelReasonsRoundTrip(t *testing.T) { | |
| 361 | + | ctx := context.Background() | |
| 362 | + | _, ci, n := ciRepo(t) | |
| 363 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} { | |
| 366 | + | j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created | |
| 369 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 370 | + | require.NoError(t, err) | |
| 371 | + | got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |
| 372 | + | require.NoError(t, err) | |
| 373 | + | assert.Equal(t, reason, got.CancelReason) | |
| 374 | + | } | |
| 375 | + | } | |
M internal/adapters/kvstore/mergerequests.go +2 -19
| @@ -71,26 +71,9 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||
| 71 | 71 | return keys | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | // guardRepo makes the batch depend on the repository: it requires the | |
| 75 | - | // repository key to be at the version just read and rewrites it, which gives it | |
| 76 | - | // a new version. Every write that adds a key under the repository's merge | |
| 77 | - | // requests (a request, a comment, a read position) goes through it, so that | |
| 78 | - | // | |
| 79 | - | // - such a write fails with domain.ErrNotFound once the repository is deleted, | |
| 80 | - | // and leaves no key that a repository created again under the name would | |
| 81 | - | // inherit; | |
| 82 | - | // - Store.Delete, which requires the version of the repository key it listed | |
| 83 | - | // the keys at, notices a write that got in after the listing and lists again. | |
| 84 | - | // | |
| 85 | - | // It returns domain.ErrNotFound when the repository does not exist. | |
| 74 | + | // guardRepo is guardRepoKey on the database of the merge requests; see there. | |
| 86 | 75 | func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error { | |
| 87 | - | item, ok := m.db.Get(ctx, repoKey(repo)) | |
| 88 | - | if !ok { | |
| 89 | - | return domain.ErrNotFound | |
| 90 | - | } | |
| 91 | - | b.RequireVersion(repoKey(repo), item.Version) | |
| 92 | - | b.Put(repoKey(repo), item.Value) | |
| 93 | - | return nil | |
| 76 | + | return guardRepoKey(ctx, m.db, b, repo) | |
| 94 | 77 | } | |
| 95 | 78 | ||
| 96 | 79 | // Create takes the next number of the repository from mrseq/<repo> and stores | |
M internal/adapters/kvstore/repos.go +27 -3
| @@ -90,6 +90,29 @@ func (s *Store) Update(ctx context.Context, repo domain.Repo) error { | |||
| 90 | 90 | return nil | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | + | // guardRepoKey makes the batch depend on the repository: it requires the | |
| 94 | + | // repository key to be at the version just read and rewrites it, which gives it | |
| 95 | + | // a new version. Every write that adds a key under the repository (a merge | |
| 96 | + | // request, a comment, a read position, a CI run, job or check) goes through it, | |
| 97 | + | // so that | |
| 98 | + | // | |
| 99 | + | // - such a write fails with domain.ErrNotFound once the repository is deleted, | |
| 100 | + | // and leaves no key that a repository created again under the name would | |
| 101 | + | // inherit; | |
| 102 | + | // - Store.Delete, which requires the version of the repository key it listed | |
| 103 | + | // the keys at, notices a write that got in after the listing and lists again. | |
| 104 | + | // | |
| 105 | + | // It returns domain.ErrNotFound when the repository does not exist. | |
| 106 | + | func guardRepoKey(ctx context.Context, db *kv.DB, b *kv.Batch, repo domain.RepoName) error { | |
| 107 | + | item, ok := db.Get(ctx, repoKey(repo)) | |
| 108 | + | if !ok { | |
| 109 | + | return domain.ErrNotFound | |
| 110 | + | } | |
| 111 | + | b.RequireVersion(repoKey(repo), item.Version) | |
| 112 | + | b.Put(repoKey(repo), item.Value) | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 93 | 116 | // deleteChunk is how many merge request keys one batch deletes before the | |
| 94 | 117 | // last one, which also removes the repository. | |
| 95 | 118 | const deleteChunk = 1000 | |
| @@ -99,7 +122,8 @@ const deleteChunk = 1000 | |||
| 99 | 122 | const deleteTries = 32 | |
| 100 | 123 | ||
| 101 | 124 | // Delete removes the repository and everything keyed to it: its merge | |
| 102 | - | // requests, their comments and read positions, and its number sequence. The | |
| 125 | + | // requests, their comments and read positions, and its number sequence, and | |
| 126 | + | // its CI runs, jobs, checks and run numbers. The | |
| 103 | 127 | // Git data is moved to the trash by the caller; the merge requests are not | |
| 104 | 128 | // kept anywhere, like the repository metadata, so a repository created again | |
| 105 | 129 | // under the same name starts clean, with its numbers starting at 1. A missing | |
| @@ -109,7 +133,7 @@ const deleteTries = 32 | |||
| 109 | 133 | // the last chunk: a delete that fails half way leaves the repository, which | |
| 110 | 134 | // the caller can delete again. The last batch requires the repository key to | |
| 111 | 135 | // be at the version the keys were listed at. Every write of a merge request key | |
| 112 | - | // rewrites the repository key (see MergeRequests.guardRepo), so a write that | |
| 136 | + | // rewrites the repository key (see guardRepoKey), so a write that | |
| 113 | 137 | // got in after the listing makes the batch fail and the keys are listed again, | |
| 114 | 138 | // and a write after the batch finds no repository: no key outlives the | |
| 115 | 139 | // repository to attach itself to a repository created again. | |
| @@ -119,7 +143,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |||
| 119 | 143 | for range deleteTries { | |
| 120 | 144 | snap := s.db.Snapshot(ctx) | |
| 121 | 145 | repoItem, repoOK := snap.Get(ctx, repoKey(name)) | |
| 122 | - | keys := mergeRequestKeys(ctx, snap, name) | |
| 146 | + | keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...) | |
| 123 | 147 | for len(keys) > deleteChunk { | |
| 124 | 148 | var b kv.Batch | |
| 125 | 149 | for _, k := range keys[:deleteChunk] { | |
M internal/adapters/rpc/api.go +12 -1
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 26 | 26 | ||
| 27 | 27 | // Features are the optional capabilities of the contract that this server | |
| 28 | 28 | // implements, as GetVersion lists them. | |
| 29 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 29 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 30 | 30 | ||
| 31 | 31 | // Config configures the API. | |
| 32 | 32 | type Config struct { | |
| @@ -46,6 +46,11 @@ type Config struct { | |||
| 46 | 46 | // budget of failures. Leave it unset rather than assigning a typed nil | |
| 47 | 47 | // pointer (a nil *T in the interface is not nil, and would be called). | |
| 48 | 48 | Limiter ports.FailureLimiter | |
| 49 | + | // Runner serves RunnerService and Runs serves RunService; without them the | |
| 50 | + | // CI services are not served. Leave them unset rather than assigning a | |
| 51 | + | // typed nil pointer. | |
| 52 | + | Runner ports.RunnerService | |
| 53 | + | Runs ports.RunService | |
| 49 | 54 | // Sessions are the owner's web sessions; nil serves no SessionService. | |
| 50 | 55 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | |
| 51 | 56 | // the interface is not nil, and the service would be served and called). | |
| @@ -81,6 +86,12 @@ func New(cfg Config) *API { | |||
| 81 | 86 | if cfg.Sessions != nil { | |
| 82 | 87 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | |
| 83 | 88 | } | |
| 89 | + | if cfg.Runner != nil { | |
| 90 | + | mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...)) | |
| 91 | + | } | |
| 92 | + | if cfg.Runs != nil { | |
| 93 | + | mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...)) | |
| 94 | + | } | |
| 84 | 95 | return &API{handler: mux} | |
| 85 | 96 | } | |
| 86 | 97 | ||
A internal/adapters/rpc/ci.go +308 -0
| @@ -0,0 +1,308 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "google.golang.org/protobuf/types/known/durationpb" | |
| 11 | + | ||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // secretKey carries the bearer secret of a runner or job call to the handler: | |
| 19 | + | // those secrets are not stored tokens, the use case checks them. | |
| 20 | + | type secretKey struct{} | |
| 21 | + | ||
| 22 | + | func secretFrom(ctx context.Context) string { | |
| 23 | + | s, _ := ctx.Value(secretKey{}).(string) | |
| 24 | + | return s | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // runnerSecretProcedures are the calls whose bearer secret is a runner token | |
| 28 | + | // or a job token: the interceptor does not authenticate them as stored tokens. | |
| 29 | + | var runnerSecretProcedures = map[string]bool{ | |
| 30 | + | ermiteconnect.RunnerServiceFetchJobProcedure: true, | |
| 31 | + | ermiteconnect.RunnerServiceUpdateJobProcedure: true, | |
| 32 | + | ermiteconnect.RunnerServiceAppendLogProcedure: true, | |
| 33 | + | ermiteconnect.RunnerServiceHeartbeatProcedure: true, | |
| 34 | + | ermiteconnect.RunnerServiceWatchJobProcedure: true, | |
| 35 | + | ermiteconnect.RunnerServiceCompleteJobProcedure: true, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ciError maps an error of the CI use cases. A call on a job that ended gets | |
| 39 | + | // failed_precondition with a JobEnded detail, as runner.proto says. | |
| 40 | + | func ciError(ctx context.Context, err error) error { | |
| 41 | + | if ended, ok := domain.IsJobEnded(err); ok { | |
| 42 | + | ce := connect.NewError(connect.CodeFailedPrecondition, errors.New("the job has ended")) | |
| 43 | + | if d, derr := connect.NewErrorDetail(jobEndedToPB(ended)); derr == nil { | |
| 44 | + | ce.AddDetail(d) | |
| 45 | + | } | |
| 46 | + | return ce | |
| 47 | + | } | |
| 48 | + | if errors.Is(err, domain.ErrOutOfRange) { | |
| 49 | + | return connect.NewError(connect.CodeOutOfRange, err) | |
| 50 | + | } | |
| 51 | + | return connectError(ctx, err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // runnerService implements ermiteconnect.RunnerServiceHandler. | |
| 55 | + | type runnerService struct { | |
| 56 | + | runners ports.RunnerService | |
| 57 | + | limiter ports.FailureLimiter | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // failed counts a call that was refused for its credential against the client | |
| 61 | + | // address, like every other authentication failure, and maps the error. | |
| 62 | + | func (s *runnerService) failed(ctx context.Context, err error) error { | |
| 63 | + | if errors.Is(err, domain.ErrUnauthenticated) && s.limiter != nil { | |
| 64 | + | if addr := addrFromContext(ctx); addr != "" { | |
| 65 | + | s.limiter.Fail(ctx, addr) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return ciError(ctx, err) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request[pb.RegisterRunnerRequest]) (*connect.Response[pb.RegisterRunnerResponse], error) { | |
| 72 | + | iso, err := isolationFromPB(req.Msg.GetIsolation()) | |
| 73 | + | if err != nil { | |
| 74 | + | return nil, ciError(ctx, err) | |
| 75 | + | } | |
| 76 | + | out, err := s.runners.Register(ctx, caller(ctx), ports.RegisterRunnerParams{ | |
| 77 | + | Name: req.Msg.GetName(), Labels: req.Msg.GetLabels(), Version: req.Msg.GetVersion(), Isolation: iso, | |
| 78 | + | Arch: req.Msg.GetArch(), OS: req.Msg.GetOs(), Replace: req.Msg.GetReplace(), | |
| 79 | + | }) | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, ciError(ctx, err) | |
| 82 | + | } | |
| 83 | + | return connect.NewResponse(&pb.RegisterRunnerResponse{RunnerId: out.Runner.ID, RunnerToken: out.Token}), nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) { | |
| 87 | + | res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()}) | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, s.failed(ctx, err) | |
| 90 | + | } | |
| 91 | + | out := &pb.FetchJobResponse{} | |
| 92 | + | if res.Job != nil { | |
| 93 | + | out.Job = runnerJobToPB(res.Job) | |
| 94 | + | } else if res.RetryAfter > 0 { | |
| 95 | + | out.RetryAfter = durationpb.New(res.RetryAfter) | |
| 96 | + | } | |
| 97 | + | return connect.NewResponse(out), nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.UpdateJobRequest]) (*connect.Response[pb.UpdateJobResponse], error) { | |
| 101 | + | steps, err := stepUpdatesFromPB(req.Msg.GetSteps()) | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, s.failed(ctx, err) | |
| 104 | + | } | |
| 105 | + | if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil { | |
| 106 | + | return nil, s.failed(ctx, err) | |
| 107 | + | } | |
| 108 | + | return connect.NewResponse(&pb.UpdateJobResponse{}), nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (s *runnerService) AppendLog(ctx context.Context, req *connect.Request[pb.AppendLogRequest]) (*connect.Response[pb.AppendLogResponse], error) { | |
| 112 | + | lines := make([]domain.CILogLine, 0, len(req.Msg.GetLines())) | |
| 113 | + | for _, l := range req.Msg.GetLines() { | |
| 114 | + | lines = append(lines, domain.CILogLine{Time: goTime(l.GetTime()), Text: l.GetText(), Continued: l.GetContinued()}) | |
| 115 | + | } | |
| 116 | + | next, err := s.runners.AppendLog(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStep(), req.Msg.GetOffset(), lines) | |
| 117 | + | if err != nil { | |
| 118 | + | var gap *ports.ErrCILogGap | |
| 119 | + | if errors.As(err, &gap) { | |
| 120 | + | return nil, connect.NewError(connect.CodeOutOfRange, err) | |
| 121 | + | } | |
| 122 | + | return nil, s.failed(ctx, err) | |
| 123 | + | } | |
| 124 | + | return connect.NewResponse(&pb.AppendLogResponse{NextOffset: next}), nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.HeartbeatRequest]) (*connect.Response[pb.HeartbeatResponse], error) { | |
| 128 | + | if err := s.runners.Heartbeat(ctx, secretFrom(ctx), req.Msg.GetJobId()); err != nil { | |
| 129 | + | return nil, s.failed(ctx, err) | |
| 130 | + | } | |
| 131 | + | return connect.NewResponse(&pb.HeartbeatResponse{}), nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error { | |
| 135 | + | // A client's call returns when the response begins: send the headers now, | |
| 136 | + | // not with the first message, which may be 30 seconds away. Connect | |
| 137 | + | // flushes them for a nil message; a failure shows on the first real send. | |
| 138 | + | ready := func() { _ = stream.Send(nil) } | |
| 139 | + | err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), ready, func(sig ports.JobSignal) error { | |
| 140 | + | switch { | |
| 141 | + | case sig.Cancel != nil: | |
| 142 | + | return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{ | |
| 143 | + | Reason: cancelReasonToPB(sig.Cancel.Reason), Message: sig.Cancel.Message, RequestedAt: timestamp(sig.Cancel.RequestedAt), | |
| 144 | + | }}}) | |
| 145 | + | case sig.Ended != nil: | |
| 146 | + | return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Ended{Ended: jobEndedToPB(sig.Ended)}}) | |
| 147 | + | } | |
| 148 | + | return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Keepalive{Keepalive: &pb.Keepalive{}}}) | |
| 149 | + | }) | |
| 150 | + | return s.failed(ctx, err) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb.CompleteJobRequest]) (*connect.Response[pb.CompleteJobResponse], error) { | |
| 154 | + | concl, err := conclusionFromPB(req.Msg.GetConclusion()) | |
| 155 | + | if err != nil { | |
| 156 | + | return nil, s.failed(ctx, err) | |
| 157 | + | } | |
| 158 | + | steps, err := stepUpdatesFromPB(req.Msg.GetSteps()) | |
| 159 | + | if err != nil { | |
| 160 | + | return nil, s.failed(ctx, err) | |
| 161 | + | } | |
| 162 | + | res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{ | |
| 163 | + | JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(), | |
| 164 | + | RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps, | |
| 165 | + | }) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, s.failed(ctx, err) | |
| 168 | + | } | |
| 169 | + | return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // runService implements ermiteconnect.RunServiceHandler. | |
| 173 | + | type runService struct{ runs ports.RunService } | |
| 174 | + | ||
| 175 | + | func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) } | |
| 176 | + | ||
| 177 | + | func (s *runService) ListRuns(ctx context.Context, req *connect.Request[pb.ListRunsRequest]) (*connect.Response[pb.ListRunsResponse], error) { | |
| 178 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 179 | + | if err != nil { | |
| 180 | + | return nil, ciError(ctx, err) | |
| 181 | + | } | |
| 182 | + | if _, err := statusFromPB(req.Msg.GetStatus()); err != nil { | |
| 183 | + | return nil, ciError(ctx, err) | |
| 184 | + | } | |
| 185 | + | page, err := s.runs.ListRuns(ctx, caller(ctx), name, ports.ListRunsParams{ | |
| 186 | + | PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | |
| 187 | + | Workflow: req.Msg.GetWorkflow(), Branch: req.Msg.GetBranch(), Status: statusOrZero(req.Msg.GetStatus()), | |
| 188 | + | }) | |
| 189 | + | if err != nil { | |
| 190 | + | return nil, ciError(ctx, err) | |
| 191 | + | } | |
| 192 | + | out := &pb.ListRunsResponse{NextPageToken: page.NextPageToken, Runs: make([]*pb.Run, 0, len(page.Runs))} | |
| 193 | + | for _, r := range page.Runs { | |
| 194 | + | out.Runs = append(out.Runs, runToPB(r)) | |
| 195 | + | } | |
| 196 | + | return connect.NewResponse(out), nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func statusOrZero(s pb.CiStatus) domain.CIStatus { | |
| 200 | + | d, _ := statusFromPB(s) | |
| 201 | + | return d | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (s *runService) GetRun(ctx context.Context, req *connect.Request[pb.GetRunRequest]) (*connect.Response[pb.GetRunResponse], error) { | |
| 205 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, ciError(ctx, err) | |
| 208 | + | } | |
| 209 | + | d, err := s.runs.GetRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt()) | |
| 210 | + | if err != nil { | |
| 211 | + | return nil, ciError(ctx, err) | |
| 212 | + | } | |
| 213 | + | out := &pb.GetRunResponse{Run: runToPB(d.Run), LatestAttempt: d.LatestAttempt} | |
| 214 | + | for _, j := range d.Jobs { | |
| 215 | + | out.Jobs = append(out.Jobs, jobViewToPB(j)) | |
| 216 | + | } | |
| 217 | + | return connect.NewResponse(out), nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (s *runService) GetJob(ctx context.Context, req *connect.Request[pb.GetJobRequest]) (*connect.Response[pb.GetJobResponse], error) { | |
| 221 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 222 | + | if err != nil { | |
| 223 | + | return nil, ciError(ctx, err) | |
| 224 | + | } | |
| 225 | + | j, err := s.runs.GetJob(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt()) | |
| 226 | + | if err != nil { | |
| 227 | + | return nil, ciError(ctx, err) | |
| 228 | + | } | |
| 229 | + | return connect.NewResponse(&pb.GetJobResponse{Job: jobViewToPB(j)}), nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJobLogRequest], stream *connect.ServerStream[pb.GetJobLogResponse]) error { | |
| 233 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 234 | + | if err != nil { | |
| 235 | + | return ciError(ctx, err) | |
| 236 | + | } | |
| 237 | + | err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{ | |
| 238 | + | JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(), | |
| 239 | + | Client: clientFromContext(ctx), | |
| 240 | + | Ready: func() { _ = stream.Send(nil) }, | |
| 241 | + | }, func(b ports.LogBatch) error { | |
| 242 | + | out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))} | |
| 243 | + | for _, l := range b.Lines { | |
| 244 | + | out.Lines = append(out.Lines, logLineToPB(l)) | |
| 245 | + | } | |
| 246 | + | return stream.Send(out) | |
| 247 | + | }) | |
| 248 | + | return ciError(ctx, err) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (s *runService) CancelRun(ctx context.Context, req *connect.Request[pb.CancelRunRequest]) (*connect.Response[pb.CancelRunResponse], error) { | |
| 252 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 253 | + | if err != nil { | |
| 254 | + | return nil, ciError(ctx, err) | |
| 255 | + | } | |
| 256 | + | run, err := s.runs.CancelRun(ctx, caller(ctx), name, req.Msg.GetRunId()) | |
| 257 | + | if err != nil { | |
| 258 | + | return nil, ciError(ctx, err) | |
| 259 | + | } | |
| 260 | + | return connect.NewResponse(&pb.CancelRunResponse{Run: runToPB(run)}), nil | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (s *runService) RerunRun(ctx context.Context, req *connect.Request[pb.RerunRunRequest]) (*connect.Response[pb.RerunRunResponse], error) { | |
| 264 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 265 | + | if err != nil { | |
| 266 | + | return nil, ciError(ctx, err) | |
| 267 | + | } | |
| 268 | + | run, err := s.runs.RerunRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetFailedOnly()) | |
| 269 | + | if err != nil { | |
| 270 | + | return nil, ciError(ctx, err) | |
| 271 | + | } | |
| 272 | + | return connect.NewResponse(&pb.RerunRunResponse{Run: runToPB(run)}), nil | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) { | |
| 276 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 277 | + | if err != nil { | |
| 278 | + | return nil, ciError(ctx, err) | |
| 279 | + | } | |
| 280 | + | res, err := s.runs.ListChecks(ctx, caller(ctx), name, req.Msg.GetSha()) | |
| 281 | + | if err != nil { | |
| 282 | + | return nil, ciError(ctx, err) | |
| 283 | + | } | |
| 284 | + | out := &pb.ListChecksResponse{Truncated: res.Truncated} | |
| 285 | + | for _, c := range res.Checks { | |
| 286 | + | out.Checks = append(out.Checks, checkToPB(c.CICheck)) | |
| 287 | + | } | |
| 288 | + | return connect.NewResponse(out), nil | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (s *runService) ListRunners(ctx context.Context, req *connect.Request[pb.ListRunnersRequest]) (*connect.Response[pb.ListRunnersResponse], error) { | |
| 292 | + | page, err := s.runs.ListRunners(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken()) | |
| 293 | + | if err != nil { | |
| 294 | + | return nil, ciError(ctx, err) | |
| 295 | + | } | |
| 296 | + | out := &pb.ListRunnersResponse{NextPageToken: page.NextPageToken, Runners: make([]*pb.Runner, 0, len(page.Runners))} | |
| 297 | + | for _, r := range page.Runners { | |
| 298 | + | out.Runners = append(out.Runners, runnerToPB(r)) | |
| 299 | + | } | |
| 300 | + | return connect.NewResponse(out), nil | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.RemoveRunnerRequest]) (*connect.Response[pb.RemoveRunnerResponse], error) { | |
| 304 | + | if err := s.runs.RemoveRunner(ctx, caller(ctx), req.Msg.GetRunnerId()); err != nil { | |
| 305 | + | return nil, ciError(ctx, err) | |
| 306 | + | } | |
| 307 | + | return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil | |
| 308 | + | } | |
A internal/adapters/rpc/ci_convert.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func statusToPB(s domain.CIStatus) pb.CiStatus { | |
| 14 | + | switch s { | |
| 15 | + | case domain.CIQueued: | |
| 16 | + | return pb.CiStatus_CI_STATUS_QUEUED | |
| 17 | + | case domain.CIInProgress: | |
| 18 | + | return pb.CiStatus_CI_STATUS_IN_PROGRESS | |
| 19 | + | case domain.CICompleted: | |
| 20 | + | return pb.CiStatus_CI_STATUS_COMPLETED | |
| 21 | + | } | |
| 22 | + | return pb.CiStatus_CI_STATUS_UNSPECIFIED | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func statusFromPB(s pb.CiStatus) (domain.CIStatus, error) { | |
| 26 | + | switch s { | |
| 27 | + | case pb.CiStatus_CI_STATUS_UNSPECIFIED: | |
| 28 | + | return "", nil | |
| 29 | + | case pb.CiStatus_CI_STATUS_QUEUED: | |
| 30 | + | return domain.CIQueued, nil | |
| 31 | + | case pb.CiStatus_CI_STATUS_IN_PROGRESS: | |
| 32 | + | return domain.CIInProgress, nil | |
| 33 | + | case pb.CiStatus_CI_STATUS_COMPLETED: | |
| 34 | + | return domain.CICompleted, nil | |
| 35 | + | } | |
| 36 | + | return "", fmt.Errorf("%w: status %v", domain.ErrInvalid, s) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | var conclusionPairs = []struct { | |
| 40 | + | d domain.CIConclusion | |
| 41 | + | p pb.CiConclusion | |
| 42 | + | }{ | |
| 43 | + | {domain.CISuccess, pb.CiConclusion_CI_CONCLUSION_SUCCESS}, | |
| 44 | + | {domain.CIFailure, pb.CiConclusion_CI_CONCLUSION_FAILURE}, | |
| 45 | + | {domain.CICancelled, pb.CiConclusion_CI_CONCLUSION_CANCELLED}, | |
| 46 | + | {domain.CISkipped, pb.CiConclusion_CI_CONCLUSION_SKIPPED}, | |
| 47 | + | {domain.CITimedOut, pb.CiConclusion_CI_CONCLUSION_TIMED_OUT}, | |
| 48 | + | {domain.CINeutral, pb.CiConclusion_CI_CONCLUSION_NEUTRAL}, | |
| 49 | + | {domain.CIActionRequired, pb.CiConclusion_CI_CONCLUSION_ACTION_REQUIRED}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func conclusionToPB(c domain.CIConclusion) pb.CiConclusion { | |
| 53 | + | for _, e := range conclusionPairs { | |
| 54 | + | if e.d == c { | |
| 55 | + | return e.p | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func conclusionFromPB(c pb.CiConclusion) (domain.CIConclusion, error) { | |
| 62 | + | if c == pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED { | |
| 63 | + | return domain.CINoConclusion, nil | |
| 64 | + | } | |
| 65 | + | for _, e := range conclusionPairs { | |
| 66 | + | if e.p == c { | |
| 67 | + | return e.d, nil | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return "", fmt.Errorf("%w: conclusion %v", domain.ErrInvalid, c) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func isolationToPB(i domain.CIIsolation) pb.RunnerIsolation { | |
| 74 | + | switch i { | |
| 75 | + | case domain.CIIsolationVM: | |
| 76 | + | return pb.RunnerIsolation_RUNNER_ISOLATION_VM | |
| 77 | + | case domain.CIIsolationContainerSysbox: | |
| 78 | + | return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX | |
| 79 | + | case domain.CIIsolationContainerPrivileged: | |
| 80 | + | return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED | |
| 81 | + | } | |
| 82 | + | return pb.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func isolationFromPB(i pb.RunnerIsolation) (domain.CIIsolation, error) { | |
| 86 | + | switch i { | |
| 87 | + | case pb.RunnerIsolation_RUNNER_ISOLATION_VM: | |
| 88 | + | return domain.CIIsolationVM, nil | |
| 89 | + | case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX: | |
| 90 | + | return domain.CIIsolationContainerSysbox, nil | |
| 91 | + | case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED: | |
| 92 | + | return domain.CIIsolationContainerPrivileged, nil | |
| 93 | + | } | |
| 94 | + | return "", fmt.Errorf("%w: isolation is required", domain.ErrInvalid) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func stepStateToPB(s domain.CIStepState) pb.StepState { | |
| 98 | + | switch s { | |
| 99 | + | case domain.CIStepPending: | |
| 100 | + | return pb.StepState_STEP_STATE_PENDING | |
| 101 | + | case domain.CIStepInProgress: | |
| 102 | + | return pb.StepState_STEP_STATE_IN_PROGRESS | |
| 103 | + | case domain.CIStepCompleted: | |
| 104 | + | return pb.StepState_STEP_STATE_COMPLETED | |
| 105 | + | } | |
| 106 | + | return pb.StepState_STEP_STATE_UNSPECIFIED | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func stepStateFromPB(s pb.StepState) domain.CIStepState { | |
| 110 | + | switch s { | |
| 111 | + | case pb.StepState_STEP_STATE_PENDING: | |
| 112 | + | return domain.CIStepPending | |
| 113 | + | case pb.StepState_STEP_STATE_IN_PROGRESS: | |
| 114 | + | return domain.CIStepInProgress | |
| 115 | + | case pb.StepState_STEP_STATE_COMPLETED: | |
| 116 | + | return domain.CIStepCompleted | |
| 117 | + | } | |
| 118 | + | return "" | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason { | |
| 122 | + | switch r { | |
| 123 | + | case domain.CICancelUser: | |
| 124 | + | return pb.CancelReason_CANCEL_REASON_USER | |
| 125 | + | case domain.CICancelFailFast: | |
| 126 | + | return pb.CancelReason_CANCEL_REASON_FAIL_FAST | |
| 127 | + | case domain.CICancelTimeout: | |
| 128 | + | return pb.CancelReason_CANCEL_REASON_TIMEOUT | |
| 129 | + | } | |
| 130 | + | return pb.CancelReason_CANCEL_REASON_UNSPECIFIED | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func runToPB(r domain.CIRun) *pb.Run { | |
| 134 | + | return &pb.Run{ | |
| 135 | + | Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt, | |
| 136 | + | WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event, | |
| 137 | + | Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion), | |
| 138 | + | Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt), | |
| 139 | + | Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63 | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step { | |
| 144 | + | ranges := domain.StepRanges(steps, lines) | |
| 145 | + | out := make([]*pb.Step, 0, len(steps)) | |
| 146 | + | for i, s := range steps { | |
| 147 | + | out = append(out, &pb.Step{ | |
| 148 | + | Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion), | |
| 149 | + | StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt), | |
| 150 | + | LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | return out | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func jobToPB(j domain.CIJob, lines int64) *pb.RunJob { | |
| 157 | + | return &pb.RunJob{ | |
| 158 | + | Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, Repo: j.Repo.String(), Key: j.Key, Name: j.Name, | |
| 159 | + | Status: statusToPB(j.Status()), Conclusion: conclusionToPB(j.Conclusion), Error: j.Error, | |
| 160 | + | Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, Isolation: isolationToPB(j.Isolation), | |
| 161 | + | Steps: stepsToPB(j.Steps, lines), CreatedAt: timestamp(j.CreatedAt), StartedAt: timestamp(j.StartedAt), | |
| 162 | + | CompletedAt: timestamp(j.CompletedAt), | |
| 163 | + | Version: int64(j.Version), //nolint:gosec // a store sequence, far below 2^63 | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func jobViewToPB(v ports.JobView) *pb.RunJob { | |
| 168 | + | out := jobToPB(v.CIJob, v.Lines) | |
| 169 | + | if v.RunnerGone { | |
| 170 | + | out.RunnerId = "" | |
| 171 | + | } | |
| 172 | + | return out | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func checkToPB(c domain.CICheck) *pb.Check { | |
| 176 | + | return &pb.Check{ | |
| 177 | + | Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: statusToPB(c.Status), | |
| 178 | + | Conclusion: conclusionToPB(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt, | |
| 179 | + | WorkflowName: c.WorkflowName, StartedAt: timestamp(c.StartedAt), CompletedAt: timestamp(c.CompletedAt), | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func runnerToPB(r ports.RunnerView) *pb.Runner { | |
| 184 | + | return &pb.Runner{ | |
| 185 | + | Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version, | |
| 186 | + | Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy, | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func logLineToPB(l ports.JobLogLine) *pb.JobLogLine { | |
| 191 | + | return &pb.JobLogLine{Step: l.Step, Time: timestamp(l.Time), Text: l.Text, Continued: l.Continued} | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func runnerJobToPB(j *ports.RunnerJob) *pb.Job { | |
| 195 | + | return &pb.Job{ | |
| 196 | + | JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token, | |
| 197 | + | WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON, | |
| 198 | + | Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes, | |
| 199 | + | Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | |
| 204 | + | out := make([]ports.StepUpdate, 0, len(steps)) | |
| 205 | + | for _, s := range steps { | |
| 206 | + | concl, err := conclusionFromPB(s.GetConclusion()) | |
| 207 | + | if err != nil { | |
| 208 | + | return nil, err | |
| 209 | + | } | |
| 210 | + | out = append(out, ports.StepUpdate{ | |
| 211 | + | Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl, | |
| 212 | + | StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()), | |
| 213 | + | }) | |
| 214 | + | } | |
| 215 | + | return out, nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func jobEndedToPB(e *domain.JobEndedError) *pb.JobEnded { | |
| 219 | + | return &pb.JobEnded{Conclusion: conclusionToPB(e.Conclusion), Error: e.Message} | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated { | |
| 223 | + | return &pb.Event_RunUpdated{RunUpdated: &pb.RunUpdated{Run: runToPB(ev.Run)}} | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated { | |
| 227 | + | return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}} | |
| 228 | + | } | |
A internal/adapters/rpc/ci_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // RunService.GetJob: the job with its steps and the range of their lines, the | |
| 22 | + | // states and conclusions of the contract, and the codes of the refusals. | |
| 23 | + | func TestRunServiceGetJob(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 25 | + | at := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC) | |
| 26 | + | job := ports.JobView{CIJob: domain.CIJob{ | |
| 27 | + | ID: "7-0-0", RunID: 7, Attempt: 2, RunAttempt: 2, Repo: mustRepoName(t, "demo"), Key: "build", Name: "build (linux)", | |
| 28 | + | Phase: domain.CIPhaseEnded, Conclusion: domain.CIFailure, Error: "boom", Labels: []string{"self-hosted"}, Needs: []string{"lint"}, | |
| 29 | + | RunnerName: "alpha", Isolation: domain.CIIsolationVM, CreatedAt: at, StartedAt: at, CompletedAt: at.Add(time.Minute), Version: 9, | |
| 30 | + | Steps: []domain.CIStep{ | |
| 31 | + | {Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 0}, | |
| 32 | + | {Number: 2, Name: "Run make", State: domain.CIStepCompleted, Conclusion: domain.CIFailure, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 3}, | |
| 33 | + | }, | |
| 34 | + | }, Lines: 8} | |
| 35 | + | ||
| 36 | + | runs := mocks.NewRunService(t) | |
| 37 | + | runs.EXPECT().GetJob(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), "7-0-0", int32(2)).Return(job, nil).Once() | |
| 38 | + | resp, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: "demo", JobId: "7-0-0", Attempt: 2})) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | got := resp.Msg.GetJob() | |
| 41 | + | assert.Equal(t, "7-0-0", got.GetId()) | |
| 42 | + | assert.EqualValues(t, 2, got.GetAttempt()) | |
| 43 | + | assert.Equal(t, "build (linux)", got.GetName()) | |
| 44 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, got.GetStatus()) | |
| 45 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, got.GetConclusion()) | |
| 46 | + | assert.Equal(t, "boom", got.GetError()) | |
| 47 | + | assert.Equal(t, pb.RunnerIsolation_RUNNER_ISOLATION_VM, got.GetIsolation()) | |
| 48 | + | assert.Equal(t, []string{"lint"}, got.GetNeeds()) | |
| 49 | + | assert.EqualValues(t, 9, got.GetVersion()) | |
| 50 | + | require.Len(t, got.GetSteps(), 2) | |
| 51 | + | assert.Equal(t, []int64{0, 3}, []int64{got.GetSteps()[0].GetLogStart(), got.GetSteps()[0].GetLogEnd()}, "a step ends where the next begins") | |
| 52 | + | assert.Equal(t, []int64{3, 8}, []int64{got.GetSteps()[1].GetLogStart(), got.GetSteps()[1].GetLogEnd()}, "the last ends at the end of the log") | |
| 53 | + | assert.Equal(t, pb.StepState_STEP_STATE_COMPLETED, got.GetSteps()[1].GetState()) | |
| 54 | + | ||
| 55 | + | for name, tc := range map[string]struct { | |
| 56 | + | repo string | |
| 57 | + | err error | |
| 58 | + | code connect.Code | |
| 59 | + | }{ | |
| 60 | + | "invalid repo": {"../x", nil, connect.CodeInvalidArgument}, | |
| 61 | + | "unknown job": {"demo", domain.ErrNotFound, connect.CodeNotFound}, | |
| 62 | + | "not readable": {"demo", domain.ErrNotFound, connect.CodeNotFound}, | |
| 63 | + | "store failure": {"demo", assert.AnError, connect.CodeInternal}, | |
| 64 | + | } { | |
| 65 | + | t.Run(name, func(t *testing.T) { | |
| 66 | + | t.Parallel() | |
| 67 | + | runs := mocks.NewRunService(t) | |
| 68 | + | if tc.err != nil { | |
| 69 | + | runs.EXPECT().GetJob(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.JobView{}, tc.err).Once() | |
| 70 | + | } | |
| 71 | + | _, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: tc.repo, JobId: "7-0-0"})) | |
| 72 | + | requireCode(t, tc.code, err) | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // The end of a job reaches the runner as failed_precondition with the JobEnded | |
| 78 | + | // detail; a bad credential counts against the client address. | |
| 79 | + | func TestRunnerServiceErrors(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | ended := &domain.JobEndedError{Conclusion: domain.CICancelled, Message: "stopped"} | |
| 82 | + | err := ciError(ownerCtx(), ended) | |
| 83 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 84 | + | var ce *connect.Error | |
| 85 | + | require.ErrorAs(t, err, &ce) | |
| 86 | + | require.Len(t, ce.Details(), 1) | |
| 87 | + | msg, derr := ce.Details()[0].Value() | |
| 88 | + | require.NoError(t, derr) | |
| 89 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, msg.(*pb.JobEnded).GetConclusion()) | |
| 90 | + | assert.Equal(t, "stopped", msg.(*pb.JobEnded).GetError()) | |
| 91 | + | ||
| 92 | + | requireCode(t, connect.CodeOutOfRange, ciError(ownerCtx(), &ports.ErrCILogGap{Count: 4})) | |
| 93 | + | ||
| 94 | + | limiter := mocks.NewFailureLimiter(t) | |
| 95 | + | limiter.EXPECT().Fail(mock.Anything, "client-key").Return().Once() | |
| 96 | + | s := &runnerService{runners: mocks.NewRunnerService(t), limiter: limiter} | |
| 97 | + | ctx := t.Context() | |
| 98 | + | ctx = context.WithValue(ctx, addrKey{}, "client-key") | |
| 99 | + | requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated)) | |
| 100 | + | requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail | |
| 101 | + | } | |
M internal/adapters/rpc/convert.go +6 -0
| @@ -391,6 +391,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 391 | 391 | case domain.CommentAdded: | |
| 392 | 392 | out.Time = timestamp(ev.At) | |
| 393 | 393 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 394 | + | case domain.RunUpdated: | |
| 395 | + | out.Time = timestamp(ev.At) | |
| 396 | + | out.Kind = runEventToPB(ev) | |
| 397 | + | case domain.JobUpdated: | |
| 398 | + | out.Time = timestamp(ev.At) | |
| 399 | + | out.Kind = jobEventToPB(ev) | |
| 394 | 400 | default: | |
| 395 | 401 | return nil | |
| 396 | 402 | } | |
M internal/adapters/rpc/interceptor.go +16 -0
| @@ -69,6 +69,13 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri | |||
| 69 | 69 | if !present { | |
| 70 | 70 | return ctx, nil | |
| 71 | 71 | } | |
| 72 | + | if runnerSecretProcedures[procedure] { | |
| 73 | + | // A runner or job token: not a stored token. The use case checks it | |
| 74 | + | // and answers unauthenticated; runnerService.failed counts the | |
| 75 | + | // failures against the client address. | |
| 76 | + | ctx = context.WithValue(ctx, addrKey{}, addr) | |
| 77 | + | return context.WithValue(ctx, secretKey{}, secret), nil | |
| 78 | + | } | |
| 72 | 79 | p, err := i.auth.Authenticate(ctx, secret) | |
| 73 | 80 | switch { | |
| 74 | 81 | case errors.Is(err, domain.ErrUnauthenticated): | |
| @@ -83,6 +90,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri | |||
| 83 | 90 | ||
| 84 | 91 | type clientKey struct{} | |
| 85 | 92 | ||
| 93 | + | // addrKey carries the normalized client address of a call whose credential the | |
| 94 | + | // use case checks. | |
| 95 | + | type addrKey struct{} | |
| 96 | + | ||
| 97 | + | func addrFromContext(ctx context.Context) string { | |
| 98 | + | a, _ := ctx.Value(addrKey{}).(string) | |
| 99 | + | return a | |
| 100 | + | } | |
| 101 | + | ||
| 86 | 102 | // clientFromContext returns the normalized address of the client of a call: | |
| 87 | 103 | // its peer, empty when there is none. | |
| 88 | 104 | func clientFromContext(ctx context.Context) string { | |
M internal/adapters/web/ASSETS.md +1 -0
| @@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 11 | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | |
| 12 | + | | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | | | |
| 12 | 13 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | |
| 13 | 14 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 14 | 15 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
A internal/adapters/web/actions.templ +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | package web | |
| 2 | + | ||
| 3 | + | // The CI pages: the runs of a repository, a run with its jobs, a job with its | |
| 4 | + | // steps and its log (actions.go). A state is always a word as well as a | |
| 5 | + | // symbol and a colour. | |
| 6 | + | ||
| 7 | + | // CheckIcon is the symbol of a state: a mark in the colour of its kind, with | |
| 8 | + | // the state as its text for a screen reader. | |
| 9 | + | templ CheckIcon(kind, state string) { | |
| 10 | + | <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span> | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | templ Actions(l Layout, p ActionsPage) { | |
| 14 | + | @Shell(l, nil) { | |
| 15 | + | if len(p.Runs) == 0 { | |
| 16 | + | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | |
| 17 | + | } else { | |
| 18 | + | <table class="list runs" aria-label="Runs"> | |
| 19 | + | <thead> | |
| 20 | + | <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr> | |
| 21 | + | </thead> | |
| 22 | + | <tbody id="run-rows" data-rows> | |
| 23 | + | for _, r := range p.Runs { | |
| 24 | + | @RunListRow(r) | |
| 25 | + | } | |
| 26 | + | </tbody> | |
| 27 | + | </table> | |
| 28 | + | } | |
| 29 | + | if p.More != "" { | |
| 30 | + | <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p> | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // RunListRow is a row of the list of runs; its id is the target of the live | |
| 36 | + | // updates. | |
| 37 | + | templ RunListRow(r RunRow) { | |
| 38 | + | <tr id={ "run-" + r.ID }> | |
| 39 | + | <td class="c-st">@CheckIcon(r.Kind, r.State)</td> | |
| 40 | + | <td class="c-name"> | |
| 41 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a> | |
| 42 | + | <span class="mute">{ r.Number }</span> | |
| 43 | + | if r.Attempt > 1 { | |
| 44 | + | <span class="pill">attempt { itoa(int(r.Attempt)) }</span> | |
| 45 | + | } | |
| 46 | + | </td> | |
| 47 | + | <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td> | |
| 48 | + | <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td> | |
| 49 | + | <td class="c-who dim">{ r.Actor }</td> | |
| 50 | + | <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td> | |
| 51 | + | <td class="c-when dim"> | |
| 52 | + | if r.WhenAt != "" { | |
| 53 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time> | |
| 54 | + | } | |
| 55 | + | </td> | |
| 56 | + | </tr> | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | templ RunDetail(l Layout, p RunPage) { | |
| 60 | + | @Shell(l, runRail(p)) { | |
| 61 | + | @LiveSlot("run-notice") | |
| 62 | + | <div id="run-body"> | |
| 63 | + | @RunBody(p) | |
| 64 | + | </div> | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // runBodyFragment is the body of the run page as a live fragment. | |
| 69 | + | templ runBodyFragment(p RunPage) { | |
| 70 | + | <div id="run-body"> | |
| 71 | + | @RunBody(p) | |
| 72 | + | </div> | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // RunBody is the jobs of a run, replaced by the live updates. | |
| 76 | + | templ RunBody(p RunPage) { | |
| 77 | + | <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2> | |
| 78 | + | if p.Error != "" { | |
| 79 | + | <pre class="run-err">{ p.Error }</pre> | |
| 80 | + | } | |
| 81 | + | <ul class="jobs"> | |
| 82 | + | for _, j := range p.Jobs { | |
| 83 | + | <li id={ "job-" + j.ID }> | |
| 84 | + | <a class="job-h" href={ templ.SafeURL(j.Href) }> | |
| 85 | + | @CheckIcon(j.Kind, j.State) | |
| 86 | + | <span class="j-name">{ j.Name }</span> | |
| 87 | + | <span class="dim">{ j.State }{ " " }{ j.Duration }</span> | |
| 88 | + | </a> | |
| 89 | + | if j.Runner != "" { | |
| 90 | + | <span class="mute">on { runnerText(j) }</span> | |
| 91 | + | } | |
| 92 | + | if len(j.Needs) > 0 { | |
| 93 | + | <span class="mute">needs { joinWords(j.Needs) }</span> | |
| 94 | + | } | |
| 95 | + | if j.Error != "" { | |
| 96 | + | <p class="job-err">{ j.Error }</p> | |
| 97 | + | } | |
| 98 | + | if len(j.Steps) > 0 { | |
| 99 | + | <ol class="steps"> | |
| 100 | + | for _, s := range j.Steps { | |
| 101 | + | <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li> | |
| 102 | + | } | |
| 103 | + | </ol> | |
| 104 | + | } | |
| 105 | + | </li> | |
| 106 | + | } | |
| 107 | + | </ul> | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | templ runRail(p RunPage) { | |
| 111 | + | <section aria-labelledby="r-run"> | |
| 112 | + | <h2 id="r-run">Run</h2> | |
| 113 | + | <dl class="kv"> | |
| 114 | + | <dt>result</dt> | |
| 115 | + | <dd>{ p.State }</dd> | |
| 116 | + | <dt>commit</dt> | |
| 117 | + | <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd> | |
| 118 | + | <dt>ref</dt> | |
| 119 | + | <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd> | |
| 120 | + | <dt>actor</dt> | |
| 121 | + | <dd>{ p.Actor }</dd> | |
| 122 | + | <dt>attempt</dt> | |
| 123 | + | <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd> | |
| 124 | + | </dl> | |
| 125 | + | if len(p.Attempts) > 0 { | |
| 126 | + | <ul class="attempts"> | |
| 127 | + | for _, a := range p.Attempts { | |
| 128 | + | <li> | |
| 129 | + | if a.Current { | |
| 130 | + | <span aria-current="page">{ a.Label }</span> | |
| 131 | + | } else { | |
| 132 | + | <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a> | |
| 133 | + | } | |
| 134 | + | </li> | |
| 135 | + | } | |
| 136 | + | </ul> | |
| 137 | + | } | |
| 138 | + | </section> | |
| 139 | + | if p.CancelAction != "" || p.RerunAction != "" { | |
| 140 | + | <section aria-labelledby="r-act" class="decide"> | |
| 141 | + | <h2 id="r-act">Actions</h2> | |
| 142 | + | <div class="d-btns"> | |
| 143 | + | if p.CancelAction != "" { | |
| 144 | + | <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form> | |
| 145 | + | } | |
| 146 | + | if p.RerunAction != "" { | |
| 147 | + | <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form> | |
| 148 | + | } | |
| 149 | + | if p.CanRerunFailed { | |
| 150 | + | <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form> | |
| 151 | + | } | |
| 152 | + | </div> | |
| 153 | + | </section> | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // JobHead is the title of the job page with its state and its error, replaced | |
| 158 | + | // by the live updates. total is the number of lines the log holds. | |
| 159 | + | templ JobHead(j JobRow, total int64) { | |
| 160 | + | <div id="job-head" data-total={ itoa64(total) }> | |
| 161 | + | <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2> | |
| 162 | + | if j.Error != "" { | |
| 163 | + | <p class="job-err">{ j.Error }</p> | |
| 164 | + | } | |
| 165 | + | </div> | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // JobSteps is the steps of the job in the rail, replaced by the live updates. | |
| 169 | + | templ JobSteps(steps []StepView) { | |
| 170 | + | <ol class="steps" id="job-steps"> | |
| 171 | + | for _, s := range steps { | |
| 172 | + | <li> | |
| 173 | + | @CheckIcon(s.Kind, s.State) | |
| 174 | + | if s.LogEnd > s.LogStart { | |
| 175 | + | <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a> | |
| 176 | + | } else { | |
| 177 | + | <span>{ s.Name }</span> | |
| 178 | + | } | |
| 179 | + | <span class="dim">{ s.Duration }</span> | |
| 180 | + | </li> | |
| 181 | + | } | |
| 182 | + | </ol> | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | templ Job(l Layout, p JobPage) { | |
| 186 | + | @Shell(l, jobRail(p)) { | |
| 187 | + | @LiveSlot("job-notice") | |
| 188 | + | @JobHead(p.JobRow, p.Total) | |
| 189 | + | <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }> | |
| 190 | + | if p.Note != "" { | |
| 191 | + | <p class="dim">{ p.Note }</p> | |
| 192 | + | } | |
| 193 | + | if p.Prev != "" { | |
| 194 | + | <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p> | |
| 195 | + | } | |
| 196 | + | <ol class="log" start={ itoa64(p.From + 1) }> | |
| 197 | + | for _, ln := range p.Lines { | |
| 198 | + | @LogLine(ln) | |
| 199 | + | } | |
| 200 | + | </ol> | |
| 201 | + | if p.Next != "" { | |
| 202 | + | <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p> | |
| 203 | + | } | |
| 204 | + | </div> | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // LogLine is a line of the log. Group and error markers of the runner show as | |
| 209 | + | // headings and errors, never as raw text. | |
| 210 | + | templ LogLine(ln LogLineView) { | |
| 211 | + | <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) } data-cont?={ ln.Continued }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li> | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | templ jobRail(p JobPage) { | |
| 215 | + | <section aria-labelledby="r-job"> | |
| 216 | + | <h2 id="r-job">Steps</h2> | |
| 217 | + | @JobSteps(p.Steps) | |
| 218 | + | </section> | |
| 219 | + | <section aria-labelledby="r-jobf"> | |
| 220 | + | <h2 id="r-jobf">Job</h2> | |
| 221 | + | <dl class="kv"> | |
| 222 | + | <dt>run</dt> | |
| 223 | + | <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd> | |
| 224 | + | if p.Runner != "" { | |
| 225 | + | <dt>runner</dt> | |
| 226 | + | <dd>{ p.Runner }</dd> | |
| 227 | + | } | |
| 228 | + | if p.Isolation != "" { | |
| 229 | + | <dt>isolation</dt> | |
| 230 | + | <dd>{ p.Isolation }</dd> | |
| 231 | + | } | |
| 232 | + | <dt>lines</dt> | |
| 233 | + | <dd>{ itoa64(p.Total) }</dd> | |
| 234 | + | </dl> | |
| 235 | + | <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p> | |
| 236 | + | </section> | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // CommitChecks is the combined state of the checks of a commit, as a mark | |
| 240 | + | // with a count, linking to the page that explains it. | |
| 241 | + | templ CommitChecks(c ChecksSummary) { | |
| 242 | + | if c.Total > 0 { | |
| 243 | + | <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }> | |
| 244 | + | @CheckIcon(c.Kind, c.State) | |
| 245 | + | <span class="mute">{ c.Label }</span> | |
| 246 | + | </a> | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // MRChecks is the checks section of the rail of a merge request. | |
| 251 | + | templ MRChecks(c ChecksSection) { | |
| 252 | + | <section aria-labelledby="r-checks" id="mr-checks"> | |
| 253 | + | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | |
| 254 | + | if len(c.Rows) == 0 { | |
| 255 | + | <p class="dim">no checks</p> | |
| 256 | + | } else { | |
| 257 | + | <ul class="checks"> | |
| 258 | + | for _, r := range c.Rows { | |
| 259 | + | <li> | |
| 260 | + | @CheckIcon(r.Kind, r.State) | |
| 261 | + | if r.Href != "" { | |
| 262 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 263 | + | } else { | |
| 264 | + | <span>{ r.Name }</span> | |
| 265 | + | } | |
| 266 | + | <span class="dim">{ r.State }</span> | |
| 267 | + | if r.Waiting { | |
| 268 | + | <span class="mute">waiting for a runner</span> | |
| 269 | + | } | |
| 270 | + | </li> | |
| 271 | + | } | |
| 272 | + | </ul> | |
| 273 | + | } | |
| 274 | + | if c.Note != "" { | |
| 275 | + | <p class="mute">{ c.Note }</p> | |
| 276 | + | } | |
| 277 | + | </section> | |
| 278 | + | } | |
A internal/adapters/web/actions_log.go +266 -0
| @@ -0,0 +1,266 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // LogLineView is a line of a job log, as shown. | |
| 21 | + | type LogLineView struct { | |
| 22 | + | N int64 | |
| 23 | + | Step int32 | |
| 24 | + | Text string | |
| 25 | + | Time string | |
| 26 | + | // Group marks "##[group]" and "##[endgroup]" lines and Error the | |
| 27 | + | // "##[error]" ones; the text is shown without the marker. | |
| 28 | + | Group, End, Error bool | |
| 29 | + | // Continued marks a piece of a line whose beginning is in an earlier batch | |
| 30 | + | // of the live feed: the page appends its text to the line before it. | |
| 31 | + | Continued bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // JobPage is one job with its steps and its log. | |
| 35 | + | type JobPage struct { | |
| 36 | + | JobRow | |
| 37 | + | RunHref string | |
| 38 | + | RunName string | |
| 39 | + | Run string | |
| 40 | + | Steps []StepView | |
| 41 | + | Lines []LogLineView | |
| 42 | + | // From is the first line shown; Next is the link to the lines after the | |
| 43 | + | // last shown, "" when it is the end; Follow says the log is still being | |
| 44 | + | // written, so the page follows it live. | |
| 45 | + | From int64 | |
| 46 | + | Next string | |
| 47 | + | Prev string | |
| 48 | + | Total int64 | |
| 49 | + | Follow bool | |
| 50 | + | RawHref string | |
| 51 | + | // StreamHref is the live feed of the lines (logStream). | |
| 52 | + | StreamHref string | |
| 53 | + | Note string | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // appendLogLine adds the line n of a job log to the lines shown. The markers | |
| 57 | + | // of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and | |
| 58 | + | // are not shown as text. A piece that continues a line too long to send whole | |
| 59 | + | // is joined to the line before it; one that opens the list (its line is in an | |
| 60 | + | // earlier batch) keeps Continued, and the page appends it to that line. | |
| 61 | + | func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView { | |
| 62 | + | text := l.Text | |
| 63 | + | v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued} | |
| 64 | + | switch { | |
| 65 | + | case strings.HasPrefix(text, "##[group]"): | |
| 66 | + | v.Group, text = true, strings.TrimPrefix(text, "##[group]") | |
| 67 | + | case strings.HasPrefix(text, "##[endgroup]"): | |
| 68 | + | v.End, text = true, strings.TrimPrefix(text, "##[endgroup]") | |
| 69 | + | case strings.HasPrefix(text, "##[error]"): | |
| 70 | + | v.Error, text = true, strings.TrimPrefix(text, "##[error]") | |
| 71 | + | } | |
| 72 | + | v.Text = visibleText(text) | |
| 73 | + | if l.Continued && len(out) > 0 { | |
| 74 | + | out[len(out)-1].Text += v.Text | |
| 75 | + | return out | |
| 76 | + | } | |
| 77 | + | return append(out, v) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // logClient is the key of the visitor for the limit of log streams per client. | |
| 81 | + | func (s *Server) logClient(r *http.Request) string { | |
| 82 | + | return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // readLog reads up to max lines from a line number, through the log stream of | |
| 86 | + | // the use case, and returns the line after the last read. | |
| 87 | + | func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) { | |
| 88 | + | if from > j.Lines || j.Lines == 0 { | |
| 89 | + | return nil, from, nil | |
| 90 | + | } | |
| 91 | + | var out []LogLineView | |
| 92 | + | next := from | |
| 93 | + | stop := errors.New("enough") | |
| 94 | + | err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error { | |
| 95 | + | for i, l := range b.Lines { | |
| 96 | + | if len(out) >= max && !l.Continued { | |
| 97 | + | return stop // the rest of a cut line still belongs to this page | |
| 98 | + | } | |
| 99 | + | out = appendLogLine(out, b.Offset+int64(i), l) | |
| 100 | + | next = b.Offset + int64(i) + 1 | |
| 101 | + | } | |
| 102 | + | return nil | |
| 103 | + | }) | |
| 104 | + | if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) { | |
| 105 | + | return nil, from, err | |
| 106 | + | } | |
| 107 | + | return out, next, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text, | |
| 111 | + | // one line per line with its time, joined where the runner cut a long line. | |
| 112 | + | func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) { | |
| 113 | + | ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) | |
| 114 | + | defer cancel() | |
| 115 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 116 | + | if err != nil { | |
| 117 | + | s.fail(ctx, w, r, err) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | id, err := parseRunPath(r) | |
| 121 | + | jobID := r.PathValue("job") | |
| 122 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |
| 123 | + | s.fail(ctx, w, r, errNotFound) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | var attempt int32 | |
| 127 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 128 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 129 | + | if perr != nil || n < 1 { | |
| 130 | + | s.fail(ctx, w, r, errNotFound) | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | attempt = int32(n) | |
| 134 | + | } | |
| 135 | + | // The job is read first: a job that is not there, or not readable, is a 404 | |
| 136 | + | // before any header of the log is sent. | |
| 137 | + | if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil { | |
| 138 | + | s.fail(ctx, w, r, err) | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | h := w.Header() | |
| 142 | + | h.Set("Content-Type", "text/plain; charset=utf-8") | |
| 143 | + | h.Set("X-Content-Type-Options", "nosniff") | |
| 144 | + | h.Set("Content-Security-Policy", "default-src 'none'; sandbox") | |
| 145 | + | h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID)) | |
| 146 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 147 | + | written := 0 | |
| 148 | + | open := false | |
| 149 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error { | |
| 150 | + | for _, l := range b.Lines { | |
| 151 | + | if written > rawLogMaxBytes { | |
| 152 | + | return errors.New("log too large") | |
| 153 | + | } | |
| 154 | + | var line string | |
| 155 | + | switch { | |
| 156 | + | case l.Continued && open: | |
| 157 | + | line = l.Text | |
| 158 | + | case open: | |
| 159 | + | line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |
| 160 | + | default: | |
| 161 | + | line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text | |
| 162 | + | } | |
| 163 | + | open = true | |
| 164 | + | n, werr := fmt.Fprint(w, line) | |
| 165 | + | written += n | |
| 166 | + | if werr != nil { | |
| 167 | + | return werr | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return nil | |
| 171 | + | }) | |
| 172 | + | if open { | |
| 173 | + | _, _ = fmt.Fprintln(w) | |
| 174 | + | } | |
| 175 | + | if err != nil && !open && errors.Is(err, domain.ErrNotFound) { | |
| 176 | + | w.WriteHeader(http.StatusNotFound) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // logClass is the class of a line of the log. | |
| 181 | + | func logClass(ln LogLineView) string { | |
| 182 | + | switch { | |
| 183 | + | case ln.Group: | |
| 184 | + | return "lg group" | |
| 185 | + | case ln.End: | |
| 186 | + | return "lg endgroup" | |
| 187 | + | case ln.Error: | |
| 188 | + | return "lg error" | |
| 189 | + | } | |
| 190 | + | return "lg" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the | |
| 194 | + | // lines of a job log from line N as Server-Sent Events, following the job while | |
| 195 | + | // it runs. Each "ci-log" event holds the <li> of the lines of one batch and has | |
| 196 | + | // the number of the next line as its id, so that a reconnection (Last-Event-ID) | |
| 197 | + | // goes on where it stopped. "ci-end" tells the job is over and every line was | |
| 198 | + | // sent. The job events of the repository stream carry no line (see events.go). | |
| 199 | + | func (s *Server) logStream(w http.ResponseWriter, r *http.Request) { | |
| 200 | + | ctx, cancel := context.WithCancel(r.Context()) | |
| 201 | + | defer cancel() | |
| 202 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 203 | + | if err != nil { | |
| 204 | + | s.denyEvents(ctx, w, err) | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | id, err := parseRunPath(r) | |
| 208 | + | jobID := r.PathValue("job") | |
| 209 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id { | |
| 210 | + | s.denyEvents(ctx, w, errNotFound) | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | from := int64(0) | |
| 214 | + | if v := r.Header.Get("Last-Event-ID"); v != "" { | |
| 215 | + | from, err = strconv.ParseInt(v, 10, 64) | |
| 216 | + | } else if v := r.URL.Query().Get("from"); v != "" { | |
| 217 | + | from, err = strconv.ParseInt(v, 10, 64) | |
| 218 | + | } | |
| 219 | + | if err != nil || from < 0 { | |
| 220 | + | s.denyEvents(ctx, w, errNotFound) | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil { | |
| 224 | + | s.denyEvents(ctx, w, err) | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | rc := http.NewResponseController(w) | |
| 229 | + | hdr := w.Header() | |
| 230 | + | hdr.Set("Content-Type", "text/event-stream") | |
| 231 | + | cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform") | |
| 232 | + | hdr.Set("X-Accel-Buffering", "no") | |
| 233 | + | w.WriteHeader(http.StatusOK) | |
| 234 | + | out := &sseWriter{w: w, rc: rc} | |
| 235 | + | if out.raw(": connected\n\n") != nil { | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | n := from | |
| 239 | + | err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{ | |
| 240 | + | JobID: jobID, Offset: from, Follow: true, | |
| 241 | + | Client: s.logClient(r), | |
| 242 | + | }, func(b ports.LogBatch) error { | |
| 243 | + | var views []LogLineView | |
| 244 | + | for i, l := range b.Lines { | |
| 245 | + | views = appendLogLine(views, b.Offset+int64(i), l) | |
| 246 | + | } | |
| 247 | + | var html strings.Builder | |
| 248 | + | for _, v := range views { | |
| 249 | + | var b2 strings.Builder | |
| 250 | + | if err := LogLine(v).Render(ctx, &b2); err != nil { | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | html.WriteString(strings.ReplaceAll(b2.String(), "\n", "")) | |
| 254 | + | } | |
| 255 | + | n = b.Offset + int64(len(b.Lines)) | |
| 256 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 257 | + | _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n") | |
| 258 | + | if werr != nil { | |
| 259 | + | return werr | |
| 260 | + | } | |
| 261 | + | return rc.Flush() | |
| 262 | + | }) | |
| 263 | + | if err == nil || errors.Is(err, domain.ErrNotFound) { | |
| 264 | + | _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n") | |
| 265 | + | } | |
| 266 | + | } | |
A internal/adapters/web/actions_pages.go +468 -0
| @@ -0,0 +1,468 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "net/http" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The CI pages (ADR 0025): the runs of a repository, one run with its jobs, | |
| 20 | + | // one job with its steps and its log. They read through ports.RunService and | |
| 21 | + | // follow the events RunUpdated and JobUpdated of the repository topic. | |
| 22 | + | // | |
| 23 | + | // GET /<repo>/actions the runs, newest first | |
| 24 | + | // GET /<repo>/actions/runs/<id> one run: jobs and steps | |
| 25 | + | // GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log | |
| 26 | + | // GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text | |
| 27 | + | // POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner) | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | // actionsPageSize is the number of runs one page lists. | |
| 31 | + | actionsPageSize = 30 | |
| 32 | + | // jobLogPageLines is the number of log lines a job page shows at once; the | |
| 33 | + | // rest is reached with ?from=. | |
| 34 | + | jobLogPageLines = 2000 | |
| 35 | + | // rawLogMaxBytes bounds the raw log the server writes to one response. | |
| 36 | + | rawLogMaxBytes = 64 << 20 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | func actionsBase(repo string) string { return repoHref(repo, "actions") } | |
| 40 | + | func runHref(repo string, id int64) string { | |
| 41 | + | return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10)) | |
| 42 | + | } | |
| 43 | + | func jobHref(repo string, runID int64, jobID string) string { | |
| 44 | + | return runHref(repo, runID) + "/job/" + jobID | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // ciState is the one word shown for a status and a conclusion: what is going | |
| 48 | + | // on, or how it ended. | |
| 49 | + | func ciState(status domain.CIStatus, c domain.CIConclusion) string { | |
| 50 | + | switch status { | |
| 51 | + | case domain.CIQueued: | |
| 52 | + | return "queued" | |
| 53 | + | case domain.CIInProgress: | |
| 54 | + | return "running" | |
| 55 | + | } | |
| 56 | + | switch c { | |
| 57 | + | case domain.CISuccess: | |
| 58 | + | return "success" | |
| 59 | + | case domain.CIFailure: | |
| 60 | + | return "failure" | |
| 61 | + | case domain.CICancelled: | |
| 62 | + | return "cancelled" | |
| 63 | + | case domain.CISkipped: | |
| 64 | + | return "skipped" | |
| 65 | + | case domain.CITimedOut: | |
| 66 | + | return "timed out" | |
| 67 | + | case domain.CINeutral: | |
| 68 | + | return "neutral" | |
| 69 | + | case domain.CIActionRequired: | |
| 70 | + | return "action required" | |
| 71 | + | } | |
| 72 | + | return "done" | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // ciKind groups the states into the few that have an icon and a colour. | |
| 76 | + | func ciKind(status domain.CIStatus, c domain.CIConclusion) string { | |
| 77 | + | switch { | |
| 78 | + | case status == domain.CIQueued: | |
| 79 | + | return "queued" | |
| 80 | + | case status == domain.CIInProgress: | |
| 81 | + | return "running" | |
| 82 | + | case c == domain.CISuccess, c == domain.CINeutral: | |
| 83 | + | return "ok" | |
| 84 | + | case c == domain.CISkipped: | |
| 85 | + | return "skipped" | |
| 86 | + | case c == domain.CICancelled: | |
| 87 | + | return "cancelled" | |
| 88 | + | } | |
| 89 | + | return "bad" | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // duration is a short elapsed time between two instants; empty when the | |
| 93 | + | // start is unknown, running up to now when the end is not. | |
| 94 | + | func duration(start, end, now time.Time) string { | |
| 95 | + | if start.IsZero() { | |
| 96 | + | return "" | |
| 97 | + | } | |
| 98 | + | if end.IsZero() { | |
| 99 | + | end = now | |
| 100 | + | } | |
| 101 | + | d := end.Sub(start).Round(time.Second) | |
| 102 | + | switch { | |
| 103 | + | case d < 0: | |
| 104 | + | return "" | |
| 105 | + | case d < time.Minute: | |
| 106 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 107 | + | case d < time.Hour: | |
| 108 | + | return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) | |
| 109 | + | } | |
| 110 | + | return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // RunRow is a run of the list. | |
| 114 | + | type RunRow struct { | |
| 115 | + | ID string | |
| 116 | + | Href string | |
| 117 | + | Kind string // ciKind | |
| 118 | + | State string // ciState | |
| 119 | + | Workflow string | |
| 120 | + | Title string | |
| 121 | + | Ref string | |
| 122 | + | RefHref string | |
| 123 | + | Short string | |
| 124 | + | CHref string | |
| 125 | + | Actor string | |
| 126 | + | When string | |
| 127 | + | WhenAt string | |
| 128 | + | Duration string | |
| 129 | + | Number string | |
| 130 | + | Attempt int32 | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // ActionsPage lists the runs of a repository. | |
| 134 | + | type ActionsPage struct { | |
| 135 | + | Runs []RunRow | |
| 136 | + | // More is the link to the next page, "" on the last. | |
| 137 | + | More string | |
| 138 | + | // NoRunner says that CI has no runner to run on yet. | |
| 139 | + | NoRunner bool | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // StepView is a step of a job. | |
| 143 | + | type StepView struct { | |
| 144 | + | Number int32 | |
| 145 | + | Name string | |
| 146 | + | Kind, State string | |
| 147 | + | Duration string | |
| 148 | + | // LogHref jumps to the lines of the step in the job log. | |
| 149 | + | LogStart, LogEnd int64 | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // JobRow is a job of a run. | |
| 153 | + | type JobRow struct { | |
| 154 | + | ID, Name, Href string | |
| 155 | + | Kind, State string | |
| 156 | + | Runner string | |
| 157 | + | Isolation string | |
| 158 | + | Duration string | |
| 159 | + | Error string | |
| 160 | + | Needs []string | |
| 161 | + | Steps []StepView | |
| 162 | + | Attempt int32 | |
| 163 | + | // Waiting says that the job is queued for a runner that no registered | |
| 164 | + | // runner can be. | |
| 165 | + | Waiting bool | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // RunPage is one run with its jobs. | |
| 169 | + | type RunPage struct { | |
| 170 | + | RunRow | |
| 171 | + | Sha, Error string | |
| 172 | + | Attempt int32 | |
| 173 | + | Latest int32 | |
| 174 | + | Attempts []AttemptLink | |
| 175 | + | Jobs []JobRow | |
| 176 | + | Active bool | |
| 177 | + | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| 178 | + | CancelAction, RerunAction, RerunFailedAction string | |
| 179 | + | CanRerunFailed bool | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // AttemptLink switches between the attempts of a run. | |
| 183 | + | type AttemptLink struct { | |
| 184 | + | Label, Href string | |
| 185 | + | Current bool | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // actions is GET /<repo>/actions. | |
| 189 | + | func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 190 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 191 | + | if err != nil { | |
| 192 | + | return nil, 0, err | |
| 193 | + | } | |
| 194 | + | token := r.URL.Query().Get("page") | |
| 195 | + | page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token}) | |
| 196 | + | if err != nil { | |
| 197 | + | return nil, 0, err | |
| 198 | + | } | |
| 199 | + | now := s.cfg.Now() | |
| 200 | + | p := ActionsPage{} | |
| 201 | + | for _, run := range page.Runs { | |
| 202 | + | p.Runs = append(p.Runs, s.runRow(d.name(), run, now)) | |
| 203 | + | } | |
| 204 | + | if page.NextPageToken != "" { | |
| 205 | + | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | |
| 206 | + | } | |
| 207 | + | l := s.repoLayout(ctx, d, "actions") | |
| 208 | + | l.Title = "actions · " + d.name() + " · " + s.instance | |
| 209 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | |
| 210 | + | l.Crumbs[0].Current = false | |
| 211 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true}) | |
| 212 | + | return Actions(l, p), http.StatusOK, nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | |
| 216 | + | row := RunRow{ | |
| 217 | + | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | |
| 218 | + | Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion), | |
| 219 | + | Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title), | |
| 220 | + | Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")), | |
| 221 | + | Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor), | |
| 222 | + | Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt, | |
| 223 | + | } | |
| 224 | + | row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")) | |
| 225 | + | row.When, row.WhenAt = whenOf(now, run.CreatedAt) | |
| 226 | + | return row | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func stepViews(j ports.JobView, now time.Time) []StepView { | |
| 230 | + | ranges := j.StepRanges() | |
| 231 | + | out := make([]StepView, 0, len(j.Steps)) | |
| 232 | + | for i, st := range j.Steps { | |
| 233 | + | status := domain.CIQueued | |
| 234 | + | switch st.State { | |
| 235 | + | case domain.CIStepInProgress: | |
| 236 | + | status = domain.CIInProgress | |
| 237 | + | case domain.CIStepCompleted: | |
| 238 | + | status = domain.CICompleted | |
| 239 | + | } | |
| 240 | + | out = append(out, StepView{ | |
| 241 | + | Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion), | |
| 242 | + | Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |
| 243 | + | }) | |
| 244 | + | } | |
| 245 | + | return out | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow { | |
| 249 | + | return JobRow{ | |
| 250 | + | ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion), | |
| 251 | + | State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation), | |
| 252 | + | Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs, | |
| 253 | + | Steps: stepViews(j, now), Attempt: j.Attempt, | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func parseRunPath(r *http.Request) (int64, error) { | |
| 258 | + | id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | |
| 259 | + | if err != nil || id < 1 { | |
| 260 | + | return 0, errNotFound | |
| 261 | + | } | |
| 262 | + | return id, nil | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // run is GET /<repo>/actions/runs/<id>. | |
| 266 | + | func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 267 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 268 | + | if err != nil { | |
| 269 | + | return nil, 0, err | |
| 270 | + | } | |
| 271 | + | id, err := parseRunPath(r) | |
| 272 | + | if err != nil { | |
| 273 | + | return nil, 0, err | |
| 274 | + | } | |
| 275 | + | var attempt int32 | |
| 276 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 277 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 278 | + | if perr != nil || n < 1 { | |
| 279 | + | return nil, 0, errNotFound | |
| 280 | + | } | |
| 281 | + | attempt = int32(n) | |
| 282 | + | } | |
| 283 | + | p, err := s.runPage(ctx, d, id, attempt) | |
| 284 | + | if err != nil { | |
| 285 | + | return nil, 0, err | |
| 286 | + | } | |
| 287 | + | l := s.repoLayout(ctx, d, "actions") | |
| 288 | + | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |
| 289 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | |
| 290 | + | l.Crumbs[0].Current = false | |
| 291 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true}) | |
| 292 | + | l.HasRail = true | |
| 293 | + | return RunDetail(l, p), http.StatusOK, nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here | |
| 297 | + | func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) { | |
| 298 | + | detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt) | |
| 299 | + | if err != nil { | |
| 300 | + | return RunPage{}, err | |
| 301 | + | } | |
| 302 | + | now := s.cfg.Now() | |
| 303 | + | run := detail.Run | |
| 304 | + | p := RunPage{ | |
| 305 | + | RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error), | |
| 306 | + | Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, | |
| 307 | + | } | |
| 308 | + | for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- { | |
| 309 | + | p.Attempts = append(p.Attempts, AttemptLink{ | |
| 310 | + | Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt, | |
| 311 | + | }) | |
| 312 | + | } | |
| 313 | + | for _, j := range detail.Jobs { | |
| 314 | + | row := s.jobRow(d.name(), j, now) | |
| 315 | + | if run.Attempt == detail.LatestAttempt { | |
| 316 | + | // An earlier attempt keeps the job as it ran then. | |
| 317 | + | row.Href = jobHref(d.name(), id, j.ID) | |
| 318 | + | } | |
| 319 | + | p.Jobs = append(p.Jobs, row) | |
| 320 | + | } | |
| 321 | + | if SignedIn(ctx) && run.Attempt == detail.LatestAttempt { | |
| 322 | + | base := runHref(d.name(), id) | |
| 323 | + | if p.Active { | |
| 324 | + | p.CancelAction = base + "/cancel" | |
| 325 | + | } else if len(detail.Jobs) > 0 { | |
| 326 | + | p.RerunAction = base + "/rerun" | |
| 327 | + | for _, j := range detail.Jobs { | |
| 328 | + | if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled { | |
| 329 | + | p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true | |
| 330 | + | break | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | return p, nil | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // job is GET /<repo>/actions/runs/<id>/job/<job id>. | |
| 339 | + | func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 340 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 341 | + | if err != nil { | |
| 342 | + | return nil, 0, err | |
| 343 | + | } | |
| 344 | + | id, err := parseRunPath(r) | |
| 345 | + | if err != nil { | |
| 346 | + | return nil, 0, err | |
| 347 | + | } | |
| 348 | + | jobID := r.PathValue("job") | |
| 349 | + | if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id { | |
| 350 | + | return nil, 0, errNotFound | |
| 351 | + | } | |
| 352 | + | var attempt int32 | |
| 353 | + | if a := r.URL.Query().Get("attempt"); a != "" { | |
| 354 | + | n, perr := strconv.ParseInt(a, 10, 32) | |
| 355 | + | if perr != nil || n < 1 { | |
| 356 | + | return nil, 0, errNotFound | |
| 357 | + | } | |
| 358 | + | attempt = int32(n) | |
| 359 | + | } | |
| 360 | + | view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt) | |
| 361 | + | if err != nil { | |
| 362 | + | return nil, 0, err | |
| 363 | + | } | |
| 364 | + | run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0) | |
| 365 | + | if err != nil { | |
| 366 | + | return nil, 0, err | |
| 367 | + | } | |
| 368 | + | now := s.cfg.Now() | |
| 369 | + | var from int64 | |
| 370 | + | if f := r.URL.Query().Get("from"); f != "" { | |
| 371 | + | from, err = strconv.ParseInt(f, 10, 64) | |
| 372 | + | if err != nil || from < 0 { | |
| 373 | + | return nil, 0, errNotFound | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | p := JobPage{ | |
| 377 | + | JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName), | |
| 378 | + | Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines, | |
| 379 | + | RawHref: jobHref(d.name(), id, jobID) + "/raw", | |
| 380 | + | } | |
| 381 | + | if attempt > 0 { | |
| 382 | + | p.RawHref += "?attempt=" + strconv.Itoa(int(attempt)) | |
| 383 | + | } | |
| 384 | + | p.Follow = !view.Ended() | |
| 385 | + | p.StreamHref = jobHref(d.name(), id, jobID) + "/stream" | |
| 386 | + | lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines) | |
| 387 | + | if err != nil { | |
| 388 | + | return nil, 0, err | |
| 389 | + | } | |
| 390 | + | p.Lines = lines | |
| 391 | + | if next < view.Lines { | |
| 392 | + | p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10) | |
| 393 | + | p.Follow = false // more is waiting before the live end | |
| 394 | + | } | |
| 395 | + | if from > 0 { | |
| 396 | + | p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10) | |
| 397 | + | } | |
| 398 | + | if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady { | |
| 399 | + | p.Note = "the job has not started: no log yet." | |
| 400 | + | } else if view.Lines == 0 && view.Ended() { | |
| 401 | + | p.Note = "the job wrote no log." | |
| 402 | + | } | |
| 403 | + | l := s.repoLayout(ctx, d, "actions") | |
| 404 | + | l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance | |
| 405 | + | l.Live = true | |
| 406 | + | l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID) | |
| 407 | + | l.Crumbs[0].Current = false | |
| 408 | + | l.Crumbs = append(l.Crumbs, | |
| 409 | + | Crumb{Label: "actions", Href: actionsBase(d.name())}, | |
| 410 | + | Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref}, | |
| 411 | + | Crumb{Label: p.Name, Current: true}) | |
| 412 | + | l.HasRail = true | |
| 413 | + | return Job(l, p), http.StatusOK, nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // ciAction wraps a POST of the actions pages: the form gate, the bounded | |
| 417 | + | // context and the mapping of a refusal to a page. | |
| 418 | + | func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc { | |
| 419 | + | return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) { | |
| 420 | + | to, err := h(ctx, r) | |
| 421 | + | return to, actionsBase(r.PathValue("repo")), err | |
| 422 | + | }) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) { | |
| 426 | + | name, id, err := s.runTarget(r) | |
| 427 | + | if err != nil { | |
| 428 | + | return "", err | |
| 429 | + | } | |
| 430 | + | if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil { | |
| 431 | + | return "", err | |
| 432 | + | } | |
| 433 | + | return runHref(name.String(), id), nil | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) { | |
| 437 | + | return func(ctx context.Context, r *http.Request) (string, error) { | |
| 438 | + | name, id, err := s.runTarget(r) | |
| 439 | + | if err != nil { | |
| 440 | + | return "", err | |
| 441 | + | } | |
| 442 | + | if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil { | |
| 443 | + | return "", err | |
| 444 | + | } | |
| 445 | + | return runHref(name.String(), id), nil | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) { | |
| 450 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 451 | + | if err != nil { | |
| 452 | + | return domain.RepoName{}, 0, errNotFound | |
| 453 | + | } | |
| 454 | + | id, err := parseRunPath(r) | |
| 455 | + | return name, id, err | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func itoa64(n int64) string { return strconv.FormatInt(n, 10) } | |
| 459 | + | ||
| 460 | + | // runnerText names the runner of a job and how it isolated it. | |
| 461 | + | func runnerText(j JobRow) string { | |
| 462 | + | if j.Isolation == "" { | |
| 463 | + | return j.Runner | |
| 464 | + | } | |
| 465 | + | return j.Runner + " (" + j.Isolation + ")" | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func joinWords(ws []string) string { return strings.Join(ws, ", ") } | |
A internal/adapters/web/actions_templ.go +2022 -0 generated file: collapsed
A internal/adapters/web/actions_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/netip" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // S6: every page that streams a log through the use case names the visitor, so | |
| 24 | + | // that the limit of log streams per client applies to the pages too, behind a | |
| 25 | + | // trusted proxy by the address it forwards. | |
| 26 | + | func TestLogPagesPassTheClientKey(t *testing.T) { | |
| 27 | + | t.Parallel() | |
| 28 | + | const jobID = "1-0-0" | |
| 29 | + | repo := repoName(t, "proj") | |
| 30 | + | job := ports.JobView{CIJob: domain.CIJob{ | |
| 31 | + | ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build", | |
| 32 | + | Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess, | |
| 33 | + | }, Lines: 1} | |
| 34 | + | want := clientlimit.Key("198.51.100.7") | |
| 35 | + | ||
| 36 | + | runs := mocks.NewRunService(t) | |
| 37 | + | runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil).Maybe() | |
| 38 | + | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 39 | + | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1, | |
| 40 | + | }, nil).Maybe() | |
| 41 | + | var seen []string | |
| 42 | + | runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn( | |
| 43 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error { | |
| 44 | + | seen = append(seen, p.Client) | |
| 45 | + | return send(ports.LogBatch{Lines: []ports.JobLogLine{{Step: 1, CILogLine: domain.CILogLine{Time: time.Unix(0, 0), Text: "x"}}}}) | |
| 46 | + | }) | |
| 47 | + | ||
| 48 | + | browse := mocks.NewBrowseService(t) | |
| 49 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 50 | + | srv := newTestServer(t, Config{ | |
| 51 | + | Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs, | |
| 52 | + | TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, | |
| 53 | + | }) | |
| 54 | + | for _, path := range []string{ | |
| 55 | + | "/proj/actions/runs/1/job/" + jobID, | |
| 56 | + | "/proj/actions/runs/1/job/" + jobID + "/raw", | |
| 57 | + | "/proj/actions/runs/1/job/" + jobID + "/stream", | |
| 58 | + | } { | |
| 59 | + | req := httptest.NewRequest(http.MethodGet, path, nil) | |
| 60 | + | req.RemoteAddr = "192.0.2.1:4000" | |
| 61 | + | req.Header.Set("X-Forwarded-For", "198.51.100.7") | |
| 62 | + | rec := httptest.NewRecorder() | |
| 63 | + | srv.ServeHTTP(rec, req) | |
| 64 | + | require.Equal(t, http.StatusOK, rec.Code, path) | |
| 65 | + | } | |
| 66 | + | require.Len(t, seen, 3) | |
| 67 | + | for _, c := range seen { | |
| 68 | + | assert.Equal(t, want, c) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // M4: one helper shapes the lines of the page, the raw feed and the live feed. | |
| 73 | + | func TestAppendLogLine(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC) | |
| 76 | + | line := func(text string, cont bool) ports.JobLogLine { | |
| 77 | + | return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}} | |
| 78 | + | } | |
| 79 | + | var out []LogLineView | |
| 80 | + | out = appendLogLine(out, 10, line("##[group]Run make", false)) | |
| 81 | + | out = appendLogLine(out, 11, line("plain <b>", false)) | |
| 82 | + | out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false)) | |
| 83 | + | out = appendLogLine(out, 13, line("##[endgroup]", false)) | |
| 84 | + | require.Len(t, out, 4) | |
| 85 | + | assert.True(t, out[0].Group) | |
| 86 | + | assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text") | |
| 87 | + | assert.EqualValues(t, 10, out[0].N) | |
| 88 | + | assert.Equal(t, "10:11:12", out[0].Time) | |
| 89 | + | assert.EqualValues(t, 2, out[0].Step) | |
| 90 | + | assert.False(t, out[1].Group || out[1].Error || out[1].End) | |
| 91 | + | assert.True(t, out[2].Error) | |
| 92 | + | assert.Equal(t, "Process completed with exit code 1.", out[2].Text) | |
| 93 | + | assert.True(t, out[3].End) | |
| 94 | + | ||
| 95 | + | // A cut line comes back as one line, with the number of its first piece. | |
| 96 | + | var cut []LogLineView | |
| 97 | + | cut = appendLogLine(cut, 20, line("first half, ", false)) | |
| 98 | + | cut = appendLogLine(cut, 21, line("second half", true)) | |
| 99 | + | require.Len(t, cut, 1) | |
| 100 | + | assert.Equal(t, "first half, second half", cut[0].Text) | |
| 101 | + | assert.EqualValues(t, 20, cut[0].N) | |
| 102 | + | ||
| 103 | + | // A piece that opens a batch has no line before it here: it stays, marked, | |
| 104 | + | // for the page to join to the line it already holds. | |
| 105 | + | open := appendLogLine(nil, 30, line("rest", true)) | |
| 106 | + | require.Len(t, open, 1) | |
| 107 | + | assert.True(t, open[0].Continued) | |
| 108 | + | ||
| 109 | + | // Invisible characters of the runner's text are shown, never applied. | |
| 110 | + | hidden := appendLogLine(nil, 40, line("a\u202eb", false)) | |
| 111 | + | assert.NotContains(t, hidden[0].Text, "\u202e") | |
| 112 | + | } | |
M internal/adapters/web/assets.go +2 -1
| @@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/" | |||
| 23 | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 24 | 24 | // the content, so they can be cached for ever. | |
| 25 | 25 | type Assets struct { | |
| 26 | - | CSS, HTMX, Live, SSE, App, Files, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Log, Icon string | |
| 27 | 27 | // Font is the UI font, preloaded by every page. | |
| 28 | 28 | Font string | |
| 29 | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | |
| @@ -56,6 +56,7 @@ var scripts = []struct { | |||
| 56 | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 57 | 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |
| 58 | 58 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |
| 59 | + | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | |
A internal/adapters/web/checks.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ChecksSummary is the combined state of the checks of a commit. | |
| 17 | + | type ChecksSummary struct { | |
| 18 | + | Total int | |
| 19 | + | Kind, State string | |
| 20 | + | Label, Title string | |
| 21 | + | Href string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // CheckRow is a check of a commit or a merge request. | |
| 25 | + | type CheckRow struct { | |
| 26 | + | Name, Kind, State, Href string | |
| 27 | + | Waiting bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ChecksSection is the checks of the head of a merge request. | |
| 31 | + | type ChecksSection struct { | |
| 32 | + | Rows []CheckRow | |
| 33 | + | // Note explains the rule that applies to them. | |
| 34 | + | Note string | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // checksFor reads the checks of a commit as the caller of the request. A page | |
| 38 | + | // shows them when it can: a failure of the read is logged and the page goes on | |
| 39 | + | // without, since the code and the review do not depend on CI. | |
| 40 | + | func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView { | |
| 41 | + | if s.cfg.Runs == nil || sha == "" { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha) | |
| 45 | + | if err != nil { | |
| 46 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 47 | + | log := zerowrap.FromCtx(ctx) | |
| 48 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit") | |
| 49 | + | } | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | return res.Checks | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // checksSummary reduces checks to the mark a list shows next to a commit. | |
| 56 | + | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | |
| 57 | + | if len(checks) == 0 { | |
| 58 | + | return ChecksSummary{} | |
| 59 | + | } | |
| 60 | + | plain := make([]domain.CICheck, 0, len(checks)) | |
| 61 | + | passed := 0 | |
| 62 | + | for _, c := range checks { | |
| 63 | + | plain = append(plain, c.CICheck) | |
| 64 | + | if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() { | |
| 65 | + | passed++ | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))} | |
| 69 | + | switch domain.CombineChecks(plain) { | |
| 70 | + | case domain.CheckStateSuccess: | |
| 71 | + | sum.Kind, sum.State = "ok", "checks passed" | |
| 72 | + | case domain.CheckStateFailure: | |
| 73 | + | sum.Kind, sum.State = "bad", "checks failed" | |
| 74 | + | default: | |
| 75 | + | sum.Kind, sum.State = "running", "checks pending" | |
| 76 | + | } | |
| 77 | + | sum.Title = sum.State + ": " + sum.Label | |
| 78 | + | return sum | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // checksSection is the checks of the rail of a merge request, with the rule of | |
| 82 | + | // the repository that applies to them. | |
| 83 | + | func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection { | |
| 84 | + | sec := ChecksSection{} | |
| 85 | + | for _, c := range checks { | |
| 86 | + | row := CheckRow{ | |
| 87 | + | Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion), | |
| 88 | + | Waiting: c.WaitingForRunner, | |
| 89 | + | } | |
| 90 | + | if c.JobID != "" { | |
| 91 | + | row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID) | |
| 92 | + | } else { | |
| 93 | + | row.Href = runHref(repo.Name.String(), c.RunID) | |
| 94 | + | } | |
| 95 | + | sec.Rows = append(sec.Rows, row) | |
| 96 | + | } | |
| 97 | + | switch { | |
| 98 | + | case len(checks) == 0: | |
| 99 | + | sec.Note = "no check on this commit: nothing blocks the merge." | |
| 100 | + | case repo.Settings.FailingChecksBlock: | |
| 101 | + | sec.Note = "a failing or unfinished check blocks the merge." | |
| 102 | + | default: | |
| 103 | + | sec.Note = "checks do not block the merge in this repository." | |
| 104 | + | } | |
| 105 | + | return sec | |
| 106 | + | } | |
M internal/adapters/web/commit.go +3 -0
| @@ -44,6 +44,8 @@ type CommitPage struct { | |||
| 44 | 44 | // Invisible is true when some line holds an invisible character, shown as a | |
| 45 | 45 | // marker. | |
| 46 | 46 | Invisible bool | |
| 47 | + | // Checks is the combined state of the CI checks of the commit. | |
| 48 | + | Checks ChecksSummary | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // DiffView is how a page lays its diff out: the layouts in the page and the | |
| @@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 138 | 140 | } | |
| 139 | 141 | } | |
| 140 | 142 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 143 | + | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | |
| 141 | 144 | ||
| 142 | 145 | lines, invisible := diffLines(p.Files) | |
| 143 | 146 | p.Invisible = invisible | |
M internal/adapters/web/commits.go +9 -2
| @@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 73 | 73 | if token != "" { | |
| 74 | 74 | p.Newest = base | |
| 75 | 75 | } | |
| 76 | - | s.fillDays(&p, d, cs) | |
| 76 | + | s.fillDays(ctx, &p, d, cs) | |
| 77 | 77 | if sel.path != "" && token == "" { | |
| 78 | 78 | p.Open = s.pathLink(ctx, d, sel) | |
| 79 | 79 | } | |
| @@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 95 | 95 | return Commits(l, p), http.StatusOK, nil | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // maxCheckLookups bounds the commits of one page whose checks are read: each | |
| 99 | + | // is one read of the store. | |
| 100 | + | const maxCheckLookups = 100 | |
| 101 | + | ||
| 98 | 102 | // fillDays groups the commits of a page by day and lists the refs among them. | |
| 99 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { | |
| 103 | + | func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) { | |
| 100 | 104 | now := s.cfg.Now() | |
| 101 | 105 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 102 | 106 | for i, c := range cs { | |
| 103 | 107 | row := commitRow(d.name(), c, now) | |
| 104 | 108 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | |
| 105 | 109 | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] | |
| 110 | + | if i < maxCheckLookups { | |
| 111 | + | row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String())) | |
| 112 | + | } | |
| 106 | 113 | day := "unknown date" | |
| 107 | 114 | if t := c.Committer.Time; !t.IsZero() { | |
| 108 | 115 | day = t.UTC().Format("2006-01-02") | |
M internal/adapters/web/components.templ +1 -0
| @@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) { | |||
| 112 | 112 | <span class="pill tag"><span class="sr">tag </span>{ t }</span> | |
| 113 | 113 | } | |
| 114 | 114 | </td> | |
| 115 | + | <td class="c-ck">@CommitChecks(c.Checks)</td> | |
| 115 | 116 | <td class="c-who dim">{ c.Author }</td> | |
| 116 | 117 | <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td> | |
| 117 | 118 | </tr> | |
M internal/adapters/web/components_templ.go +18 -10 generated file: collapsed
M internal/adapters/web/diff.templ +4 -0
| @@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) { | |||
| 40 | 40 | { " · diff against the first parent" } | |
| 41 | 41 | } | |
| 42 | 42 | </dd> | |
| 43 | + | if p.Checks.Total > 0 { | |
| 44 | + | <dt>checks</dt> | |
| 45 | + | <dd>@CommitChecks(p.Checks)</dd> | |
| 46 | + | } | |
| 43 | 47 | if len(p.Refs) > 0 { | |
| 44 | 48 | <dt>refs</dt> | |
| 45 | 49 | <dd> | |
M internal/adapters/web/diff_templ.go +180 -166 generated file: collapsed
M internal/adapters/web/events.go +9 -1
| @@ -31,6 +31,9 @@ import ( | |||
| 31 | 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 32 | 32 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 33 | 33 | // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch> | |
| 34 | + | // GET /-/events?topic=repo:<name>&view=actions | |
| 35 | + | // GET /-/events?topic=repo:<name>&view=run&n=<run id> | |
| 36 | + | // GET /-/events?topic=repo:<name>&view=job&job=<job id> | |
| 34 | 37 | // | |
| 35 | 38 | // topic is the Watch topic. view says which fragments of a repository the | |
| 36 | 39 | // page shows, so that the server renders only those (summary when absent). | |
| @@ -50,6 +53,11 @@ import ( | |||
| 50 | 53 | // commits-head data: <div id="commits-head">N new commits — reload</div> | |
| 51 | 54 | // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages: | |
| 52 | 55 | // something changed, with the link that reloads the page | |
| 56 | + | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | |
| 57 | + | // <div id="run-body">…</div> (one run) | |
| 58 | + | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | |
| 59 | + | // itself is never sent in this stream: the page follows it on its own | |
| 60 | + | // feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line> | |
| 53 | 61 | // gap data: gap; events were lost, or what the page shows is gone or | |
| 54 | 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 55 | 63 | // | |
| @@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 194 | 202 | return | |
| 195 | 203 | } | |
| 196 | 204 | ||
| 197 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | |
| 205 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")} | |
| 198 | 206 | handle := func(m recvd) bool { | |
| 199 | 207 | if m.err != nil { | |
| 200 | 208 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | |
M internal/adapters/web/fragments.go +105 -2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | 9 | "strconv" | |
| 10 | + | "strings" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -23,7 +24,8 @@ type liveStream struct { | |||
| 23 | 24 | view string // "summary", "refs" or "commits" | |
| 24 | 25 | ref string // commits: the branch listed | |
| 25 | 26 | head string // commits: the commit listed first | |
| 26 | - | n string // mr: the number of the merge request shown | |
| 27 | + | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | + | job string // job: the id of the job shown | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | // renderFragment renders a component to the data of an event. | |
| @@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 46 | 48 | } | |
| 47 | 49 | switch we.Event.(type) { | |
| 48 | 50 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 49 | - | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | |
| 51 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded, | |
| 52 | + | domain.RunUpdated, domain.JobUpdated: | |
| 50 | 53 | return true | |
| 51 | 54 | } | |
| 52 | 55 | return false | |
| @@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 117 | 120 | // repoPage handles the events of the topic of one repository. | |
| 118 | 121 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 119 | 122 | switch ev := we.Event.(type) { | |
| 123 | + | case domain.RunUpdated: | |
| 124 | + | return l.runEvent(ctx, ev) | |
| 125 | + | case domain.JobUpdated: | |
| 126 | + | return l.jobEvent(ctx, ev) | |
| 120 | 127 | case domain.RepoDeleted, domain.RepoCreated: | |
| 121 | 128 | // Gone, or a new repository under the same name: whatever the page | |
| 122 | 129 | // shows is not true any more. | |
| @@ -395,3 +402,99 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, | |||
| 395 | 402 | f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href)) | |
| 396 | 403 | return []fragment{f}, err | |
| 397 | 404 | } | |
| 405 | + | ||
| 406 | + | // runEvent updates the pages of CI that a RunUpdated changes: the row of the | |
| 407 | + | // list of runs, or the body of the run page shown. Only a state is sent: the | |
| 408 | + | // events carry no log and no text of the workflow. | |
| 409 | + | func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) { | |
| 410 | + | if l.s.cfg.Runs == nil { | |
| 411 | + | return nil, nil | |
| 412 | + | } | |
| 413 | + | switch l.view { | |
| 414 | + | case "actions": | |
| 415 | + | f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now()))) | |
| 416 | + | if err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | if ev.Run.Status == domain.CIQueued { | |
| 420 | + | f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1) | |
| 421 | + | } | |
| 422 | + | return []fragment{f}, nil | |
| 423 | + | case "run": | |
| 424 | + | if strconv.FormatInt(ev.Run.ID, 10) != l.n { | |
| 425 | + | return nil, nil | |
| 426 | + | } | |
| 427 | + | return l.runBody(ctx) | |
| 428 | + | } | |
| 429 | + | return nil, nil | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | // jobEvent updates the body of the run page and the steps of the job page. | |
| 433 | + | func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) { | |
| 434 | + | if l.s.cfg.Runs == nil { | |
| 435 | + | return nil, nil | |
| 436 | + | } | |
| 437 | + | switch l.view { | |
| 438 | + | case "run": | |
| 439 | + | if strconv.FormatInt(ev.Job.RunID, 10) != l.n { | |
| 440 | + | return nil, nil | |
| 441 | + | } | |
| 442 | + | return l.runBody(ctx) | |
| 443 | + | case "job": | |
| 444 | + | if ev.Job.ID != l.job { | |
| 445 | + | return nil, nil | |
| 446 | + | } | |
| 447 | + | return l.jobState(ctx) | |
| 448 | + | } | |
| 449 | + | return nil, nil | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // runBody renders again the jobs of the run page. | |
| 453 | + | func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) { | |
| 454 | + | id, err := strconv.ParseInt(l.n, 10, 64) | |
| 455 | + | if err != nil { | |
| 456 | + | return nil, nil | |
| 457 | + | } | |
| 458 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 459 | + | if err != nil { | |
| 460 | + | return nil, err | |
| 461 | + | } | |
| 462 | + | p, err := l.s.runPage(ctx, d, id, 0) | |
| 463 | + | if err != nil { | |
| 464 | + | return nil, err | |
| 465 | + | } | |
| 466 | + | body, err := renderFragment(ctx, "ci-run", runBodyFragment(p)) | |
| 467 | + | if err != nil { | |
| 468 | + | return nil, err | |
| 469 | + | } | |
| 470 | + | // The buttons depend on the state: the page fetches itself again when the | |
| 471 | + | // run completed, so that the owner gets the right ones. | |
| 472 | + | if !p.Active { | |
| 473 | + | return []fragment{body, {event: "gap", html: "gap"}}, nil | |
| 474 | + | } | |
| 475 | + | return []fragment{body}, nil | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // jobState tells the job page that its state or its steps changed: the page | |
| 479 | + | // reloads the part of the log after the last line it holds (ermite.js). | |
| 480 | + | func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |
| 481 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 482 | + | if err != nil { | |
| 483 | + | return nil, err | |
| 484 | + | } | |
| 485 | + | view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0) | |
| 486 | + | if err != nil { | |
| 487 | + | return nil, err | |
| 488 | + | } | |
| 489 | + | now := l.s.cfg.Now() | |
| 490 | + | row := l.s.jobRow(d.name(), view, now) | |
| 491 | + | f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines)) | |
| 492 | + | if err != nil { | |
| 493 | + | return nil, err | |
| 494 | + | } | |
| 495 | + | steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now))) | |
| 496 | + | if err != nil { | |
| 497 | + | return nil, err | |
| 498 | + | } | |
| 499 | + | return []fragment{f, steps}, nil | |
| 500 | + | } | |
M internal/adapters/web/fragments.templ +1 -1
| @@ -101,7 +101,7 @@ templ Live(l Layout) { | |||
| 101 | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 102 | 102 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 103 | 103 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 104 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 104 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 105 | 105 | gap -> ermiteLive.reload()" | |
| 106 | 106 | ></span> | |
| 107 | 107 | } | |
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/jstest/log.test.mjs +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | // Tests of the live job log of static/js/log.js (window.ermiteLog) with a stub | |
| 2 | + | // EventSource on the tiny hand-rolled DOM. Run with | |
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | + | import assert from 'node:assert/strict'; | |
| 5 | + | import { test } from 'node:test'; | |
| 6 | + | import { el, installDom } from './dom.mjs'; | |
| 7 | + | ||
| 8 | + | // A line is "text" or "+text" for a piece that continues the line before it. | |
| 9 | + | function line(text) { | |
| 10 | + | const cont = text.startsWith('+'); | |
| 11 | + | const li = el('li', { cls: 'lg', attrs: cont ? { 'data-cont': '' } : {} }, el('span', { cls: 't', text: cont ? text.slice(1) : text })); | |
| 12 | + | return li; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | installDom({ parseHTML: (data) => data.split('|').filter(Boolean).map(line) }); | |
| 16 | + | globalThis.document.addEventListener = () => {}; // log.js starts itself on DOMContentLoaded | |
| 17 | + | ||
| 18 | + | class StubEventSource { | |
| 19 | + | static CLOSED = 2; | |
| 20 | + | static last = null; | |
| 21 | + | constructor(url) { | |
| 22 | + | this.url = url; | |
| 23 | + | this.readyState = 1; | |
| 24 | + | this.handlers = new Map(); | |
| 25 | + | StubEventSource.last = this; | |
| 26 | + | } | |
| 27 | + | addEventListener(name, fn) { this.handlers.set(name, fn); } | |
| 28 | + | close() { this.readyState = StubEventSource.CLOSED; } | |
| 29 | + | emit(name, data) { this.handlers.get(name)?.({ data }); } | |
| 30 | + | } | |
| 31 | + | globalThis.EventSource = StubEventSource; | |
| 32 | + | ||
| 33 | + | let scrolls = []; | |
| 34 | + | globalThis.scrollTo = (x, y) => scrolls.push(y); | |
| 35 | + | Object.assign(globalThis, { innerHeight: 800, scrollY: 0 }); | |
| 36 | + | ||
| 37 | + | await import('../static/js/log.js'); | |
| 38 | + | const { follow } = window.ermiteLog; | |
| 39 | + | ||
| 40 | + | // page makes a job page body: the box with its list and the lines already there. | |
| 41 | + | function page(attrs, ...lines) { | |
| 42 | + | const list = el('ol', { cls: 'log' }, ...lines.map(line)); | |
| 43 | + | const box = el('div', { id: 'job-log', attrs }, list); | |
| 44 | + | const body = document.body; | |
| 45 | + | body.replaceChildren(box); | |
| 46 | + | Object.defineProperty(globalThis.document.body, 'scrollHeight', { value: 1000, configurable: true }); | |
| 47 | + | return { box, list }; | |
| 48 | + | } | |
| 49 | + | const running = { 'data-follow': '', 'data-stream': '/r/job/stream', 'data-next': '3' }; | |
| 50 | + | const texts = (list) => list.children.map((li) => li.querySelector('.t').textContent); | |
| 51 | + | ||
| 52 | + | test('it does nothing for a job that is over, or a page without a feed', () => { | |
| 53 | + | assert.equal(follow(null), null); | |
| 54 | + | assert.equal(follow(page({ 'data-stream': '/s' }).box), null, 'no data-follow: the job ended'); | |
| 55 | + | assert.equal(follow(page({ 'data-follow': '' }).box), null, 'no stream URL'); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test('it opens the feed after the last line the page holds', () => { | |
| 59 | + | const { box } = page(running, 'a', 'b', 'c'); | |
| 60 | + | const es = follow(box); | |
| 61 | + | assert.equal(es.url, '/r/job/stream?from=3'); | |
| 62 | + | }); | |
| 63 | + | ||
| 64 | + | test('it appends the lines of a batch', () => { | |
| 65 | + | const { box, list } = page(running, 'a'); | |
| 66 | + | const es = follow(box); | |
| 67 | + | es.emit('ci-log', 'b|c'); | |
| 68 | + | es.emit('ci-log', 'd'); | |
| 69 | + | assert.deepEqual(texts(list), ['a', 'b', 'c', 'd']); | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | test('a piece that continues a line of an earlier batch joins that line', () => { | |
| 73 | + | const { box, list } = page(running, 'start of a long'); | |
| 74 | + | const es = follow(box); | |
| 75 | + | es.emit('ci-log', '+ line|next'); | |
| 76 | + | assert.deepEqual(texts(list), ['start of a long line', 'next']); | |
| 77 | + | }); | |
| 78 | + | ||
| 79 | + | test('it scrolls with the log only while the reader is at its end', () => { | |
| 80 | + | const { box } = page(running, 'a'); | |
| 81 | + | const es = follow(box); | |
| 82 | + | scrolls = []; | |
| 83 | + | Object.assign(globalThis, { scrollY: 190 }); // 800 + 190 >= 1000 - 80: at the end | |
| 84 | + | es.emit('ci-log', 'b'); | |
| 85 | + | assert.deepEqual(scrolls, [1000]); | |
| 86 | + | scrolls = []; | |
| 87 | + | Object.assign(globalThis, { scrollY: 0 }); // reading above | |
| 88 | + | es.emit('ci-log', 'c'); | |
| 89 | + | assert.deepEqual(scrolls, []); | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | test('ci-end closes the feed, and a closed connection stays closed', () => { | |
| 93 | + | const { box } = page(running, 'a'); | |
| 94 | + | const es = follow(box); | |
| 95 | + | assert.equal(es.readyState, 1); | |
| 96 | + | es.emit('ci-end', 'end'); | |
| 97 | + | assert.equal(es.readyState, StubEventSource.CLOSED); | |
| 98 | + | ||
| 99 | + | const again = follow(page(running, 'a').box); | |
| 100 | + | again.readyState = StubEventSource.CLOSED; | |
| 101 | + | again.onerror(); | |
| 102 | + | assert.equal(again.readyState, StubEventSource.CLOSED); | |
| 103 | + | }); | |
M internal/adapters/web/mr.go +8 -0
| @@ -205,6 +205,8 @@ type MRPage struct { | |||
| 205 | 205 | CommitsNote string | |
| 206 | 206 | Modes []string | |
| 207 | 207 | ChecksBlock bool | |
| 208 | + | // Checks are the CI checks of the head of the request. | |
| 209 | + | Checks ChecksSection | |
| 208 | 210 | ||
| 209 | 211 | Box *MergeBox | |
| 210 | 212 | // Live is true when the page announces changes. | |
| @@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 456 | 458 | } | |
| 457 | 459 | } | |
| 458 | 460 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | |
| 461 | + | if open && head != "" { | |
| 462 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | |
| 463 | + | } else if head != "" { | |
| 464 | + | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | |
| 465 | + | p.Checks.Note = "" | |
| 466 | + | } | |
| 459 | 467 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 460 | 468 | ||
| 461 | 469 | // The commits of the request, newest first. | |
M internal/adapters/web/mr.templ +1 -5
| @@ -302,11 +302,7 @@ templ mrRail(p MRPage) { | |||
| 302 | 302 | if p.Box != nil { | |
| 303 | 303 | @mergeBox(p) | |
| 304 | 304 | } | |
| 305 | - | <section aria-labelledby="r-checks"> | |
| 306 | - | <h2 id="r-checks">Checks <span class="count">0</span></h2> | |
| 307 | - | <p class="dim">no checks</p> | |
| 308 | - | <p class="mute">there is no CI yet: nothing blocks the merge.</p> | |
| 309 | - | </section> | |
| 305 | + | @MRChecks(p.Checks) | |
| 310 | 306 | <section aria-labelledby="r-settings"> | |
| 311 | 307 | <h2 id="r-settings">Settings</h2> | |
| 312 | 308 | <dl class="kv"> | |
M internal/adapters/web/mr_templ.go +56 -52 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
| @@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) { | |||
| 128 | 128 | } else { | |
| 129 | 129 | { r.Short } | |
| 130 | 130 | } | |
| 131 | + | @CommitChecks(r.Checks) | |
| 131 | 132 | </td> | |
| 132 | 133 | <td class="c-sub">{ r.Subject }</td> | |
| 133 | 134 | <td class="c-when dim"> | |
M internal/adapters/web/pages_templ.go +17 -13 generated file: collapsed
M internal/adapters/web/refspage.go +4 -3
| @@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 43 | 43 | return a && !b | |
| 44 | 44 | }) | |
| 45 | 45 | for i, b := range branches { | |
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) | |
| 46 | + | p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0)) | |
| 47 | 47 | } | |
| 48 | 48 | tags := append([]domain.Ref(nil), d.tags...) | |
| 49 | 49 | sort.SliceStable(tags, func(i, j int) bool { | |
| 50 | 50 | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at | |
| 51 | 51 | }) | |
| 52 | 52 | for i, t := range tags { | |
| 53 | - | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 53 | + | p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 54 | 54 | } | |
| 55 | 55 | if len(tags) > 0 { | |
| 56 | 56 | p.Latest = tags[0].ShortName | |
| @@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 61 | 61 | return p | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 64 | + | func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 65 | 65 | id := r.CommitID.String() | |
| 66 | 66 | c := commits[id] | |
| 67 | 67 | row := RefRow{ | |
| @@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, | |||
| 71 | 71 | } | |
| 72 | 72 | if id != "" { | |
| 73 | 73 | row.CHref = repoHref(d.name(), "commit", id) | |
| 74 | + | row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id)) | |
| 74 | 75 | } | |
| 75 | 76 | return row | |
| 76 | 77 | } | |
M internal/adapters/web/repo.go +9 -0
| @@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 135 | 135 | if s.cfg.MergeRequests != nil { | |
| 136 | 136 | l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests")) | |
| 137 | 137 | } | |
| 138 | + | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 138 | 139 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 139 | 140 | ||
| 140 | 141 | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | |
| @@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 161 | 162 | return l | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 165 | + | // withActions adds the Actions tab when the server runs CI. | |
| 166 | + | func (s *Server) withActions(sections []Section, repo, current string) []Section { | |
| 167 | + | if s.cfg.Runs == nil { | |
| 168 | + | return sections | |
| 169 | + | } | |
| 170 | + | return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"}) | |
| 171 | + | } | |
| 172 | + | ||
| 164 | 173 | // cloneURL is the HTTPS clone URL of a repository. | |
| 165 | 174 | func (s *Server) cloneURL(name string) string { | |
| 166 | 175 | return s.cfg.PublicURL + "/" + name + ".git" | |
M internal/adapters/web/shell.templ +1 -0
| @@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 22 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | 24 | <script src={ l.Assets.Files } defer></script> | |
| 25 | + | <script src={ l.Assets.Log } defer></script> | |
| 25 | 26 | </head> | |
| 26 | 27 | <body | |
| 27 | 28 | data-ref={ l.RefPath } | |
M internal/adapters/web/shell_templ.go +155 -142 generated file: collapsed
A internal/adapters/web/static/css/65-ci.css +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a | |
| 3 | + | verdict, and every mark has its word beside it or in its label. */ | |
| 4 | + | .ck { | |
| 5 | + | display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px; | |
| 6 | + | border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none; | |
| 7 | + | } | |
| 8 | + | .ck-ok { background: var(--teal); border-color: var(--teal); } | |
| 9 | + | .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); } | |
| 10 | + | .ck-cancelled { border-style: dashed; } | |
| 11 | + | .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); } | |
| 12 | + | .ck-queued { border-style: dotted; } | |
| 13 | + | .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; } | |
| 14 | + | @keyframes ck-spin { to { transform: rotate(360deg); } } | |
| 15 | + | @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } } | |
| 16 | + | ||
| 17 | + | .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; } | |
| 18 | + | .c-ck { width: 1%; white-space: nowrap; } | |
| 19 | + | ||
| 20 | + | .runs .c-st { width: 1%; } | |
| 21 | + | .runs .c-dur, .runs .c-who { white-space: nowrap; } | |
| 22 | + | .runs tr[data-new] { background: var(--bg-raised); } | |
| 23 | + | ||
| 24 | + | .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); } | |
| 25 | + | .run-err, .job-err { | |
| 26 | + | max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere; | |
| 27 | + | background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s); | |
| 28 | + | font: inherit; font-size: var(--fs-s); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; } | |
| 32 | + | .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); } | |
| 33 | + | .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; } | |
| 34 | + | .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |
| 35 | + | .steps li { display: flex; align-items: center; gap: var(--s1); } | |
| 36 | + | .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); } | |
| 37 | + | .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); } | |
| 38 | + | .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); } | |
| 39 | + | ||
| 40 | + | .joblog { margin-top: var(--s4); } | |
| 41 | + | .log { | |
| 42 | + | margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto; | |
| 43 | + | font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s); | |
| 44 | + | } | |
| 45 | + | .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 46 | + | .log .lg:target { background: var(--bg-raised); } | |
| 47 | + | .log .lg time { flex: none; color: var(--ink-dim); user-select: none; } | |
| 48 | + | .log .lg .t { min-width: 0; } | |
| 49 | + | .log .group .t { font-weight: 700; } | |
| 50 | + | .log .endgroup { display: none; } | |
| 51 | + | .log .error .t { color: var(--red); } | |
| 52 | + | .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); } | |
| 53 | + | } | |
A internal/adapters/web/static/js/log.js +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | // log.js: the live log of a running job (page /<repo>/actions/runs/<id>/job/<id>). | |
| 2 | + | // The lines arrive as "ci-log" events (internal/adapters/web/actions.go, | |
| 3 | + | // logStream), each one the <li> of the lines of a batch, and are appended to the | |
| 4 | + | // list. The browser reconnects by itself with Last-Event-ID, the number of the | |
| 5 | + | // next line; "ci-end" closes the feed when the job is over. The page follows the | |
| 6 | + | // log (scrolls to its end) only while the reader is at the end already. A piece | |
| 7 | + | // that continues a line begun in an earlier batch (data-cont) is joined to the | |
| 8 | + | // last line of the list. | |
| 9 | + | window.ermiteLog = (() => { | |
| 10 | + | const nearEnd = () => window.innerHeight + window.scrollY >= document.body.scrollHeight - 80; | |
| 11 | + | ||
| 12 | + | // follow starts the feed of the box (#job-log) and returns the EventSource, | |
| 13 | + | // or null when the box is not a running job's. | |
| 14 | + | function follow(box) { | |
| 15 | + | if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return null; | |
| 16 | + | const list = box.querySelector('ol.log'); | |
| 17 | + | if (!list) return null; | |
| 18 | + | const es = new window.EventSource(box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0')); | |
| 19 | + | es.addEventListener('ci-log', (e) => { | |
| 20 | + | const t = document.createElement('template'); | |
| 21 | + | t.innerHTML = e.data; | |
| 22 | + | const atEnd = nearEnd(); | |
| 23 | + | for (const line of [...t.content.children]) { | |
| 24 | + | const last = list.children[list.children.length - 1]; | |
| 25 | + | if (line.hasAttribute('data-cont') && last) { | |
| 26 | + | const text = line.querySelector('.t'); | |
| 27 | + | const into = last.querySelector('.t'); | |
| 28 | + | if (text && into) into.textContent += text.textContent; | |
| 29 | + | } else { | |
| 30 | + | list.append(line); | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | if (atEnd) window.scrollTo(0, document.body.scrollHeight); | |
| 34 | + | }); | |
| 35 | + | es.addEventListener('ci-end', () => es.close()); | |
| 36 | + | es.onerror = () => { if (es.readyState === window.EventSource.CLOSED) es.close(); }; | |
| 37 | + | return es; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return { follow }; | |
| 41 | + | })(); | |
| 42 | + | ||
| 43 | + | document.addEventListener('DOMContentLoaded', () => window.ermiteLog.follow(document.getElementById('job-log'))); | |
M internal/adapters/web/view.go +5 -0
| @@ -135,6 +135,9 @@ type CommitRow struct { | |||
| 135 | 135 | Branches []string | |
| 136 | 136 | Tags []string | |
| 137 | 137 | Cursor bool | |
| 138 | + | // Checks is the combined state of the CI checks of the commit; empty when | |
| 139 | + | // it has none. | |
| 140 | + | Checks ChecksSummary | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | // SummaryPage is the repository summary. | |
| @@ -162,6 +165,8 @@ type LatestTag struct { | |||
| 162 | 165 | type RefRow struct { | |
| 163 | 166 | Name, Href, Short, CHref, Subject, When, WhenAt string | |
| 164 | 167 | Default, Cursor bool | |
| 168 | + | // Checks is the combined state of the CI checks of the commit. | |
| 169 | + | Checks ChecksSummary | |
| 165 | 170 | } | |
| 166 | 171 | ||
| 167 | 172 | // RefsPage lists the branches and tags of a repository. | |
M internal/adapters/web/web.go +15 -0
| @@ -54,6 +54,10 @@ type Config struct { | |||
| 54 | 54 | // there are no such pages, nor section for them. Leave it unset rather | |
| 55 | 55 | // than assigning a typed nil pointer. | |
| 56 | 56 | MergeRequests ports.MergeRequestService | |
| 57 | + | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 58 | + | // commits and merge requests. Without it there are no such pages. Leave it | |
| 59 | + | // unset rather than assigning a typed nil pointer. | |
| 60 | + | Runs ports.RunService | |
| 57 | 61 | // Events is the source of the event stream behind /-/events. Without | |
| 58 | 62 | // it the pages are not live and the route answers 404. Leave it unset | |
| 59 | 63 | // rather than assigning a typed nil pointer. | |
| @@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux { | |||
| 161 | 165 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | |
| 162 | 166 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | |
| 163 | 167 | } | |
| 168 | + | if s.cfg.Runs != nil { | |
| 169 | + | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 170 | + | mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions)) | |
| 171 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run)) | |
| 172 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job)) | |
| 173 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog) | |
| 174 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream) | |
| 175 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel)) | |
| 176 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |
| 177 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | |
| 178 | + | } | |
| 164 | 179 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 165 | 180 | return mux | |
| 166 | 181 | } | |
M internal/app/api_helpers_test.go +4 -0
| @@ -33,6 +33,8 @@ type clients struct { | |||
| 33 | 33 | mr ermiteconnect.MergeRequestServiceClient | |
| 34 | 34 | ||
| 35 | 35 | session ermiteconnect.SessionServiceClient | |
| 36 | + | runner ermiteconnect.RunnerServiceClient | |
| 37 | + | run ermiteconnect.RunServiceClient | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 38 | 40 | // bearerTransport adds the Authorization header of a secret, if any. | |
| @@ -81,6 +83,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||
| 81 | 83 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |
| 82 | 84 | ||
| 83 | 85 | session: ermiteconnect.NewSessionServiceClient(hc, base, opts...), | |
| 86 | + | runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...), | |
| 87 | + | run: ermiteconnect.NewRunServiceClient(hc, base, opts...), | |
| 84 | 88 | } | |
| 85 | 89 | } | |
| 86 | 90 | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
A internal/app/ci_e2e_test.go +447 -0
| @@ -0,0 +1,447 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 17 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 18 | + | ||
| 19 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/usecase" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // ciHarness is a server with a repository whose pushes run workflows, and a | |
| 26 | + | // fake runner that talks to the real Connect handlers. | |
| 27 | + | type ciHarness struct { | |
| 28 | + | *harness | |
| 29 | + | root *clients | |
| 30 | + | wt string | |
| 31 | + | repo string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const ciWorkflow = `name: ci | |
| 35 | + | on: | |
| 36 | + | push: | |
| 37 | + | branches: [main, feat] | |
| 38 | + | jobs: | |
| 39 | + | build: | |
| 40 | + | runs-on: ubuntu-latest | |
| 41 | + | steps: | |
| 42 | + | - run: echo hello | |
| 43 | + | test: | |
| 44 | + | needs: build | |
| 45 | + | runs-on: ubuntu-latest | |
| 46 | + | steps: | |
| 47 | + | - run: echo test | |
| 48 | + | ` | |
| 49 | + | ||
| 50 | + | func newCIHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness { | |
| 51 | + | t.Helper() | |
| 52 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = tweak }) | |
| 53 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 54 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | c.wt = h.workTree("main") | |
| 57 | + | return c | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // push commits the files to branch and pushes it. | |
| 61 | + | func (c *ciHarness) push(branch string, files map[string]string, msg string) string { | |
| 62 | + | c.t.Helper() | |
| 63 | + | if cur, err := c.git(c.wt, "rev-parse", "--abbrev-ref", "HEAD"); err != nil || strings.TrimSpace(cur) != branch { | |
| 64 | + | if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil { | |
| 65 | + | c.mustGit(c.wt, "checkout", "-q", "-b", branch) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | for name, content := range files { | |
| 69 | + | c.writeFile(c.wt, name, []byte(content)) | |
| 70 | + | } | |
| 71 | + | if msg != "" { | |
| 72 | + | c.mustGit(c.wt, "commit", "-q", "--amend", "-m", msg) | |
| 73 | + | } | |
| 74 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), branch) | |
| 75 | + | return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // registerRunner registers a runner and returns a fake that polls with it. | |
| 79 | + | func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner { | |
| 80 | + | c.t.Helper() | |
| 81 | + | res, err := c.root.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{ | |
| 82 | + | Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", | |
| 83 | + | })) | |
| 84 | + | require.NoError(c.t, err) | |
| 85 | + | return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), secret: res.Msg.GetRunnerToken(), api: c.api(res.Msg.GetRunnerToken())} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // fakeRunner is the runner side of the protocol, as a test. | |
| 89 | + | type fakeRunner struct { | |
| 90 | + | c *ciHarness | |
| 91 | + | id string | |
| 92 | + | secret string | |
| 93 | + | api *clients | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // runnerSecret is the runner token of a fake runner. | |
| 97 | + | func (c *ciHarness) runnerSecret(r *fakeRunner) string { return r.secret } | |
| 98 | + | ||
| 99 | + | // fetch polls once for a job. | |
| 100 | + | func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) { | |
| 101 | + | res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID})) | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, err | |
| 104 | + | } | |
| 105 | + | return res.Msg.GetJob(), nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // await polls until a job is given. | |
| 109 | + | func (r *fakeRunner) await() *pb.Job { | |
| 110 | + | r.c.t.Helper() | |
| 111 | + | deadline := time.Now().Add(15 * time.Second) | |
| 112 | + | for time.Now().Before(deadline) { | |
| 113 | + | j, err := r.fetch("") | |
| 114 | + | require.NoError(r.c.t, err) | |
| 115 | + | if j != nil { | |
| 116 | + | return j | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | r.c.t.Fatal("no job was given to the runner") | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // jobAPI is the client of a job: its token is the bearer secret. | |
| 124 | + | func (r *fakeRunner) jobAPI(j *pb.Job) *clients { return r.c.api(j.GetJobToken()) } | |
| 125 | + | ||
| 126 | + | func now() *timestamppb.Timestamp { return timestamppb.Now() } | |
| 127 | + | ||
| 128 | + | // runJob executes a job as a runner does: heartbeat, steps, log, completion. | |
| 129 | + | func (r *fakeRunner) runJob(j *pb.Job, conclusion pb.CiConclusion, outputs map[string]string, text ...string) { | |
| 130 | + | r.c.t.Helper() | |
| 131 | + | ctx, api := r.c.ctx, r.jobAPI(j) | |
| 132 | + | _, err := api.runner.Heartbeat(ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()})) | |
| 133 | + | require.NoError(r.c.t, err) | |
| 134 | + | start := now() | |
| 135 | + | _, err = api.runner.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: []*pb.StepUpdate{ | |
| 136 | + | {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start}, | |
| 137 | + | }})) | |
| 138 | + | require.NoError(r.c.t, err) | |
| 139 | + | var lines []*pb.LogLine | |
| 140 | + | for _, t := range text { | |
| 141 | + | lines = append(lines, &pb.LogLine{Time: now(), Text: t}) | |
| 142 | + | } | |
| 143 | + | if len(lines) > 0 { | |
| 144 | + | _, err = api.runner.AppendLog(ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: j.GetJobId(), Step: 2, Offset: 0, Lines: lines})) | |
| 145 | + | require.NoError(r.c.t, err) | |
| 146 | + | } | |
| 147 | + | step := pb.CiConclusion_CI_CONCLUSION_SUCCESS | |
| 148 | + | if conclusion != pb.CiConclusion_CI_CONCLUSION_SUCCESS { | |
| 149 | + | step = conclusion | |
| 150 | + | } | |
| 151 | + | res, err := api.runner.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |
| 152 | + | JobId: j.GetJobId(), Conclusion: conclusion, Outputs: outputs, | |
| 153 | + | Steps: []*pb.StepUpdate{ | |
| 154 | + | {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()}, | |
| 155 | + | {Number: 2, Name: "Run echo", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: step, StartedAt: start, CompletedAt: now()}, | |
| 156 | + | }, | |
| 157 | + | })) | |
| 158 | + | require.NoError(r.c.t, err) | |
| 159 | + | assert.True(r.c.t, res.Msg.GetApplied()) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // waitFor polls cond until it holds. | |
| 163 | + | func (c *ciHarness) waitFor(what string, cond func() bool) { | |
| 164 | + | c.t.Helper() | |
| 165 | + | require.Eventually(c.t, cond, 15*time.Second, 20*time.Millisecond, what) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (c *ciHarness) runs() []*pb.Run { | |
| 169 | + | c.t.Helper() | |
| 170 | + | res, err := c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: c.repo})) | |
| 171 | + | require.NoError(c.t, err) | |
| 172 | + | return res.Msg.GetRuns() | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (c *ciHarness) checks(sha string) []*pb.Check { | |
| 176 | + | c.t.Helper() | |
| 177 | + | res, err := c.root.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: c.repo, Sha: sha})) | |
| 178 | + | require.NoError(c.t, err) | |
| 179 | + | return res.Msg.GetChecks() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (c *ciHarness) getRun(id int64) *pb.GetRunResponse { | |
| 183 | + | c.t.Helper() | |
| 184 | + | res, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: c.repo, RunId: id})) | |
| 185 | + | require.NoError(c.t, err) | |
| 186 | + | return res.Msg | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func ciErrCode(err error) connect.Code { | |
| 190 | + | var ce *connect.Error | |
| 191 | + | if errors.As(err, &ce) { | |
| 192 | + | return ce.Code() | |
| 193 | + | } | |
| 194 | + | return connect.Code(0) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // AC3, AC5: a push runs the workflow on a runner; its jobs follow their needs; | |
| 198 | + | // the checks appear on the commit; the log is readable. | |
| 199 | + | func TestCIPushRunsAWorkflowOnARunner(t *testing.T) { | |
| 200 | + | c := newCIHarness(t, nil) | |
| 201 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 202 | + | sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 203 | + | ||
| 204 | + | c.waitFor("a run is created", func() bool { return len(c.runs()) == 1 }) | |
| 205 | + | run := c.runs()[0] | |
| 206 | + | assert.Equal(t, "ci", run.GetWorkflowName()) | |
| 207 | + | assert.Equal(t, "refs/heads/main", run.GetRef()) | |
| 208 | + | assert.Equal(t, sha, run.GetSha()) | |
| 209 | + | assert.Equal(t, "push", run.GetEvent()) | |
| 210 | + | assert.EqualValues(t, 1, run.GetNumber()) | |
| 211 | + | assert.Equal(t, "root", run.GetActor()) | |
| 212 | + | ||
| 213 | + | // Only the first job is ready: the second needs it. | |
| 214 | + | job := runner.await() | |
| 215 | + | assert.Equal(t, "build", job.GetJobKey()) | |
| 216 | + | assert.Equal(t, run.GetId(), job.GetRunId()) | |
| 217 | + | assert.Equal(t, sha, job.GetSha()) | |
| 218 | + | assert.Equal(t, "refs/heads/main", job.GetRef()) | |
| 219 | + | assert.Equal(t, "proj", job.GetRepo()) | |
| 220 | + | assert.Equal(t, ciWorkflow, job.GetWorkflowYaml()) | |
| 221 | + | assert.Equal(t, []string{"ubuntu-latest"}, job.GetLabels()) | |
| 222 | + | assert.EqualValues(t, 360, job.GetTimeoutMinutes()) | |
| 223 | + | assert.Equal(t, "{}", job.GetMatrix()) | |
| 224 | + | assert.True(t, strings.HasPrefix(job.GetJobToken(), "ermitejob_")) | |
| 225 | + | assert.Contains(t, job.GetContexts(), `"repository":"owner/proj"`) | |
| 226 | + | assert.Contains(t, job.GetContexts(), `"needs":{}`) | |
| 227 | + | ||
| 228 | + | // Assigned but not acknowledged: still queued for the owner. | |
| 229 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, c.getRun(run.GetId()).GetJobs()[0].GetStatus()) | |
| 230 | + | ||
| 231 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"artifact": "out.bin"}, "hello", "world") | |
| 232 | + | ||
| 233 | + | next := runner.await() | |
| 234 | + | assert.Equal(t, "test", next.GetJobKey()) | |
| 235 | + | assert.Contains(t, next.GetContexts(), `"needs":{"build":{"result":"success","outputs":{"artifact":"out.bin"}}}`) | |
| 236 | + | runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "tested") | |
| 237 | + | ||
| 238 | + | c.waitFor("the run completes", func() bool { | |
| 239 | + | return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 240 | + | }) | |
| 241 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion()) | |
| 242 | + | ||
| 243 | + | checks := c.checks(sha) | |
| 244 | + | require.Len(t, checks, 2) | |
| 245 | + | assert.Equal(t, "build", checks[0].GetName()) | |
| 246 | + | assert.Equal(t, "test", checks[1].GetName()) | |
| 247 | + | for _, ck := range checks { | |
| 248 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, ck.GetStatus()) | |
| 249 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, ck.GetConclusion()) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | detail := c.getRun(run.GetId()) | |
| 253 | + | require.Len(t, detail.GetJobs(), 2) | |
| 254 | + | steps := detail.GetJobs()[0].GetSteps() | |
| 255 | + | require.Len(t, steps, 2) | |
| 256 | + | assert.EqualValues(t, 0, steps[1].GetLogStart(), "step 2 has the lines") | |
| 257 | + | assert.EqualValues(t, 2, steps[1].GetLogEnd()) | |
| 258 | + | ||
| 259 | + | // The log is streamed back with its steps. | |
| 260 | + | stream, err := c.root.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: job.GetJobId()})) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | var got []string | |
| 263 | + | for stream.Receive() { | |
| 264 | + | for _, l := range stream.Msg().GetLines() { | |
| 265 | + | got = append(got, l.GetText()) | |
| 266 | + | assert.EqualValues(t, 2, l.GetStep()) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | require.NoError(t, stream.Err()) | |
| 270 | + | assert.Equal(t, []string{"hello", "world"}, got) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // AC5: merge refused while checks are pending or failing, allowed when they | |
| 274 | + | // pass, and when failing_checks_block is off. | |
| 275 | + | func TestCIMergeIsBlockedByChecks(t *testing.T) { | |
| 276 | + | c := newCIHarness(t, nil) | |
| 277 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 278 | + | c.push("main", map[string]string{"README": "x\n"}, "") | |
| 279 | + | ||
| 280 | + | feat := c.push("feat", map[string]string{".github/workflows/ci.yml": strings.ReplaceAll(ciWorkflow, " test:\n needs: build\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n", "")}, "") | |
| 281 | + | mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"})) | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | merge := func() error { | |
| 284 | + | _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat})) | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | c.waitFor("the feature run is queued", func() bool { return len(c.checks(feat)) == 1 }) | |
| 289 | + | err = merge() | |
| 290 | + | require.Error(t, err) | |
| 291 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a pending check blocks the merge") | |
| 292 | + | assert.Contains(t, err.Error(), "have not finished") | |
| 293 | + | ||
| 294 | + | job := runner.await() | |
| 295 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "boom") | |
| 296 | + | c.waitFor("the check fails", func() bool { | |
| 297 | + | cs := c.checks(feat) | |
| 298 | + | return len(cs) == 1 && cs[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 299 | + | }) | |
| 300 | + | err = merge() | |
| 301 | + | require.Error(t, err) | |
| 302 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err)) | |
| 303 | + | assert.Contains(t, err.Error(), "failing checks block the merge") | |
| 304 | + | ||
| 305 | + | // Off: the merge goes through. | |
| 306 | + | _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 307 | + | Repo: "proj", | |
| 308 | + | Settings: &pb.RepoSettings{FailingChecksBlock: false}, | |
| 309 | + | UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}}, | |
| 310 | + | })) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | require.NoError(t, merge()) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // AC2: a job token works for its job only, and not once the job has ended. | |
| 316 | + | func TestCIJobTokensAreScopedToTheirJob(t *testing.T) { | |
| 317 | + | c := newCIHarness(t, nil) | |
| 318 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 319 | + | c.push("main", map[string]string{".github/workflows/a.yml": ciWorkflow, ".github/workflows/b.yml": ciWorkflow}, "") | |
| 320 | + | c.waitFor("two runs", func() bool { return len(c.runs()) == 2 }) | |
| 321 | + | j1, j2 := runner.await(), runner.await() | |
| 322 | + | require.NotEqual(t, j1.GetJobId(), j2.GetJobId()) | |
| 323 | + | ||
| 324 | + | // Another job's id with this job's token: permission_denied. | |
| 325 | + | _, err := runner.jobAPI(j1).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j2.GetJobId()})) | |
| 326 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 327 | + | // A runner token is not a job token, and a stored token is neither. | |
| 328 | + | _, err = runner.api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()})) | |
| 329 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err)) | |
| 330 | + | _, err = c.root.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()})) | |
| 331 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err)) | |
| 332 | + | // A job token does not poll. | |
| 333 | + | _, err = runner.jobAPI(j1).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{})) | |
| 334 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err)) | |
| 335 | + | ||
| 336 | + | runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a") | |
| 337 | + | // After the end: failed_precondition with the JobEnded detail; CompleteJob | |
| 338 | + | // still answers with the stored result. | |
| 339 | + | _, err = runner.jobAPI(j1).runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{ | |
| 340 | + | JobId: j1.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "late"}}, | |
| 341 | + | })) | |
| 342 | + | require.Error(t, err) | |
| 343 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err)) | |
| 344 | + | var ce *connect.Error | |
| 345 | + | require.True(t, errors.As(err, &ce)) | |
| 346 | + | require.Len(t, ce.Details(), 1) | |
| 347 | + | msg, derr := ce.Details()[0].Value() | |
| 348 | + | require.NoError(t, derr) | |
| 349 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, msg.(*pb.JobEnded).GetConclusion()) | |
| 350 | + | ||
| 351 | + | res, err := runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |
| 352 | + | JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE, | |
| 353 | + | })) | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | assert.False(t, res.Msg.GetApplied(), "a second CompleteJob changes nothing") | |
| 356 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, res.Msg.GetConclusion(), "and answers the stored conclusion") | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // AC2: a job is given to exactly one runner, however many poll at once. | |
| 360 | + | func TestCIConcurrentFetchJobAssignsOnce(t *testing.T) { | |
| 361 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 300 * time.Millisecond }) | |
| 362 | + | var runners []*fakeRunner | |
| 363 | + | for i := range 8 { | |
| 364 | + | runners = append(runners, c.registerRunner(fmt.Sprintf("r%d", i), "ubuntu-latest")) | |
| 365 | + | } | |
| 366 | + | var wf strings.Builder | |
| 367 | + | wf.WriteString("on: push\njobs:\n") | |
| 368 | + | for i := range 3 { | |
| 369 | + | fmt.Fprintf(&wf, " j%d:\n runs-on: ubuntu-latest\n steps:\n - run: echo %d\n", i, i) | |
| 370 | + | } | |
| 371 | + | c.push("main", map[string]string{".github/workflows/many.yml": wf.String()}, "") | |
| 372 | + | c.waitFor("the run exists", func() bool { return len(c.runs()) == 1 }) | |
| 373 | + | ||
| 374 | + | var mu sync.Mutex | |
| 375 | + | got := map[string]int{} | |
| 376 | + | var wg sync.WaitGroup | |
| 377 | + | for _, r := range runners { | |
| 378 | + | wg.Add(1) | |
| 379 | + | go func() { | |
| 380 | + | defer wg.Done() | |
| 381 | + | for range 20 { | |
| 382 | + | j, err := r.fetch("") | |
| 383 | + | if err != nil { | |
| 384 | + | return | |
| 385 | + | } | |
| 386 | + | if j != nil { | |
| 387 | + | mu.Lock() | |
| 388 | + | got[j.GetJobId()]++ | |
| 389 | + | mu.Unlock() | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | }() | |
| 393 | + | } | |
| 394 | + | wg.Wait() | |
| 395 | + | assert.Len(t, got, 3, "every job was given") | |
| 396 | + | for id, n := range got { | |
| 397 | + | assert.Equal(t, 1, n, "job %s was given %d times", id, n) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // AC2: the first heartbeat acknowledges; without it the assignment is | |
| 402 | + | // cancelled and the job is queued again, three times in a row at most. | |
| 403 | + | func TestCIUnacknowledgedAssignmentGoesBackToTheQueue(t *testing.T) { | |
| 404 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 405 | + | cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 150*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 406 | + | }) | |
| 407 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 408 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 409 | + | ||
| 410 | + | first := runner.await() | |
| 411 | + | // No heartbeat: the token is revoked and the job comes back with a new one. | |
| 412 | + | second := runner.await() | |
| 413 | + | assert.Equal(t, first.GetJobId(), second.GetJobId()) | |
| 414 | + | assert.NotEqual(t, first.GetJobToken(), second.GetJobToken()) | |
| 415 | + | _, err := runner.jobAPI(first).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: first.GetJobId()})) | |
| 416 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err), "the replaced token is rejected") | |
| 417 | + | ||
| 418 | + | // The third assignment is the last: ignoring it fails the job. | |
| 419 | + | runner.await() | |
| 420 | + | c.waitFor("the job fails", func() bool { | |
| 421 | + | return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 422 | + | }) | |
| 423 | + | detail := c.getRun(c.runs()[0].GetId()) | |
| 424 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, detail.GetJobs()[0].GetConclusion()) | |
| 425 | + | assert.Contains(t, detail.GetJobs()[0].GetError(), "acknowledged") | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // A retried poll with the same poll_id returns the same assignment. | |
| 429 | + | func TestCIRetriedPollReturnsTheSameAssignment(t *testing.T) { | |
| 430 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 200 * time.Millisecond }) | |
| 431 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 432 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 433 | + | c.waitFor("a run", func() bool { return len(c.runs()) == 1 }) | |
| 434 | + | ||
| 435 | + | var first *pb.Job | |
| 436 | + | c.waitFor("a job", func() bool { | |
| 437 | + | j, err := runner.fetch("poll-1") | |
| 438 | + | require.NoError(t, err) | |
| 439 | + | first = j | |
| 440 | + | return j != nil | |
| 441 | + | }) | |
| 442 | + | again, err := runner.fetch("poll-1") | |
| 443 | + | require.NoError(t, err) | |
| 444 | + | require.NotNil(t, again) | |
| 445 | + | assert.Equal(t, first.GetJobId(), again.GetJobId()) | |
| 446 | + | assert.Equal(t, first.GetJobToken(), again.GetJobToken()) | |
| 447 | + | } | |
A internal/app/ci_flow_test.go +686 -0
| @@ -0,0 +1,686 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "connectrpc.com/connect" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | + | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | + | "bnema.dev/ermite/internal/usecase" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const oneJob = `on: | |
| 27 | + | push: | |
| 28 | + | %s | |
| 29 | + | jobs: | |
| 30 | + | j: | |
| 31 | + | runs-on: ubuntu-latest | |
| 32 | + | steps: | |
| 33 | + | - run: echo hi | |
| 34 | + | ` | |
| 35 | + | ||
| 36 | + | func workflowWith(filter string) string { return fmt.Sprintf(oneJob, filter) } | |
| 37 | + | ||
| 38 | + | // AC3: filters, tags, skip directives, invalid files and deletions. | |
| 39 | + | func TestCITriggerRules(t *testing.T) { | |
| 40 | + | c := newCIHarness(t, nil) | |
| 41 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 42 | + | count := func() int { return len(c.runs()) } | |
| 43 | + | settle := func() { time.Sleep(300 * time.Millisecond) } // a push that runs nothing leaves nothing to wait for | |
| 44 | + | ||
| 45 | + | // A workflow with a branch filter does not run for another branch. | |
| 46 | + | c.push("main", map[string]string{".github/workflows/only-main.yml": workflowWith("branches: [main]"), "src/a.go": "package a\n"}, "") | |
| 47 | + | c.waitFor("main runs", func() bool { return count() == 1 }) | |
| 48 | + | c.push("other", map[string]string{"x.txt": "x\n"}, "") | |
| 49 | + | settle() | |
| 50 | + | assert.Equal(t, 1, count(), "branches: [main] does not run for other") | |
| 51 | + | ||
| 52 | + | // A paths filter looks at the files of the push. | |
| 53 | + | c.push("main", map[string]string{".github/workflows/paths.yml": workflowWith("paths: ['docs/**']")}, "") | |
| 54 | + | c.waitFor("only-main runs again for the new commit", func() bool { return count() == 2 }) | |
| 55 | + | c.push("main", map[string]string{"docs/readme.md": "doc\n"}, "") | |
| 56 | + | c.waitFor("both run for docs", func() bool { return count() == 4 }) | |
| 57 | + | c.push("main", map[string]string{"src/b.go": "package b\n"}, "") | |
| 58 | + | c.waitFor("only-main runs for src", func() bool { return count() == 5 }) | |
| 59 | + | settle() | |
| 60 | + | assert.Equal(t, 5, count(), "the paths filter kept paths.yml out") | |
| 61 | + | ||
| 62 | + | // [skip ci] on the head commit. | |
| 63 | + | c.push("main", map[string]string{"src/c.go": "package c\n"}, "wip [skip ci]") | |
| 64 | + | settle() | |
| 65 | + | assert.Equal(t, 5, count()) | |
| 66 | + | ||
| 67 | + | // An invalid workflow file is a failed run without a job. | |
| 68 | + | c.push("main", map[string]string{".github/workflows/broken.yml": "on: push\njobs:\n j:\n steps: [\n"}, "") | |
| 69 | + | c.waitFor("the invalid file gives a run", func() bool { | |
| 70 | + | for _, r := range c.runs() { | |
| 71 | + | if r.GetWorkflowPath() == ".github/workflows/broken.yml" { | |
| 72 | + | return true | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return false | |
| 76 | + | }) | |
| 77 | + | var broken *pb.Run | |
| 78 | + | for _, r := range c.runs() { | |
| 79 | + | if r.GetWorkflowPath() == ".github/workflows/broken.yml" { | |
| 80 | + | broken = r | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, broken.GetStatus()) | |
| 84 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, broken.GetConclusion()) | |
| 85 | + | assert.NotEmpty(t, broken.GetError()) | |
| 86 | + | assert.Empty(t, c.getRun(broken.GetId()).GetJobs(), "an invalid file has no job") | |
| 87 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 88 | + | var failed []string | |
| 89 | + | for _, ck := range c.checks(sha) { | |
| 90 | + | if ck.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE { | |
| 91 | + | failed = append(failed, ck.GetName()) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | assert.Equal(t, []string{".github/workflows/broken.yml"}, failed, "and a failed check on the commit") | |
| 95 | + | ||
| 96 | + | // A push never fails for CI: the push above succeeded (mustGit). A deletion | |
| 97 | + | // runs nothing. | |
| 98 | + | before := count() | |
| 99 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other") | |
| 100 | + | settle() | |
| 101 | + | assert.Equal(t, before, count()) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // AC3: tags run tag-filtered workflows; more than 3 tags in one push run none. | |
| 105 | + | func TestCITags(t *testing.T) { | |
| 106 | + | c := newCIHarness(t, nil) | |
| 107 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 108 | + | c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "") | |
| 109 | + | settle := func() { time.Sleep(300 * time.Millisecond) } | |
| 110 | + | settle() | |
| 111 | + | assert.Empty(t, c.runs(), "a tags-only workflow does not run for a branch") | |
| 112 | + | ||
| 113 | + | c.mustGit(c.wt, "tag", "v1") | |
| 114 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |
| 115 | + | c.waitFor("a tag runs", func() bool { return len(c.runs()) == 1 }) | |
| 116 | + | assert.Equal(t, "refs/tags/v1", c.runs()[0].GetRef()) | |
| 117 | + | ||
| 118 | + | for _, tag := range []string{"v2", "v3", "v4", "v5"} { | |
| 119 | + | c.mustGit(c.wt, "tag", tag) | |
| 120 | + | } | |
| 121 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2", "v3", "v4", "v5") | |
| 122 | + | settle() | |
| 123 | + | assert.Len(t, c.runs(), 1, "more than 3 tags in one push run nothing") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // AC6: cancelling a run asks the runner to stop through WatchJob; a runner that | |
| 127 | + | // does not answer is ended by the server. | |
| 128 | + | func TestCICancelReachesTheRunnerAndIsEnforced(t *testing.T) { | |
| 129 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 130 | + | cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 400*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 131 | + | }) | |
| 132 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 133 | + | c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 134 | + | job := runner.await() | |
| 135 | + | api := runner.jobAPI(job) | |
| 136 | + | _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | ||
| 139 | + | watch, err := api.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: job.GetJobId()})) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | run := c.runs()[0] | |
| 142 | + | ||
| 143 | + | // A reader without ci:write cannot cancel. | |
| 144 | + | reader := c.api(c.token(scopes(domain.ScopeRepoRead))) | |
| 145 | + | _, err = reader.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 146 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 147 | + | ||
| 148 | + | _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | // Cancelling again changes nothing. | |
| 151 | + | _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ||
| 154 | + | var cancel *pb.CancelRequest | |
| 155 | + | for watch.Receive() { | |
| 156 | + | if m := watch.Msg().GetCancel(); m != nil { | |
| 157 | + | cancel = m | |
| 158 | + | break | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | require.NotNil(t, cancel, "the runner is told") | |
| 162 | + | assert.Equal(t, pb.CancelReason_CANCEL_REASON_USER, cancel.GetReason()) | |
| 163 | + | ||
| 164 | + | // The queued job that needs it never ran: cancelled at once. The running | |
| 165 | + | // one is ended by the server after the grace period. | |
| 166 | + | c.waitFor("the server ends the job", func() bool { | |
| 167 | + | return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 168 | + | }) | |
| 169 | + | detail := c.getRun(run.GetId()) | |
| 170 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, detail.GetRun().GetConclusion()) | |
| 171 | + | for _, j := range detail.GetJobs() { | |
| 172 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion(), j.GetKey()) | |
| 173 | + | } | |
| 174 | + | assert.Contains(t, detail.GetJobs()[0].GetError(), "did not answer") | |
| 175 | + | ||
| 176 | + | // The runner's late CompleteJob returns the server's result. | |
| 177 | + | res, err := api.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |
| 178 | + | JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED, | |
| 179 | + | })) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | assert.False(t, res.Msg.GetApplied()) | |
| 182 | + | ||
| 183 | + | // A completed run cannot be cancelled. | |
| 184 | + | _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 185 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err)) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // A running job without heartbeats fails with GitHub's lost-runner message. | |
| 189 | + | func TestCILostRunnerFailsTheJob(t *testing.T) { | |
| 190 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 191 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 192 | + | }) | |
| 193 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 194 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 195 | + | job := runner.await() | |
| 196 | + | _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | ||
| 199 | + | c.waitFor("the job is lost", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 200 | + | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 201 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 202 | + | assert.Contains(t, j.GetError(), "The self-hosted runner: alpha lost communication with the server") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // AC6: a re-run is a new attempt of the same run; the failed-only variant keeps | |
| 206 | + | // the jobs that passed. | |
| 207 | + | func TestCIRerun(t *testing.T) { | |
| 208 | + | c := newCIHarness(t, nil) | |
| 209 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 210 | + | c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 211 | + | run := func() *pb.Run { return c.runs()[0] } | |
| 212 | + | ||
| 213 | + | build := runner.await() | |
| 214 | + | runner.runJob(build, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok") | |
| 215 | + | test := runner.await() | |
| 216 | + | runner.runJob(test, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "red") | |
| 217 | + | c.waitFor("the run fails", func() bool { return run().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 218 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run().GetConclusion()) | |
| 219 | + | id, number := run().GetId(), run().GetNumber() | |
| 220 | + | ||
| 221 | + | // A running run cannot be re-run; a completed one can, by who has ci:write. | |
| 222 | + | reader := c.api(c.token(scopes(domain.ScopeRepoRead))) | |
| 223 | + | _, err := reader.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id})) | |
| 224 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 225 | + | ||
| 226 | + | res, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true})) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | assert.Equal(t, id, res.Msg.GetRun().GetId()) | |
| 229 | + | assert.Equal(t, number, res.Msg.GetRun().GetNumber()) | |
| 230 | + | assert.EqualValues(t, 2, res.Msg.GetRun().GetAttempt()) | |
| 231 | + | ||
| 232 | + | // build keeps its attempt-1 result; only test runs again. | |
| 233 | + | again := runner.await() | |
| 234 | + | assert.Equal(t, "test", again.GetJobKey()) | |
| 235 | + | assert.EqualValues(t, 2, again.GetAttempt()) | |
| 236 | + | runner.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "green") | |
| 237 | + | c.waitFor("attempt 2 passes", func() bool { | |
| 238 | + | r := run() | |
| 239 | + | return r.GetAttempt() == 2 && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 240 | + | }) | |
| 241 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, run().GetConclusion()) | |
| 242 | + | detail := c.getRun(id) | |
| 243 | + | require.Len(t, detail.GetJobs(), 2) | |
| 244 | + | assert.EqualValues(t, 1, detail.GetJobs()[0].GetAttempt(), "build was kept from attempt 1") | |
| 245 | + | assert.EqualValues(t, 2, detail.GetJobs()[1].GetAttempt()) | |
| 246 | + | ||
| 247 | + | // Attempt 1 is still readable, with its failure. | |
| 248 | + | first, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: id, Attempt: 1})) | |
| 249 | + | require.NoError(t, err) | |
| 250 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, first.Msg.GetRun().GetConclusion()) | |
| 251 | + | assert.EqualValues(t, 2, first.Msg.GetLatestAttempt()) | |
| 252 | + | ||
| 253 | + | // Re-running a run with nothing failed is refused with failed-only. | |
| 254 | + | _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true})) | |
| 255 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err)) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // A matrix is expanded when its job is ready, with fail-fast. | |
| 259 | + | func TestCIMatrixAndFailFast(t *testing.T) { | |
| 260 | + | c := newCIHarness(t, nil) | |
| 261 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 262 | + | c.push("main", map[string]string{".github/workflows/m.yml": `on: push | |
| 263 | + | jobs: | |
| 264 | + | build: | |
| 265 | + | runs-on: ubuntu-latest | |
| 266 | + | strategy: | |
| 267 | + | matrix: | |
| 268 | + | go: ['1.26', '1.27'] | |
| 269 | + | steps: | |
| 270 | + | - run: echo ${{ matrix.go }} | |
| 271 | + | after: | |
| 272 | + | needs: build | |
| 273 | + | if: always() | |
| 274 | + | runs-on: ubuntu-latest | |
| 275 | + | steps: | |
| 276 | + | - run: echo done | |
| 277 | + | `}, "") | |
| 278 | + | a := runner.await() | |
| 279 | + | b := runner.await() | |
| 280 | + | assert.Equal(t, "build", a.GetJobKey()) | |
| 281 | + | assert.JSONEq(t, `{"go":"1.26"}`, a.GetMatrix()) | |
| 282 | + | assert.JSONEq(t, `{"go":"1.27"}`, b.GetMatrix()) | |
| 283 | + | assert.Contains(t, a.GetContexts(), `"job-total":2`) | |
| 284 | + | ||
| 285 | + | jobs := c.getRun(c.runs()[0].GetId()).GetJobs() | |
| 286 | + | var names []string | |
| 287 | + | for _, j := range jobs { | |
| 288 | + | names = append(names, j.GetName()) | |
| 289 | + | } | |
| 290 | + | assert.Equal(t, []string{"build (1.26)", "build (1.27)", "after"}, names) | |
| 291 | + | ||
| 292 | + | // The first fails: fail-fast asks the second to stop. | |
| 293 | + | apiB := runner.jobAPI(b) | |
| 294 | + | _, err := apiB.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: b.GetJobId()})) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | watch, err := apiB.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: b.GetJobId()})) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") | |
| 299 | + | var reason pb.CancelReason | |
| 300 | + | for watch.Receive() { | |
| 301 | + | if m := watch.Msg().GetCancel(); m != nil { | |
| 302 | + | reason = m.GetReason() | |
| 303 | + | break | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | assert.Equal(t, pb.CancelReason_CANCEL_REASON_FAIL_FAST, reason) | |
| 307 | + | _, err = apiB.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED})) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | ||
| 310 | + | // "after" has if: always(), so it runs once both ended. | |
| 311 | + | after := runner.await() | |
| 312 | + | assert.Equal(t, "after", after.GetJobKey()) | |
| 313 | + | assert.Contains(t, after.GetContexts(), `"result":"failure"`) | |
| 314 | + | runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "done") | |
| 315 | + | c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 316 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion()) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // A job with a feature of a later phase fails clearly, never silently. | |
| 320 | + | func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | |
| 321 | + | c := newCIHarness(t, nil) | |
| 322 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 323 | + | c.push("main", map[string]string{".github/workflows/u.yml": `on: push | |
| 324 | + | jobs: | |
| 325 | + | j: | |
| 326 | + | runs-on: ubuntu-latest | |
| 327 | + | steps: | |
| 328 | + | - uses: actions/checkout@v4 | |
| 329 | + | `}, "") | |
| 330 | + | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 331 | + | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 332 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 333 | + | assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError()) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // A job for a label no runner has is "waiting for a runner" and fails after the | |
| 337 | + | // queue timeout. | |
| 338 | + | func TestCIQueueTimeout(t *testing.T) { | |
| 339 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.QueueTimeout, cfg.Tick = 300*time.Millisecond, 20*time.Millisecond }) | |
| 340 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 341 | + | c.push("main", map[string]string{".github/workflows/q.yml": strings.Replace(workflowWith("branches: [main]"), "ubuntu-latest", "gpu", 1)}, "") | |
| 342 | + | c.waitFor("the job fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 343 | + | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 344 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 345 | + | assert.Contains(t, j.GetError(), "was not acquired") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // The runner API validates what it is given. | |
| 349 | + | func TestCIRunnerRegistrationRules(t *testing.T) { | |
| 350 | + | c := newCIHarness(t, nil) | |
| 351 | + | reg := func(api *clients, name string, replace bool) error { | |
| 352 | + | _, err := api.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{ | |
| 353 | + | Name: name, Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: replace, | |
| 354 | + | })) | |
| 355 | + | return err | |
| 356 | + | } | |
| 357 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(reg(c.api(""), "a", false))) | |
| 358 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRepoRead))), "a", false))) | |
| 359 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRunnerRegister), "proj")), "a", false)), "a repository-restricted token cannot register a runner") | |
| 360 | + | reg1 := c.api(c.token(scopes(domain.ScopeRunnerRegister))) | |
| 361 | + | require.NoError(t, reg(reg1, "a", false)) | |
| 362 | + | assert.Equal(t, connect.CodeAlreadyExists, ciErrCode(reg(reg1, "a", false))) | |
| 363 | + | require.NoError(t, reg(reg1, "a", true), "replace") | |
| 364 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(reg(reg1, "bad name!", false))) | |
| 365 | + | ||
| 366 | + | list, err := reg1.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | require.Len(t, list.Msg.GetRunners(), 1) | |
| 369 | + | assert.Equal(t, []string{"linux", "self-hosted", "x64"}, list.Msg.GetRunners()[0].GetLabels(), "implicit labels, sorted") | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // AC2: after a restart of the server, running jobs go on with their tokens, | |
| 373 | + | // queued jobs stay queued, and a job whose runner kept quiet during the outage | |
| 374 | + | // is not failed for it. | |
| 375 | + | func TestCIRestartKeepsRunningAndQueuedJobs(t *testing.T) { | |
| 376 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.HeartbeatDeadline = 2 * time.Second }) | |
| 377 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 378 | + | c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 379 | + | job := runner.await() // "build"; "test" waits for it | |
| 380 | + | api := runner.jobAPI(job) | |
| 381 | + | _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | _, err = api.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{ | |
| 384 | + | JobId: job.GetJobId(), Step: 2, Offset: 0, Lines: []*pb.LogLine{{Time: now(), Text: "before the restart"}}, | |
| 385 | + | })) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | ||
| 388 | + | // Stop the server and start another on the same data directory. | |
| 389 | + | cfg, hookExe := c.forge.cfg, binary(t) | |
| 390 | + | c.srv.Close() | |
| 391 | + | c.forge.Close(c.ctx) | |
| 392 | + | f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, ci: func(cc *usecase.CIConfig) { cc.HeartbeatDeadline = 2 * time.Second }}) | |
| 393 | + | require.NoError(t, err) | |
| 394 | + | t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) }) | |
| 395 | + | srv := httptest.NewServer(f2.server.Handler()) | |
| 396 | + | t.Cleanup(srv.Close) | |
| 397 | + | api2 := newClients(srv.URL+"/-/api", job.GetJobToken()) | |
| 398 | + | ||
| 399 | + | // The job token still works, and the log goes on from where it was. | |
| 400 | + | _, err = api2.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 401 | + | require.NoError(t, err, "the job token survives a restart") | |
| 402 | + | next, err := api2.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{ | |
| 403 | + | JobId: job.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "after the restart"}}, | |
| 404 | + | })) | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | assert.EqualValues(t, 2, next.Msg.GetNextOffset()) | |
| 407 | + | ||
| 408 | + | root2 := newClients(srv.URL+"/-/api", adminToken) | |
| 409 | + | runs, err := root2.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"})) | |
| 410 | + | require.NoError(t, err) | |
| 411 | + | require.Len(t, runs.Msg.GetRuns(), 1) | |
| 412 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_IN_PROGRESS, runs.Msg.GetRuns()[0].GetStatus()) | |
| 413 | + | ||
| 414 | + | // Finish the job on the new server: its dependant is planned and ready. | |
| 415 | + | _, err = api2.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |
| 416 | + | JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, | |
| 417 | + | })) | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | got := make(chan *pb.Job, 1) | |
| 420 | + | go func() { | |
| 421 | + | for range 50 { | |
| 422 | + | res, err := newClients(srv.URL+"/-/api", c.runnerSecret(runner)).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{})) | |
| 423 | + | if err == nil && res.Msg.GetJob() != nil { | |
| 424 | + | got <- res.Msg.GetJob() | |
| 425 | + | return | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | got <- nil | |
| 429 | + | }() | |
| 430 | + | select { | |
| 431 | + | case j := <-got: | |
| 432 | + | require.NotNil(t, j, "the runner token and the queue survive a restart") | |
| 433 | + | assert.Equal(t, "test", j.GetJobKey()) | |
| 434 | + | case <-time.After(30 * time.Second): | |
| 435 | + | t.Fatal("no job after the restart") | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // AC5: a head without any check merges, and so does one whose checks passed; a | |
| 440 | + | // merge runs the push workflows on the target. | |
| 441 | + | func TestCIMergeWithoutChecksAndWithPassingChecks(t *testing.T) { | |
| 442 | + | c := newCIHarness(t, nil) | |
| 443 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 444 | + | c.push("main", map[string]string{"README": "x\n"}, "") | |
| 445 | + | headOf := func(branch string) string { return strings.TrimSpace(c.bareGit("proj", "rev-parse", branch)) } | |
| 446 | + | merge := func(n int64, head string) error { | |
| 447 | + | _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head})) | |
| 448 | + | return err | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // No workflow on the branch: no check, the merge goes through. | |
| 452 | + | c.push("plain", map[string]string{"a.txt": "a\n"}, "") | |
| 453 | + | mr1, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "plain", SourceBranch: "plain"})) | |
| 454 | + | require.NoError(t, err) | |
| 455 | + | require.NoError(t, merge(mr1.Msg.GetMergeRequest().GetNumber(), headOf("plain"))) | |
| 456 | + | ||
| 457 | + | // A branch with a workflow: merge after its check passed. The merge itself | |
| 458 | + | // triggers the workflow on main (GitHub runs push workflows on a merge). | |
| 459 | + | c.mustGit(c.wt, "checkout", "-q", "main") | |
| 460 | + | c.mustGit(c.wt, "pull", "-q", c.urlAuth(adminToken, "/proj.git"), "main") | |
| 461 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "ci") | |
| 462 | + | c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(strings.Replace(workflowWith("branches: [main, ci]"), "echo hi", "echo ci", 1))) | |
| 463 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "ci") | |
| 464 | + | head := headOf("ci") | |
| 465 | + | c.waitFor("the run on ci", func() bool { return len(c.checks(head)) == 1 }) | |
| 466 | + | mr2, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "ci", SourceBranch: "ci"})) | |
| 467 | + | require.NoError(t, err) | |
| 468 | + | job := runner.await() | |
| 469 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok") | |
| 470 | + | c.waitFor("the check passes", func() bool { | |
| 471 | + | cs := c.checks(head) | |
| 472 | + | return len(cs) == 1 && cs[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_SUCCESS | |
| 473 | + | }) | |
| 474 | + | require.NoError(t, merge(mr2.Msg.GetMergeRequest().GetNumber(), head)) | |
| 475 | + | ||
| 476 | + | // The fast-forward merge moved main to the same commit: the workflow runs | |
| 477 | + | // again for main, with the owner as the actor. | |
| 478 | + | c.waitFor("the merge triggers a run on main", func() bool { | |
| 479 | + | for _, r := range c.runs() { | |
| 480 | + | if r.GetRef() == "refs/heads/main" && r.GetSha() == head { | |
| 481 | + | return true | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | return false | |
| 485 | + | }) | |
| 486 | + | for _, r := range c.runs() { | |
| 487 | + | if r.GetRef() == "refs/heads/main" { | |
| 488 | + | assert.Equal(t, "owner", r.GetActor()) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // Repository deletion removes the runs, the checks and the logs; a repository | |
| 494 | + | // created again starts at run number 1. | |
| 495 | + | func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) { | |
| 496 | + | c := newCIHarness(t, nil) | |
| 497 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 498 | + | sha := c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 499 | + | job := runner.await() | |
| 500 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "line") | |
| 501 | + | logDir := filepath.Join(c.data, "ci", "logs") | |
| 502 | + | entries, err := os.ReadDir(logDir) | |
| 503 | + | require.NoError(t, err) | |
| 504 | + | require.NotEmpty(t, entries, "the log is a file under ci/logs") | |
| 505 | + | ||
| 506 | + | _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"})) | |
| 507 | + | require.NoError(t, err) | |
| 508 | + | _, err = c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"})) | |
| 509 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 510 | + | entries, err = os.ReadDir(logDir) | |
| 511 | + | require.NoError(t, err) | |
| 512 | + | assert.Empty(t, entries, "the logs are removed with the repository") | |
| 513 | + | ||
| 514 | + | _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 515 | + | require.NoError(t, err) | |
| 516 | + | assert.Empty(t, c.checks(sha), "no check survives the repository") | |
| 517 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0] + "# again\n"}, "again") | |
| 518 | + | c.waitFor("a run", func() bool { return len(c.runs()) == 1 }) | |
| 519 | + | assert.EqualValues(t, 1, c.runs()[0].GetNumber(), "run numbers start again with the repository") | |
| 520 | + | assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused") | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // The events of a run and its jobs are published on the repository topic. | |
| 524 | + | func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) { | |
| 525 | + | c := newCIHarness(t, nil) | |
| 526 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 527 | + | s := c.watch(c.root, "", "repo:proj") | |
| 528 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 529 | + | job := runner.await() | |
| 530 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 531 | + | ||
| 532 | + | var runUpdates, jobUpdates int | |
| 533 | + | var lastRun *pb.Run | |
| 534 | + | deadline := time.After(10 * time.Second) | |
| 535 | + | for lastRun.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED { | |
| 536 | + | select { | |
| 537 | + | case ev := <-s.events: | |
| 538 | + | if r := ev.GetRunUpdated(); r != nil { | |
| 539 | + | runUpdates++ | |
| 540 | + | if r.GetRun().GetVersion() >= lastRun.GetVersion() { | |
| 541 | + | lastRun = r.GetRun() | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | if ev.GetJobUpdated() != nil { | |
| 545 | + | jobUpdates++ | |
| 546 | + | } | |
| 547 | + | case <-deadline: | |
| 548 | + | t.Fatalf("no completed run event: %d run and %d job events", runUpdates, jobUpdates) | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | assert.GreaterOrEqual(t, runUpdates, 2) | |
| 552 | + | assert.GreaterOrEqual(t, jobUpdates, 2) | |
| 553 | + | ||
| 554 | + | // They are never sent on "home". | |
| 555 | + | home := c.watch(c.root, "", "home") | |
| 556 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()})) | |
| 557 | + | require.NoError(t, err) | |
| 558 | + | c.sentinel(t, home, "sentinel") | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | // A new branch at a commit that already exists, and a branch rewound to an | |
| 562 | + | // ancestor, bring no new commit: GitHub still runs the push workflows (those | |
| 563 | + | // without a paths filter, as no file changed). | |
| 564 | + | func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) { | |
| 565 | + | c := newCIHarness(t, nil) | |
| 566 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 567 | + | c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "") | |
| 568 | + | c.waitFor("main runs", func() bool { return len(c.runs()) == 1 }) | |
| 569 | + | first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 570 | + | ||
| 571 | + | // A new branch at the commit main already has. | |
| 572 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same") | |
| 573 | + | c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 }) | |
| 574 | + | assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef()) | |
| 575 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |
| 576 | + | ||
| 577 | + | // A branch moved forward, then rewound to its ancestor (a forced push). | |
| 578 | + | c.push("main", map[string]string{"a.txt": "a\n"}, "") | |
| 579 | + | c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 }) | |
| 580 | + | c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main") | |
| 581 | + | c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 }) | |
| 582 | + | assert.Equal(t, first, c.runs()[0].GetSha()) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // CI is on once a runner is registered: before that a push makes no run and no | |
| 586 | + | // check; after, it does. | |
| 587 | + | func TestCINoRunnerNoRunsNoChecks(t *testing.T) { | |
| 588 | + | c := newCIHarness(t, nil) | |
| 589 | + | sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 590 | + | time.Sleep(400 * time.Millisecond) | |
| 591 | + | assert.Empty(t, c.runs()) | |
| 592 | + | assert.Empty(t, c.checks(sha)) | |
| 593 | + | ||
| 594 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 595 | + | c.push("main", map[string]string{"a.txt": "a\n"}, "") | |
| 596 | + | c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 }) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // A job that fail-fast cancelled while its assignment was not acknowledged is | |
| 600 | + | // over when the assignment is cancelled: it is not given to a runner again. | |
| 601 | + | func TestCICancelledUnacknowledgedAssignmentIsNotAssignedAgain(t *testing.T) { | |
| 602 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 603 | + | cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 604 | + | }) | |
| 605 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 606 | + | c.push("main", map[string]string{".github/workflows/m.yml": `on: push | |
| 607 | + | jobs: | |
| 608 | + | build: | |
| 609 | + | runs-on: ubuntu-latest | |
| 610 | + | strategy: | |
| 611 | + | matrix: | |
| 612 | + | n: [1, 2] | |
| 613 | + | steps: | |
| 614 | + | - run: echo ${{ matrix.n }} | |
| 615 | + | `}, "") | |
| 616 | + | a, b := runner.await(), runner.await() // both assigned, neither acknowledged yet | |
| 617 | + | assert.NotEqual(t, a.GetJobId(), b.GetJobId()) | |
| 618 | + | ||
| 619 | + | runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") // fail-fast asks b to stop | |
| 620 | + | ||
| 621 | + | c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 622 | + | for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() { | |
| 623 | + | if j.GetId() == b.GetJobId() { | |
| 624 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion()) | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | again, err := runner.fetch("") | |
| 628 | + | require.NoError(t, err) | |
| 629 | + | assert.Nil(t, again, "a cancelled job is not given again") | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // Once the runner acknowledged a job, a retried poll with the same poll_id has | |
| 633 | + | // nothing to repeat: the plaintext token is no longer kept. | |
| 634 | + | func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) { | |
| 635 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 150 * time.Millisecond }) | |
| 636 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 637 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 638 | + | var job *pb.Job | |
| 639 | + | c.waitFor("a job", func() bool { | |
| 640 | + | j, err := runner.fetch("poll-9") | |
| 641 | + | require.NoError(t, err) | |
| 642 | + | job = j | |
| 643 | + | return j != nil | |
| 644 | + | }) | |
| 645 | + | _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 646 | + | require.NoError(t, err) | |
| 647 | + | again, err := runner.fetch("poll-9") | |
| 648 | + | require.NoError(t, err) | |
| 649 | + | assert.Nil(t, again, "the acknowledged assignment is not handed out again") | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // The push payload follows GitHub's (checked against the recordings of | |
| 653 | + | // ermite-conformance): 12-character ids in "compare", the commit page for a | |
| 654 | + | // created ref and a compare range for an update, the default branch of the | |
| 655 | + | // repository, a tree id, and string counters. | |
| 656 | + | func TestCIGithubContextOfAPush(t *testing.T) { | |
| 657 | + | c := newCIHarness(t, nil) | |
| 658 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 659 | + | oneJobWF := strings.Split(ciWorkflow, " test:")[0] | |
| 660 | + | first := c.push("main", map[string]string{".github/workflows/ci.yml": oneJobWF}, "") | |
| 661 | + | ev := func() (map[string]any, map[string]any) { | |
| 662 | + | job := runner.await() | |
| 663 | + | var ctxs map[string]map[string]any | |
| 664 | + | require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs)) | |
| 665 | + | gh := ctxs["github"] | |
| 666 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 667 | + | return gh, gh["event"].(map[string]any) | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | gh, event := ev() | |
| 671 | + | assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit") | |
| 672 | + | assert.Equal(t, true, event["created"]) | |
| 673 | + | assert.Equal(t, domain.ZeroOID, event["before"]) | |
| 674 | + | repo := event["repository"].(map[string]any) | |
| 675 | + | assert.Equal(t, "main", repo["default_branch"]) | |
| 676 | + | assert.Equal(t, "main", repo["master_branch"]) | |
| 677 | + | assert.Equal(t, "90", gh["retention_days"], "a string, from the configured retention") | |
| 678 | + | head := event["head_commit"].(map[string]any) | |
| 679 | + | tree := strings.TrimSpace(c.bareGit("proj", "rev-parse", first+"^{tree}")) | |
| 680 | + | assert.Equal(t, tree, head["tree_id"]) | |
| 681 | + | ||
| 682 | + | second := c.push("main", map[string]string{"a.txt": "a\n"}, "") | |
| 683 | + | _, event = ev() | |
| 684 | + | assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"]) | |
| 685 | + | assert.Equal(t, false, event["created"]) | |
| 686 | + | } | |
A internal/app/ci_web_test.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/url" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "connectrpc.com/connect" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 15 | + | ||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page, | |
| 21 | + | // raw and stream), the owner's buttons, and the checks on the commit, the | |
| 22 | + | // branch list, the commit list and the merge request. | |
| 23 | + | func TestWebActionsPages(t *testing.T) { | |
| 24 | + | ci := newCIHarness(t, nil) | |
| 25 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 26 | + | b := browser{ci.harness} | |
| 27 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |
| 28 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 29 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 30 | + | })) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | ci.push("main", map[string]string{"README": "x\n"}, "") | |
| 33 | + | sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci") | |
| 34 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |
| 35 | + | ||
| 36 | + | // Queued: the list shows it, with a word for its state. | |
| 37 | + | list := b.get("/proj/actions", nil) | |
| 38 | + | require.Equal(t, 200, list.status, list.body) | |
| 39 | + | assert.Contains(t, list.body, "queued") | |
| 40 | + | assert.Contains(t, list.body, `href="/proj/actions/runs/1"`) | |
| 41 | + | assert.Contains(t, list.body, `aria-label="queued"`) | |
| 42 | + | assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&view=actions", "the page follows the repository stream") | |
| 43 | + | assert.Contains(t, list.body, ">Actions<", "the tab") | |
| 44 | + | ||
| 45 | + | // The MR page says "waiting for a runner" only when no runner matches. | |
| 46 | + | mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"})) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | mrPage := b.get("/proj/merge-requests/1", nil) | |
| 49 | + | require.Equal(t, 200, mrPage.status) | |
| 50 | + | assert.Contains(t, mrPage.body, `id="mr-checks"`) | |
| 51 | + | assert.Contains(t, mrPage.body, "build") | |
| 52 | + | assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies") | |
| 53 | + | assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches") | |
| 54 | + | _ = mr | |
| 55 | + | ||
| 56 | + | job := runner.await() | |
| 57 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]") | |
| 58 | + | next := runner.await() | |
| 59 | + | runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.") | |
| 60 | + | ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 61 | + | ||
| 62 | + | run := b.get("/proj/actions/runs/1", nil) | |
| 63 | + | require.Equal(t, 200, run.status, run.body) | |
| 64 | + | assert.Contains(t, run.body, "build") | |
| 65 | + | assert.Contains(t, run.body, "test") | |
| 66 | + | assert.Contains(t, run.body, "failure") | |
| 67 | + | assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named") | |
| 68 | + | assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor") | |
| 69 | + | ||
| 70 | + | jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil) | |
| 71 | + | require.Equal(t, 200, jobPage.status, jobPage.body) | |
| 72 | + | assert.Contains(t, jobPage.body, "Run echo hello") | |
| 73 | + | assert.Contains(t, jobPage.body, "hello <b>bold</b>", "log text is escaped, never markup") | |
| 74 | + | assert.NotContains(t, jobPage.body, "<b>bold</b>") | |
| 75 | + | assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure") | |
| 76 | + | assert.Contains(t, jobPage.body, `class="lg group"`) | |
| 77 | + | failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil) | |
| 78 | + | assert.Contains(t, failed.body, `class="lg error"`) | |
| 79 | + | ||
| 80 | + | raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil) | |
| 81 | + | require.Equal(t, 200, raw.status) | |
| 82 | + | assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type")) | |
| 83 | + | assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options")) | |
| 84 | + | assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox") | |
| 85 | + | assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text") | |
| 86 | + | assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body) | |
| 87 | + | ||
| 88 | + | // The stream of a finished job sends its lines then ends. | |
| 89 | + | stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil) | |
| 90 | + | require.Equal(t, 200, stream.status) | |
| 91 | + | assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type")) | |
| 92 | + | assert.Contains(t, stream.body, "event: ci-log") | |
| 93 | + | assert.Contains(t, stream.body, "event: ci-end") | |
| 94 | + | assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line") | |
| 95 | + | ||
| 96 | + | // Checks next to the commit, the commits list and the branch list. | |
| 97 | + | commit := b.get("/proj/commit/"+sha, nil) | |
| 98 | + | require.Equal(t, 200, commit.status) | |
| 99 | + | assert.Contains(t, commit.body, "checks failed") | |
| 100 | + | assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed") | |
| 101 | + | assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed") | |
| 102 | + | ||
| 103 | + | // Private repository: nothing to anonymous. | |
| 104 | + | _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 105 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 106 | + | })) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} { | |
| 109 | + | assert.Equal(t, 404, b.get(p, nil).status, p) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // The owner sees the buttons and re-runs the failed job. | |
| 113 | + | token := c.signIn(t) | |
| 114 | + | owner := b.get("/proj/actions/runs/1", as(token)) | |
| 115 | + | require.Equal(t, 200, owner.status) | |
| 116 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`) | |
| 117 | + | assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`) | |
| 118 | + | assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`) | |
| 119 | + | // A POST needs the owner's session and the origin. | |
| 120 | + | assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous") | |
| 121 | + | res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{}) | |
| 122 | + | require.Equal(t, 303, res.status, res.body) | |
| 123 | + | ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 }) | |
| 124 | + | again := b.get("/proj/actions/runs/1?attempt=1", as(token)) | |
| 125 | + | assert.Contains(t, again.body, "attempt 1") | |
| 126 | + | assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button") | |
| 127 | + | ||
| 128 | + | cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{}) | |
| 129 | + | require.Equal(t, 303, cancel.status, cancel.body) | |
| 130 | + | ci.waitFor("cancelled", func() bool { | |
| 131 | + | return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED | |
| 132 | + | }) | |
| 133 | + | assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // The live updates of the pages: the run page gets the body of the run when a | |
| 137 | + | // job changes, and the job page its state, never a line of the log. | |
| 138 | + | func TestWebActionsLiveFragments(t *testing.T) { | |
| 139 | + | ci := newCIHarness(t, nil) | |
| 140 | + | c := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 141 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |
| 142 | + | _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 143 | + | Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 144 | + | })) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 147 | + | ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 }) | |
| 148 | + | token := c.signIn(t) | |
| 149 | + | ||
| 150 | + | run := ci.sse("topic=repo:proj&view=run&n=1", "") | |
| 151 | + | list := ci.sse("topic=repo:proj&view=actions", "") | |
| 152 | + | job := runner.await() | |
| 153 | + | jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "") | |
| 154 | + | _ = token | |
| 155 | + | ||
| 156 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log") | |
| 157 | + | deadline := time.After(10 * time.Second) | |
| 158 | + | var sawRun, sawList, sawJob bool | |
| 159 | + | for !sawRun || !sawList || !sawJob { | |
| 160 | + | select { | |
| 161 | + | case ev := <-run.events: | |
| 162 | + | sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`)) | |
| 163 | + | assert.NotContains(t, ev.data, "a secret value in the log") | |
| 164 | + | case ev := <-list.events: | |
| 165 | + | sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`)) | |
| 166 | + | case ev := <-jobStream.events: | |
| 167 | + | sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`)) | |
| 168 | + | assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log") | |
| 169 | + | case <-deadline: | |
| 170 | + | t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | _ = domain.CISuccess | |
| 174 | + | } | |
M internal/app/cli_e2e_test.go +1 -1
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 324 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | |
| 328 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_repo.go +1 -1
| @@ -275,7 +275,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 275 | 275 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 276 | 276 | "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 277 | 277 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 278 | - | "--failing-checks-block says whether a failing check prevents a merge (there are no checks yet).\n" + | |
| 278 | + | "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" + | |
| 279 | 279 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| 280 | 280 | Args: cobra.ExactArgs(1), | |
| 281 | 281 | ValidArgsFunction: completeRepoNames(env), | |
A internal/app/cli_run.go +545 -0
| @@ -0,0 +1,545 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/spf13/cobra" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // jsonRun is the stable JSON shape of a run. | |
| 21 | + | type jsonRun struct { | |
| 22 | + | ID int64 `json:"id"` | |
| 23 | + | Repo string `json:"repo"` | |
| 24 | + | Number int64 `json:"number"` | |
| 25 | + | Attempt int32 `json:"attempt"` | |
| 26 | + | Workflow string `json:"workflow"` | |
| 27 | + | WorkflowPath string `json:"workflow_path"` | |
| 28 | + | Title string `json:"title"` | |
| 29 | + | Event string `json:"event"` | |
| 30 | + | Ref string `json:"ref"` | |
| 31 | + | Sha string `json:"sha"` | |
| 32 | + | Actor string `json:"actor"` | |
| 33 | + | Status string `json:"status"` | |
| 34 | + | Conclusion string `json:"conclusion"` | |
| 35 | + | Error string `json:"error"` | |
| 36 | + | CreatedAt *string `json:"created_at"` | |
| 37 | + | StartedAt *string `json:"started_at"` | |
| 38 | + | CompletedAt *string `json:"completed_at"` | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type jsonStep struct { | |
| 42 | + | Number int32 `json:"number"` | |
| 43 | + | Name string `json:"name"` | |
| 44 | + | State string `json:"state"` | |
| 45 | + | Conclusion string `json:"conclusion"` | |
| 46 | + | StartedAt *string `json:"started_at"` | |
| 47 | + | CompletedAt *string `json:"completed_at"` | |
| 48 | + | LogStart int64 `json:"log_start"` | |
| 49 | + | LogEnd int64 `json:"log_end"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type jsonRunJob struct { | |
| 53 | + | ID string `json:"id"` | |
| 54 | + | Attempt int32 `json:"attempt"` | |
| 55 | + | Key string `json:"key"` | |
| 56 | + | Name string `json:"name"` | |
| 57 | + | Status string `json:"status"` | |
| 58 | + | Conclusion string `json:"conclusion"` | |
| 59 | + | Error string `json:"error"` | |
| 60 | + | Labels []string `json:"labels"` | |
| 61 | + | Needs []string `json:"needs"` | |
| 62 | + | Runner string `json:"runner"` | |
| 63 | + | Isolation string `json:"isolation"` | |
| 64 | + | Steps []jsonStep `json:"steps"` | |
| 65 | + | StartedAt *string `json:"started_at"` | |
| 66 | + | CompletedAt *string `json:"completed_at"` | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type jsonRunDetail struct { | |
| 70 | + | jsonRun | |
| 71 | + | LatestAttempt int32 `json:"latest_attempt"` | |
| 72 | + | Jobs []jsonRunJob `json:"jobs"` | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | const jsonRunHelp = ` | |
| 76 | + | JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event, | |
| 77 | + | ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled, | |
| 78 | + | skipped, timed_out, neutral, action_required; empty until completed), error, created_at, | |
| 79 | + | started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and | |
| 80 | + | jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps | |
| 81 | + | with number, name, state, conclusion, log_start and log_end, started_at, completed_at).` | |
| 82 | + | ||
| 83 | + | func statusText(s ermite.CiStatus) string { | |
| 84 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_")) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func conclusionText(c ermite.CiConclusion) string { | |
| 88 | + | if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED { | |
| 89 | + | return "" | |
| 90 | + | } | |
| 91 | + | return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_")) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func isolationText(i ermite.RunnerIsolation) string { | |
| 95 | + | if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED { | |
| 96 | + | return "" | |
| 97 | + | } | |
| 98 | + | return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func stepStateText(s ermite.StepState) string { | |
| 102 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_")) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // runResult is "status" while the run is going, the conclusion once over. | |
| 106 | + | func runResult(status ermite.CiStatus, c ermite.CiConclusion) string { | |
| 107 | + | if status == ermite.CiStatus_CI_STATUS_COMPLETED { | |
| 108 | + | return conclusionText(c) | |
| 109 | + | } | |
| 110 | + | return statusText(status) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func toJSONRun(r *ermite.Run) jsonRun { | |
| 114 | + | return jsonRun{ | |
| 115 | + | ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(), | |
| 116 | + | WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(), | |
| 117 | + | Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(), | |
| 118 | + | CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()), | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func toJSONRunJob(j *ermite.RunJob) jsonRunJob { | |
| 123 | + | steps := make([]jsonStep, 0, len(j.GetSteps())) | |
| 124 | + | for _, s := range j.GetSteps() { | |
| 125 | + | steps = append(steps, jsonStep{ | |
| 126 | + | Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()), | |
| 127 | + | StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(), | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | labels, needs := j.GetLabels(), j.GetNeeds() | |
| 131 | + | if labels == nil { | |
| 132 | + | labels = []string{} | |
| 133 | + | } | |
| 134 | + | if needs == nil { | |
| 135 | + | needs = []string{} | |
| 136 | + | } | |
| 137 | + | return jsonRunJob{ | |
| 138 | + | ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()), | |
| 139 | + | Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs, | |
| 140 | + | Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps, | |
| 141 | + | StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func newRunCmd(env *cliEnv) *cobra.Command { | |
| 146 | + | run := &cobra.Command{ | |
| 147 | + | Use: "run", | |
| 148 | + | Short: "List, read, watch, cancel and re-run CI runs", | |
| 149 | + | Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" + | |
| 150 | + | "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.", | |
| 151 | + | } | |
| 152 | + | run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env)) | |
| 153 | + | return run | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // parseRunID reads a run id argument. | |
| 157 | + | func parseRunID(s string) (int64, error) { | |
| 158 | + | id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64) | |
| 159 | + | if err != nil || id < 1 { | |
| 160 | + | return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s) | |
| 161 | + | } | |
| 162 | + | return id, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func newRunListCmd(env *cliEnv) *cobra.Command { | |
| 166 | + | var workflow, branch, status string | |
| 167 | + | var limit int | |
| 168 | + | cmd := &cobra.Command{ | |
| 169 | + | Use: "list <repo>", | |
| 170 | + | Short: "List the runs of a repository", | |
| 171 | + | Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" + | |
| 172 | + | "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.", | |
| 173 | + | Args: cobra.ExactArgs(1), | |
| 174 | + | ValidArgsFunction: completeRepoNames(env), | |
| 175 | + | } | |
| 176 | + | cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file") | |
| 177 | + | cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch") | |
| 178 | + | cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed") | |
| 179 | + | cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs") | |
| 180 | + | asJSON := addJSONFlag(cmd) | |
| 181 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 182 | + | st, err := parseStatusFlag(status) | |
| 183 | + | if err != nil { | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | if limit < 1 { | |
| 187 | + | return fmt.Errorf("--limit must be at least 1") | |
| 188 | + | } | |
| 189 | + | c, err := env.client(cmd, true) | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | ctx := env.ctx(cmd) | |
| 194 | + | var runs []*ermite.Run | |
| 195 | + | token := "" | |
| 196 | + | for len(runs) < limit { | |
| 197 | + | res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{ | |
| 198 | + | Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded | |
| 199 | + | Workflow: workflow, Branch: branch, Status: st, | |
| 200 | + | })) | |
| 201 | + | if err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | runs = append(runs, res.Msg.GetRuns()...) | |
| 205 | + | if token = res.Msg.GetNextPageToken(); token == "" { | |
| 206 | + | break | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | if *asJSON { | |
| 210 | + | out := make([]jsonRun, 0, len(runs)) | |
| 211 | + | for _, r := range runs { | |
| 212 | + | out = append(out, toJSONRun(r)) | |
| 213 | + | } | |
| 214 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 215 | + | } | |
| 216 | + | if len(runs) == 0 { | |
| 217 | + | noticef(cmd, "no runs") | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | rows := make([][]string, 0, len(runs)) | |
| 221 | + | for _, r := range runs { | |
| 222 | + | rows = append(rows, []string{ | |
| 223 | + | strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(), | |
| 224 | + | strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()), | |
| 225 | + | r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(), | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows) | |
| 229 | + | } | |
| 230 | + | return cmd | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func shortSha(s string) string { | |
| 234 | + | if len(s) > 8 { | |
| 235 | + | return s[:8] | |
| 236 | + | } | |
| 237 | + | return s | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func parseStatusFlag(s string) (ermite.CiStatus, error) { | |
| 241 | + | switch strings.ToLower(s) { | |
| 242 | + | case "": | |
| 243 | + | return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil | |
| 244 | + | case "queued": | |
| 245 | + | return ermite.CiStatus_CI_STATUS_QUEUED, nil | |
| 246 | + | case "in_progress", "in-progress": | |
| 247 | + | return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil | |
| 248 | + | case "completed": | |
| 249 | + | return ermite.CiStatus_CI_STATUS_COMPLETED, nil | |
| 250 | + | } | |
| 251 | + | return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func newRunViewCmd(env *cliEnv) *cobra.Command { | |
| 255 | + | var attempt int32 | |
| 256 | + | var logFlag bool | |
| 257 | + | var jobFlag string | |
| 258 | + | cmd := &cobra.Command{ | |
| 259 | + | Use: "view <repo> <run id>", | |
| 260 | + | Short: "Show a run with its jobs and steps, or the log of a job", | |
| 261 | + | Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" + | |
| 262 | + | "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" + | |
| 263 | + | "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp, | |
| 264 | + | Args: cobra.ExactArgs(2), | |
| 265 | + | ValidArgsFunction: completeRepoNames(env), | |
| 266 | + | } | |
| 267 | + | cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)") | |
| 268 | + | cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary") | |
| 269 | + | cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)") | |
| 270 | + | asJSON := addJSONFlag(cmd) | |
| 271 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 272 | + | id, err := parseRunID(args[1]) | |
| 273 | + | if err != nil { | |
| 274 | + | return err | |
| 275 | + | } | |
| 276 | + | c, err := env.client(cmd, true) | |
| 277 | + | if err != nil { | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | ctx := env.ctx(cmd) | |
| 281 | + | res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt})) | |
| 282 | + | if err != nil { | |
| 283 | + | return err | |
| 284 | + | } | |
| 285 | + | if logFlag { | |
| 286 | + | return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false) | |
| 287 | + | } | |
| 288 | + | if *asJSON { | |
| 289 | + | d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}} | |
| 290 | + | for _, j := range res.Msg.GetJobs() { | |
| 291 | + | d.Jobs = append(d.Jobs, toJSONRunJob(j)) | |
| 292 | + | } | |
| 293 | + | return writeJSON(cmd.OutOrStdout(), d) | |
| 294 | + | } | |
| 295 | + | return printRun(cmd.OutOrStdout(), res.Msg) | |
| 296 | + | } | |
| 297 | + | return cmd | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func printRun(w io.Writer, r *ermite.GetRunResponse) error { | |
| 301 | + | run := r.GetRun() | |
| 302 | + | pairs := [][2]string{ | |
| 303 | + | {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())}, | |
| 304 | + | {"result", runResult(run.GetStatus(), run.GetConclusion())}, | |
| 305 | + | {"ref", run.GetRef()}, | |
| 306 | + | {"commit", run.GetSha()}, | |
| 307 | + | {"title", run.GetTitle()}, | |
| 308 | + | {"actor", run.GetActor()}, | |
| 309 | + | {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())}, | |
| 310 | + | {"created", shortTime(run.GetCreatedAt(), "-")}, | |
| 311 | + | } | |
| 312 | + | if run.GetError() != "" { | |
| 313 | + | pairs = append(pairs, [2]string{"error", run.GetError()}) | |
| 314 | + | } | |
| 315 | + | if err := fields(w, pairs); err != nil { | |
| 316 | + | return err | |
| 317 | + | } | |
| 318 | + | if len(r.GetJobs()) == 0 { | |
| 319 | + | return nil | |
| 320 | + | } | |
| 321 | + | _, _ = fmt.Fprintln(w) | |
| 322 | + | for _, j := range r.GetJobs() { | |
| 323 | + | line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName()) | |
| 324 | + | if j.GetRunnerName() != "" { | |
| 325 | + | line += " [" + j.GetRunnerName() + "]" | |
| 326 | + | } | |
| 327 | + | _, _ = fmt.Fprintln(w, clean(line)) | |
| 328 | + | if j.GetError() != "" { | |
| 329 | + | _, _ = fmt.Fprintln(w, " "+clean(j.GetError())) | |
| 330 | + | } | |
| 331 | + | for _, s := range j.GetSteps() { | |
| 332 | + | res := stepStateText(s.GetState()) | |
| 333 | + | if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED { | |
| 334 | + | res = conclusionText(s.GetConclusion()) | |
| 335 | + | } | |
| 336 | + | _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName()))) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | return nil | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // pickJobs selects the jobs --job names (an id or a name), or all of them. | |
| 343 | + | func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) { | |
| 344 | + | if sel == "" { | |
| 345 | + | return jobs, nil | |
| 346 | + | } | |
| 347 | + | var out []*ermite.RunJob | |
| 348 | + | for _, j := range jobs { | |
| 349 | + | if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel { | |
| 350 | + | out = append(out, j) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | if len(out) == 0 { | |
| 354 | + | return nil, fmt.Errorf("the run has no job %q", sel) | |
| 355 | + | } | |
| 356 | + | return out, nil | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // printRunLog prints the log of the chosen jobs; with follow it waits for the | |
| 360 | + | // lines of a job that is running. | |
| 361 | + | func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error { | |
| 362 | + | jobs, err := pickJobs(r.GetJobs(), sel) | |
| 363 | + | if err != nil { | |
| 364 | + | return err | |
| 365 | + | } | |
| 366 | + | for _, j := range jobs { | |
| 367 | + | if len(jobs) > 1 { | |
| 368 | + | _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName())) | |
| 369 | + | } | |
| 370 | + | if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil { | |
| 371 | + | return err | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | return nil | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // streamJobLog writes the lines of one job. Continued pieces are joined to the | |
| 378 | + | // line before them, as the raw log has them. | |
| 379 | + | func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error { | |
| 380 | + | if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow { | |
| 381 | + | noticef2(w, "job %q has not started: no log yet", j.GetName()) | |
| 382 | + | return nil | |
| 383 | + | } | |
| 384 | + | stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{ | |
| 385 | + | Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow, | |
| 386 | + | })) | |
| 387 | + | if err != nil { | |
| 388 | + | return err | |
| 389 | + | } | |
| 390 | + | defer func() { _ = stream.Close() }() | |
| 391 | + | open := false | |
| 392 | + | for stream.Receive() { | |
| 393 | + | for _, l := range stream.Msg().GetLines() { | |
| 394 | + | if l.GetContinued() && open { | |
| 395 | + | _, _ = fmt.Fprint(w, clean(l.GetText())) | |
| 396 | + | continue | |
| 397 | + | } | |
| 398 | + | if open { | |
| 399 | + | _, _ = fmt.Fprintln(w) | |
| 400 | + | } | |
| 401 | + | _, _ = fmt.Fprint(w, clean(l.GetText())) | |
| 402 | + | open = true | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | if open { | |
| 406 | + | _, _ = fmt.Fprintln(w) | |
| 407 | + | } | |
| 408 | + | if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound { | |
| 409 | + | return err | |
| 410 | + | } | |
| 411 | + | return nil | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func noticef2(w io.Writer, format string, args ...any) { | |
| 415 | + | _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func newRunWatchCmd(env *cliEnv) *cobra.Command { | |
| 419 | + | var interval time.Duration | |
| 420 | + | var logFlag bool | |
| 421 | + | cmd := &cobra.Command{ | |
| 422 | + | Use: "watch <repo> <run id>", | |
| 423 | + | Short: "Wait for a run to complete and follow its jobs", | |
| 424 | + | Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" + | |
| 425 | + | "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" + | |
| 426 | + | "`ermite run watch` can gate a script.", | |
| 427 | + | Args: cobra.ExactArgs(2), | |
| 428 | + | ValidArgsFunction: completeRepoNames(env), | |
| 429 | + | } | |
| 430 | + | cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run") | |
| 431 | + | cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run") | |
| 432 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 433 | + | id, err := parseRunID(args[1]) | |
| 434 | + | if err != nil { | |
| 435 | + | return err | |
| 436 | + | } | |
| 437 | + | c, err := env.client(cmd, true) | |
| 438 | + | if err != nil { | |
| 439 | + | return err | |
| 440 | + | } | |
| 441 | + | ctx := env.ctx(cmd) | |
| 442 | + | w := cmd.OutOrStdout() | |
| 443 | + | last := "" | |
| 444 | + | for { | |
| 445 | + | res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id})) | |
| 446 | + | if err != nil { | |
| 447 | + | return err | |
| 448 | + | } | |
| 449 | + | if line := watchLine(res.Msg); line != last { | |
| 450 | + | _, _ = fmt.Fprintln(w, line) | |
| 451 | + | last = line | |
| 452 | + | } | |
| 453 | + | if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED { | |
| 454 | + | if logFlag { | |
| 455 | + | if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil { | |
| 456 | + | return err | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS { | |
| 460 | + | return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion())) | |
| 461 | + | } | |
| 462 | + | return nil | |
| 463 | + | } | |
| 464 | + | select { | |
| 465 | + | case <-ctx.Done(): | |
| 466 | + | return ctx.Err() | |
| 467 | + | case <-time.After(interval): | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | return cmd | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // watchLine is a one-line state of a run for `run watch`. | |
| 475 | + | func watchLine(r *ermite.GetRunResponse) string { | |
| 476 | + | var parts []string | |
| 477 | + | for _, j := range r.GetJobs() { | |
| 478 | + | parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion())) | |
| 479 | + | } | |
| 480 | + | return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", "))) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func newRunCancelCmd(env *cliEnv) *cobra.Command { | |
| 484 | + | cmd := &cobra.Command{ | |
| 485 | + | Use: "cancel <repo> <run id>", | |
| 486 | + | Short: "Cancel a run", | |
| 487 | + | Long: "Ask the runners to stop the jobs of a run that is not over; jobs that no runner has yet are cancelled at once. Needs the ci:write scope.", | |
| 488 | + | Args: cobra.ExactArgs(2), | |
| 489 | + | ValidArgsFunction: completeRepoNames(env), | |
| 490 | + | } | |
| 491 | + | asJSON := addJSONFlag(cmd) | |
| 492 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 493 | + | id, err := parseRunID(args[1]) | |
| 494 | + | if err != nil { | |
| 495 | + | return err | |
| 496 | + | } | |
| 497 | + | c, err := env.client(cmd, true) | |
| 498 | + | if err != nil { | |
| 499 | + | return err | |
| 500 | + | } | |
| 501 | + | res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id})) | |
| 502 | + | if err != nil { | |
| 503 | + | return err | |
| 504 | + | } | |
| 505 | + | if *asJSON { | |
| 506 | + | return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun())) | |
| 507 | + | } | |
| 508 | + | noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion())) | |
| 509 | + | return nil | |
| 510 | + | } | |
| 511 | + | return cmd | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func newRunRerunCmd(env *cliEnv) *cobra.Command { | |
| 515 | + | var failed bool | |
| 516 | + | cmd := &cobra.Command{ | |
| 517 | + | Use: "rerun <repo> <run id>", | |
| 518 | + | Short: "Run a completed run again", | |
| 519 | + | Long: "Start a new attempt of a completed run, with the same commit and workflow file. With --failed, only the jobs that did not succeed (and the jobs that need them) run again. Needs the ci:write scope." + jsonRunHelp, | |
| 520 | + | Args: cobra.ExactArgs(2), | |
| 521 | + | ValidArgsFunction: completeRepoNames(env), | |
| 522 | + | } | |
| 523 | + | cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them") | |
| 524 | + | asJSON := addJSONFlag(cmd) | |
| 525 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 526 | + | id, err := parseRunID(args[1]) | |
| 527 | + | if err != nil { | |
| 528 | + | return err | |
| 529 | + | } | |
| 530 | + | c, err := env.client(cmd, true) | |
| 531 | + | if err != nil { | |
| 532 | + | return err | |
| 533 | + | } | |
| 534 | + | res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed})) | |
| 535 | + | if err != nil { | |
| 536 | + | return err | |
| 537 | + | } | |
| 538 | + | if *asJSON { | |
| 539 | + | return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun())) | |
| 540 | + | } | |
| 541 | + | noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt()) | |
| 542 | + | return nil | |
| 543 | + | } | |
| 544 | + | return cmd | |
| 545 | + | } | |
A internal/app/cli_run_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // The CLI over a real server: `run list|view|watch|cancel|rerun`, `runner | |
| 18 | + | // list|remove`, and the runner token preset. | |
| 19 | + | func TestCLIRunsAndRunners(t *testing.T) { | |
| 20 | + | ci := newCIHarness(t, nil) | |
| 21 | + | h := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 22 | + | runner := ci.registerRunner("alpha", "ubuntu-latest") | |
| 23 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "") | |
| 24 | + | ci.waitFor("a run", func() bool { return len(ci.runs()) == 1 }) | |
| 25 | + | ||
| 26 | + | job := runner.await() | |
| 27 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello from the job") | |
| 28 | + | next := runner.await() | |
| 29 | + | runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "this one failed") | |
| 30 | + | ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 31 | + | id := "1" | |
| 32 | + | ||
| 33 | + | // list | |
| 34 | + | out := h.cli(t, adminToken, "run", "list", "proj") | |
| 35 | + | require.Empty(t, out.err, out.stderr) | |
| 36 | + | assert.Contains(t, out.stdout, "RESULT") | |
| 37 | + | assert.Contains(t, out.stdout, "failure") | |
| 38 | + | assert.Contains(t, out.stdout, "ci") | |
| 39 | + | ||
| 40 | + | js := h.cli(t, adminToken, "run", "list", "proj", "--json") | |
| 41 | + | require.NoError(t, js.err) | |
| 42 | + | runs := decode[[]jsonRun](t, js.stdout) | |
| 43 | + | require.Len(t, runs, 1) | |
| 44 | + | assert.Equal(t, "failure", runs[0].Conclusion) | |
| 45 | + | assert.Equal(t, "completed", runs[0].Status) | |
| 46 | + | assert.Equal(t, "refs/heads/main", runs[0].Ref) | |
| 47 | + | assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]")))) | |
| 48 | + | ||
| 49 | + | none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued") | |
| 50 | + | require.NoError(t, none.err) | |
| 51 | + | assert.Contains(t, none.stderr, "no runs") | |
| 52 | + | bad := h.cli(t, adminToken, "run", "list", "proj", "--status", "weird") | |
| 53 | + | require.Error(t, bad.err) | |
| 54 | + | ||
| 55 | + | // view | |
| 56 | + | view := h.cli(t, adminToken, "run", "view", "proj", id) | |
| 57 | + | require.NoError(t, view.err, view.stderr) | |
| 58 | + | assert.Contains(t, view.stdout, "result") | |
| 59 | + | assert.Contains(t, view.stdout, "build") | |
| 60 | + | assert.Contains(t, view.stdout, "[alpha]") | |
| 61 | + | assert.Contains(t, view.stdout, "Set up job") | |
| 62 | + | ||
| 63 | + | vjs := h.cli(t, adminToken, "run", "view", "proj", id, "--json") | |
| 64 | + | detail := decode[jsonRunDetail](t, vjs.stdout) | |
| 65 | + | require.Len(t, detail.Jobs, 2) | |
| 66 | + | assert.Equal(t, "build", detail.Jobs[0].Key) | |
| 67 | + | assert.Equal(t, "vm", detail.Jobs[0].Isolation) | |
| 68 | + | assert.Equal(t, int32(1), detail.LatestAttempt) | |
| 69 | + | ||
| 70 | + | // --log, all jobs and one job | |
| 71 | + | all := h.cli(t, adminToken, "run", "view", "proj", id, "--log") | |
| 72 | + | require.NoError(t, all.err, all.stderr) | |
| 73 | + | assert.Contains(t, all.stdout, "== build ==") | |
| 74 | + | assert.Contains(t, all.stdout, "hello from the job") | |
| 75 | + | assert.Contains(t, all.stdout, "this one failed") | |
| 76 | + | one := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "test") | |
| 77 | + | require.NoError(t, one.err) | |
| 78 | + | assert.Contains(t, one.stdout, "this one failed") | |
| 79 | + | assert.NotContains(t, one.stdout, "hello from the job") | |
| 80 | + | missing := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "nope") | |
| 81 | + | require.Error(t, missing.err) | |
| 82 | + | assert.Contains(t, missing.err.Error(), `no job "nope"`) | |
| 83 | + | ||
| 84 | + | // watch: a completed failing run prints once and exits with an error. | |
| 85 | + | w := h.cli(t, adminToken, "run", "watch", "proj", id) | |
| 86 | + | require.Error(t, w.err) | |
| 87 | + | assert.Contains(t, w.err.Error(), "failure") | |
| 88 | + | assert.Contains(t, w.stdout, "build success") | |
| 89 | + | ||
| 90 | + | // rerun --failed, then cancel the new attempt. | |
| 91 | + | re := h.cli(t, adminToken, "run", "rerun", "proj", id, "--failed") | |
| 92 | + | require.NoError(t, re.err, re.stderr) | |
| 93 | + | assert.Contains(t, re.stderr, "attempt 2 started") | |
| 94 | + | ca := h.cli(t, adminToken, "run", "cancel", "proj", id, "--json") | |
| 95 | + | require.NoError(t, ca.err, ca.stderr) | |
| 96 | + | assert.Equal(t, int32(2), decode[jsonRun](t, ca.stdout).Attempt) | |
| 97 | + | // No runner holds a job of attempt 2, so the cancel ended it at once: a | |
| 98 | + | // completed run cannot be cancelled again. | |
| 99 | + | again := h.cli(t, adminToken, "run", "cancel", "proj", id) | |
| 100 | + | require.Error(t, again.err) | |
| 101 | + | assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(again.err)) | |
| 102 | + | ||
| 103 | + | // A reader may look but not cancel or rerun. | |
| 104 | + | reader := h.token(scopes(domain.ScopeRepoRead)) | |
| 105 | + | assert.NoError(t, h.cli(t, reader, "run", "view", "proj", id).err) | |
| 106 | + | denied := h.cli(t, reader, "run", "cancel", "proj", id) | |
| 107 | + | require.Error(t, denied.err) | |
| 108 | + | assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(denied.err)) | |
| 109 | + | assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "abc").err) | |
| 110 | + | ||
| 111 | + | // runners | |
| 112 | + | rl := h.cli(t, adminToken, "runner", "list") | |
| 113 | + | require.NoError(t, rl.err, rl.stderr) | |
| 114 | + | assert.Contains(t, rl.stdout, "alpha") | |
| 115 | + | assert.Contains(t, rl.stdout, "vm") | |
| 116 | + | rjs := decode[[]jsonRunner](t, h.cli(t, adminToken, "runner", "list", "--json").stdout) | |
| 117 | + | require.Len(t, rjs, 1) | |
| 118 | + | assert.Equal(t, []string{"linux", "self-hosted", "ubuntu-latest", "x64"}, rjs[0].Labels) | |
| 119 | + | rm := h.cli(t, adminToken, "runner", "remove", rjs[0].ID) | |
| 120 | + | require.NoError(t, rm.err, rm.stderr) | |
| 121 | + | assert.Error(t, h.cli(t, adminToken, "runner", "remove", rjs[0].ID).err, "already removed") | |
| 122 | + | assert.Contains(t, h.cli(t, adminToken, "runner", "list").stderr, "no runners") | |
| 123 | + | ||
| 124 | + | // The runner token preset makes a token that registers runners and nothing | |
| 125 | + | // else. | |
| 126 | + | tok := h.cli(t, adminToken, "token", "create", "runner host", "--preset", "runner", "--json") | |
| 127 | + | require.NoError(t, tok.err, tok.stderr) | |
| 128 | + | created := decode[jsonCreatedToken](t, tok.stdout) | |
| 129 | + | assert.Equal(t, []string{"runner:register"}, created.Scopes) | |
| 130 | + | assert.NoError(t, h.cli(t, created.Secret, "runner", "list").err) | |
| 131 | + | assert.Error(t, h.cli(t, created.Secret, "run", "cancel", "proj", id).err) | |
| 132 | + | } | |
A internal/app/cli_runner.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type jsonRunner struct { | |
| 20 | + | ID string `json:"id"` | |
| 21 | + | Name string `json:"name"` | |
| 22 | + | Labels []string `json:"labels"` | |
| 23 | + | Isolation string `json:"isolation"` | |
| 24 | + | Version string `json:"version"` | |
| 25 | + | Arch string `json:"arch"` | |
| 26 | + | Os string `json:"os"` | |
| 27 | + | RegisteredAt *string `json:"registered_at"` | |
| 28 | + | LastSeen *string `json:"last_seen"` | |
| 29 | + | Busy bool `json:"busy"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | const jsonRunnerHelp = ` | |
| 33 | + | JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox, | |
| 34 | + | container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never | |
| 35 | + | seen) and busy. A runner not seen for more than about 2 minutes is offline.` | |
| 36 | + | ||
| 37 | + | func newRunnerCmd(env *cliEnv) *cobra.Command { | |
| 38 | + | runner := &cobra.Command{ | |
| 39 | + | Use: "runner", | |
| 40 | + | Short: "List and remove CI runners", | |
| 41 | + | Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" + | |
| 42 | + | "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" + | |
| 43 | + | "not limited to some repositories.", | |
| 44 | + | } | |
| 45 | + | runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env)) | |
| 46 | + | return runner | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func newRunnerListCmd(env *cliEnv) *cobra.Command { | |
| 50 | + | cmd := &cobra.Command{ | |
| 51 | + | Use: "list", | |
| 52 | + | Short: "List the registered runners", | |
| 53 | + | Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.", | |
| 54 | + | Args: cobra.NoArgs, | |
| 55 | + | } | |
| 56 | + | asJSON := addJSONFlag(cmd) | |
| 57 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 58 | + | c, err := env.client(cmd, true) | |
| 59 | + | if err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | ctx := env.ctx(cmd) | |
| 63 | + | runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) { | |
| 64 | + | res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token})) | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, "", err | |
| 67 | + | } | |
| 68 | + | return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil | |
| 69 | + | }) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | if *asJSON { | |
| 74 | + | out := make([]jsonRunner, 0, len(runners)) | |
| 75 | + | for _, r := range runners { | |
| 76 | + | labels := r.GetLabels() | |
| 77 | + | if labels == nil { | |
| 78 | + | labels = []string{} | |
| 79 | + | } | |
| 80 | + | out = append(out, jsonRunner{ | |
| 81 | + | ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(), | |
| 82 | + | Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(), | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 86 | + | } | |
| 87 | + | if len(runners) == 0 { | |
| 88 | + | noticef(cmd, "no runners: register one with `ermite-runner register`") | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | rows := make([][]string, 0, len(runners)) | |
| 92 | + | for _, r := range runners { | |
| 93 | + | state := "offline" | |
| 94 | + | if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute { | |
| 95 | + | state = "online" | |
| 96 | + | if r.GetBusy() { | |
| 97 | + | state = "busy" | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | rows = append(rows, []string{ | |
| 101 | + | r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(), | |
| 102 | + | strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"), | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows) | |
| 106 | + | } | |
| 107 | + | return cmd | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func newRunnerRemoveCmd(env *cliEnv) *cobra.Command { | |
| 111 | + | cmd := &cobra.Command{ | |
| 112 | + | Use: "remove <runner id>", | |
| 113 | + | Short: "Remove a runner", | |
| 114 | + | Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" + | |
| 115 | + | "affected: it ends, or fails as a lost runner when its heartbeats stop.", | |
| 116 | + | Args: cobra.ExactArgs(1), | |
| 117 | + | ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) { | |
| 118 | + | if len(args) > 0 { | |
| 119 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 120 | + | } | |
| 121 | + | out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) { | |
| 122 | + | res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{})) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | return res.Msg.GetRunners(), nil | |
| 127 | + | }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() }) | |
| 128 | + | return out, cobra.ShellCompDirectiveNoFileComp | |
| 129 | + | }, | |
| 130 | + | } | |
| 131 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 132 | + | c, err := env.client(cmd, true) | |
| 133 | + | if err != nil { | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil { | |
| 137 | + | var ce *connect.Error | |
| 138 | + | if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound { | |
| 139 | + | return fmt.Errorf("no runner has the id %q", args[0]) | |
| 140 | + | } | |
| 141 | + | return err | |
| 142 | + | } | |
| 143 | + | noticef(cmd, "runner %s removed", args[0]) | |
| 144 | + | return nil | |
| 145 | + | } | |
| 146 | + | return cmd | |
| 147 | + | } | |
M internal/app/cli_token.go +1 -0
| @@ -81,6 +81,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 81 | 81 | "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" + | |
| 82 | 82 | " cli every scope, for your own daily use\n" + | |
| 83 | 83 | " agent repo:write, mr:write\n" + | |
| 84 | + | " runner runner:register (a CI runner host)\n" + | |
| 84 | 85 | " ci repo:read\n" + | |
| 85 | 86 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 86 | 87 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
M internal/app/cli_version_test.go +5 -5
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | |
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"} | |
| 18 | 18 | ||
| 19 | 19 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 35 | f := versionFake(allFeatures...) | |
| 36 | 36 | url := f.serve(t) | |
| 37 | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout) | |
| 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout) | |
| 39 | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 40 | ||
| 41 | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout) | |
| 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session") | |
| 46 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners") | |
| 47 | 47 | url := f.serve(t) | |
| 48 | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 49 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 51 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 52 | ||
| 53 | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 56 | ||
| 57 | 57 | f = versionFake() | |
M internal/app/e2e_helpers_test.go +11 -1
| @@ -53,6 +53,12 @@ type harnessOpts struct { | |||
| 53 | 53 | // newHarness starts a forge. The data directory is short: the hook socket | |
| 54 | 54 | // lives in it and Unix socket paths are limited to about 100 bytes. | |
| 55 | 55 | func newHarness(t *testing.T, o harnessOpts) *harness { | |
| 56 | + | t.Helper() | |
| 57 | + | return newHarnessWith(t, o, nil) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // newHarnessWith is newHarness with a change of the wiring options. | |
| 61 | + | func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness { | |
| 56 | 62 | t.Helper() | |
| 57 | 63 | gitBin := requireGit(t) | |
| 58 | 64 | data, err := os.MkdirTemp("", "ermite") | |
| @@ -85,7 +91,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness { | |||
| 85 | 91 | if hookExe == "" { | |
| 86 | 92 | hookExe = binary(t) | |
| 87 | 93 | } | |
| 88 | - | f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}) | |
| 94 | + | opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery} | |
| 95 | + | if tweak != nil { | |
| 96 | + | tweak(&opts) | |
| 97 | + | } | |
| 98 | + | f, err := newForge(ctx, cfg, opts) | |
| 89 | 99 | require.NoError(t, err) | |
| 90 | 100 | ||
| 91 | 101 | h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)} | |
M internal/app/forge.go +30 -2
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | + | "bnema.dev/ermite/internal/adapters/cilogs" | |
| 18 | 19 | "bnema.dev/ermite/internal/adapters/ermitegit" | |
| 19 | 20 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 20 | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| @@ -48,6 +49,9 @@ type options struct { | |||
| 48 | 49 | eventBuffer int | |
| 49 | 50 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 50 | 51 | revalidateEvery time.Duration | |
| 52 | + | // ci overrides the settings of the CI engine (tests shorten its | |
| 53 | + | // durations). | |
| 54 | + | ci func(*usecase.CIConfig) | |
| 51 | 55 | } | |
| 52 | 56 | ||
| 53 | 57 | // forge is the wired server: every adapter and use case, ready to serve. | |
| @@ -74,8 +78,11 @@ type forge struct { | |||
| 74 | 78 | SigningKey *usecase.SigningKey | |
| 75 | 79 | Sessions *usecase.Sessions | |
| 76 | 80 | EventLog *usecase.Events | |
| 77 | - | API *rpc.API | |
| 78 | - | Bus ports.EventBus | |
| 81 | + | // CI is the CI engine: workflow trigger, planner, runner protocol and the | |
| 82 | + | // owner side. It starts with the server and stops with it. | |
| 83 | + | CI *usecase.CI | |
| 84 | + | API *rpc.API | |
| 85 | + | Bus ports.EventBus | |
| 79 | 86 | } | |
| 80 | 87 | ||
| 81 | 88 | // newSigningSeed makes the seed of a new ed25519 signing key. | |
| @@ -182,6 +189,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 182 | 189 | } | |
| 183 | 190 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | |
| 184 | 191 | f.Browse = usecase.NewBrowse(store, reader) | |
| 192 | + | ||
| 193 | + | // CI (ADR 0025): the engine reads workflows through the reader, and plans | |
| 194 | + | // them in the background; a push only queues its refs. | |
| 195 | + | ciCfg := usecase.CIConfig{ | |
| 196 | + | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | |
| 197 | + | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 198 | + | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 199 | + | } | |
| 200 | + | if opts.ci != nil { | |
| 201 | + | opts.ci(&ciCfg) | |
| 202 | + | } | |
| 203 | + | f.CI = usecase.NewCI(ctx, ciCfg) | |
| 204 | + | f.Pushes.WithWorkflowTrigger(f.CI) | |
| 205 | + | f.Repos.WithCIRunCleaner(f.CI) | |
| 206 | + | f.CI.Start() | |
| 185 | 207 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | |
| 186 | 208 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | |
| 187 | 209 | }) | |
| @@ -200,6 +222,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 200 | 222 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 201 | 223 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 202 | 224 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 225 | + | CI: f.CI, Checks: f.CI, | |
| 203 | 226 | }) | |
| 204 | 227 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 205 | 228 | ||
| @@ -220,6 +243,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 220 | 243 | MergeRequests: f.MergeRequests, | |
| 221 | 244 | SigningKey: f.SigningKey, | |
| 222 | 245 | Sessions: f.Sessions, | |
| 246 | + | Runner: f.CI, | |
| 247 | + | Runs: f.CI, | |
| 223 | 248 | Version: serverVersion(), | |
| 224 | 249 | TrustedProxies: cfg.TrustedProxies, | |
| 225 | 250 | Limiter: limiter, | |
| @@ -297,6 +322,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 297 | 322 | if f.hooks != nil { | |
| 298 | 323 | f.hooks.Close(ctx) | |
| 299 | 324 | } | |
| 325 | + | if f.CI != nil { | |
| 326 | + | f.CI.Close(ctx) | |
| 327 | + | } | |
| 300 | 328 | if f.EventLog != nil { | |
| 301 | 329 | f.EventLog.Close(ctx) | |
| 302 | 330 | } | |
M internal/app/root.go +1 -1
| @@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
| 28 | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), | |
| 29 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env), | |
| 30 | 30 | ) | |
| 31 | 31 | return root | |
| 32 | 32 | } | |
M internal/app/serve.go +28 -1
| @@ -4,14 +4,17 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/signal" | |
| 10 | + | "strings" | |
| 9 | 11 | "syscall" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/zerowrap" | |
| 12 | 14 | "github.com/spf13/cobra" | |
| 13 | 15 | ||
| 14 | 16 | "bnema.dev/ermite/internal/config" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | 18 | ) | |
| 16 | 19 | ||
| 17 | 20 | func newServeCmd() *cobra.Command { | |
| @@ -32,7 +35,7 @@ func newServeCmd() *cobra.Command { | |||
| 32 | 35 | ||
| 33 | 36 | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | |
| 34 | 37 | func serve(ctx context.Context) error { | |
| 35 | - | cfg, err := config.Load(os.Getenv) | |
| 38 | + | cfg, err := loadConfig(os.Getenv) | |
| 36 | 39 | if err != nil { | |
| 37 | 40 | return err | |
| 38 | 41 | } | |
| @@ -67,3 +70,27 @@ func serve(ctx context.Context) error { | |||
| 67 | 70 | log.Info().Msg("stopped") | |
| 68 | 71 | return nil | |
| 69 | 72 | } | |
| 73 | + | ||
| 74 | + | // loadConfig reads the configuration and adds the checks that need the domain, | |
| 75 | + | // which config does not import: ERMITE_OWNER follows the rules of repository | |
| 76 | + | // names. Its problem joins the others in one report. | |
| 77 | + | func loadConfig(get func(string) string) (config.Config, error) { | |
| 78 | + | cfg, err := config.Load(get) | |
| 79 | + | var problems []string | |
| 80 | + | var ce *config.Error | |
| 81 | + | switch { | |
| 82 | + | case errors.As(err, &ce): | |
| 83 | + | problems = ce.Problems | |
| 84 | + | case err != nil: | |
| 85 | + | return config.Config{}, err | |
| 86 | + | } | |
| 87 | + | if owner := strings.TrimSpace(get(config.EnvOwner)); owner != "" { | |
| 88 | + | if _, perr := domain.ParseRepoName(owner); perr != nil { | |
| 89 | + | problems = append(problems, config.EnvOwner+": must follow the rules of repository names ("+perr.Error()+")") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if len(problems) > 0 { | |
| 93 | + | return config.Config{}, &config.Error{Problems: problems} | |
| 94 | + | } | |
| 95 | + | return cfg, nil | |
| 96 | + | } | |
A internal/app/serve_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/config" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestLoadConfigChecksTheOwnerName(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | base := map[string]string{ | |
| 18 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |
| 19 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |
| 20 | + | } | |
| 21 | + | load := func(extra map[string]string) (config.Config, error) { | |
| 22 | + | env := map[string]string{} | |
| 23 | + | for k, v := range base { | |
| 24 | + | env[k] = v | |
| 25 | + | } | |
| 26 | + | for k, v := range extra { | |
| 27 | + | env[k] = v | |
| 28 | + | } | |
| 29 | + | return loadConfig(func(k string) string { return env[k] }) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | cfg, err := load(map[string]string{config.EnvOwner: " bnema "}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | assert.Equal(t, "bnema", cfg.Owner) | |
| 35 | + | cfg, err = load(nil) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | assert.Equal(t, config.DefaultOwner, cfg.Owner) | |
| 38 | + | ||
| 39 | + | for _, owner := range []string{"Bad Name", ".dot", "a/b", "x.git"} { | |
| 40 | + | _, err := load(map[string]string{config.EnvOwner: owner}) | |
| 41 | + | var ce *config.Error | |
| 42 | + | require.True(t, errors.As(err, &ce), owner) | |
| 43 | + | assert.Contains(t, ce.Problems[0], config.EnvOwner, owner) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // The problem is reported with the others, in one error. | |
| 47 | + | _, err = load(map[string]string{config.EnvOwner: "Bad", config.EnvCILogRetention: "soon"}) | |
| 48 | + | var ce *config.Error | |
| 49 | + | require.True(t, errors.As(err, &ce)) | |
| 50 | + | assert.Len(t, ce.Problems, 2) | |
| 51 | + | } | |
M internal/app/testdata/web/blob-binary.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -308,6 +314,12 @@ | |||
| 308 | 314 | <span class="k">section</span> | |
| 309 | 315 | </a> | |
| 310 | 316 | </li> | |
| 317 | + | <li data-k="section actions"> | |
| 318 | + | <a href="/files/actions" tabindex="-1"> | |
| 319 | + | <span class="lbl">actions</span> | |
| 320 | + | <span class="k">section</span> | |
| 321 | + | </a> | |
| 322 | + | </li> | |
| 311 | 323 | <li data-k="branch feature/x"> | |
| 312 | 324 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 325 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-code.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -378,6 +384,12 @@ | |||
| 378 | 384 | <span class="k">section</span> | |
| 379 | 385 | </a> | |
| 380 | 386 | </li> | |
| 387 | + | <li data-k="section actions"> | |
| 388 | + | <a href="/files/actions" tabindex="-1"> | |
| 389 | + | <span class="lbl">actions</span> | |
| 390 | + | <span class="k">section</span> | |
| 391 | + | </a> | |
| 392 | + | </li> | |
| 381 | 393 | <li data-k="branch feature/x"> | |
| 382 | 394 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 383 | 395 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-empty.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -308,6 +314,12 @@ | |||
| 308 | 314 | <span class="k">section</span> | |
| 309 | 315 | </a> | |
| 310 | 316 | </li> | |
| 317 | + | <li data-k="section actions"> | |
| 318 | + | <a href="/files/actions" tabindex="-1"> | |
| 319 | + | <span class="lbl">actions</span> | |
| 320 | + | <span class="k">section</span> | |
| 321 | + | </a> | |
| 322 | + | </li> | |
| 311 | 323 | <li data-k="branch feature/x"> | |
| 312 | 324 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 325 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-image.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -310,6 +316,12 @@ | |||
| 310 | 316 | <span class="k">section</span> | |
| 311 | 317 | </a> | |
| 312 | 318 | </li> | |
| 319 | + | <li data-k="section actions"> | |
| 320 | + | <a href="/files/actions" tabindex="-1"> | |
| 321 | + | <span class="lbl">actions</span> | |
| 322 | + | <span class="k">section</span> | |
| 323 | + | </a> | |
| 324 | + | </li> | |
| 313 | 325 | <li data-k="branch feature/x"> | |
| 314 | 326 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 315 | 327 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-markdown.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -86,6 +88,10 @@ | |||
| 86 | 88 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 87 | 89 | </a> | |
| 88 | 90 | </li> | |
| 91 | + | <li> | |
| 92 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 93 | + | </a> | |
| 94 | + | </li> | |
| 89 | 95 | </ul> | |
| 90 | 96 | </nav> | |
| 91 | 97 | </header> | |
| @@ -375,6 +381,12 @@ | |||
| 375 | 381 | <span class="k">section</span> | |
| 376 | 382 | </a> | |
| 377 | 383 | </li> | |
| 384 | + | <li data-k="section actions"> | |
| 385 | + | <a href="/files/actions" tabindex="-1"> | |
| 386 | + | <span class="lbl">actions</span> | |
| 387 | + | <span class="k">section</span> | |
| 388 | + | </a> | |
| 389 | + | </li> | |
| 378 | 390 | <li data-k="branch feature/x"> | |
| 379 | 391 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 380 | 392 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-svg.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -333,6 +339,12 @@ | |||
| 333 | 339 | <span class="k">section</span> | |
| 334 | 340 | </a> | |
| 335 | 341 | </li> | |
| 342 | + | <li data-k="section actions"> | |
| 343 | + | <a href="/files/actions" tabindex="-1"> | |
| 344 | + | <span class="lbl">actions</span> | |
| 345 | + | <span class="k">section</span> | |
| 346 | + | </a> | |
| 347 | + | </li> | |
| 336 | 348 | <li data-k="branch feature/x"> | |
| 337 | 349 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 338 | 350 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-text.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -328,6 +334,12 @@ | |||
| 328 | 334 | <span class="k">section</span> | |
| 329 | 335 | </a> | |
| 330 | 336 | </li> | |
| 337 | + | <li data-k="section actions"> | |
| 338 | + | <a href="/files/actions" tabindex="-1"> | |
| 339 | + | <span class="lbl">actions</span> | |
| 340 | + | <span class="k">section</span> | |
| 341 | + | </a> | |
| 342 | + | </li> | |
| 331 | 343 | <li data-k="branch feature/x"> | |
| 332 | 344 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 333 | 345 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-too-large.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -308,6 +314,12 @@ | |||
| 308 | 314 | <span class="k">section</span> | |
| 309 | 315 | </a> | |
| 310 | 316 | </li> | |
| 317 | + | <li data-k="section actions"> | |
| 318 | + | <a href="/files/actions" tabindex="-1"> | |
| 319 | + | <span class="lbl">actions</span> | |
| 320 | + | <span class="k">section</span> | |
| 321 | + | </a> | |
| 322 | + | </li> | |
| 311 | 323 | <li data-k="branch feature/x"> | |
| 312 | 324 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 313 | 325 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-trojan.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -375,6 +381,12 @@ | |||
| 375 | 381 | <span class="k">section</span> | |
| 376 | 382 | </a> | |
| 377 | 383 | </li> | |
| 384 | + | <li data-k="section actions"> | |
| 385 | + | <a href="/files/actions" tabindex="-1"> | |
| 386 | + | <span class="lbl">actions</span> | |
| 387 | + | <span class="k">section</span> | |
| 388 | + | </a> | |
| 389 | + | </li> | |
| 378 | 390 | <li data-k="branch feature/x"> | |
| 379 | 391 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 380 | 392 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/commit-big.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -345,6 +351,12 @@ | |||
| 345 | 351 | <span class="k">section</span> | |
| 346 | 352 | </a> | |
| 347 | 353 | </li> | |
| 354 | + | <li data-k="section actions"> | |
| 355 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 356 | + | <span class="lbl">actions</span> | |
| 357 | + | <span class="k">section</span> | |
| 358 | + | </a> | |
| 359 | + | </li> | |
| 348 | 360 | <li data-k="branch main"> | |
| 349 | 361 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 350 | 362 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-binary.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -340,6 +346,12 @@ | |||
| 340 | 346 | <span class="k">section</span> | |
| 341 | 347 | </a> | |
| 342 | 348 | </li> | |
| 349 | + | <li data-k="section actions"> | |
| 350 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 351 | + | <span class="lbl">actions</span> | |
| 352 | + | <span class="k">section</span> | |
| 353 | + | </a> | |
| 354 | + | </li> | |
| 343 | 355 | <li data-k="branch main"> | |
| 344 | 356 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 345 | 357 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-merge.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -379,6 +385,12 @@ | |||
| 379 | 385 | <span class="k">section</span> | |
| 380 | 386 | </a> | |
| 381 | 387 | </li> | |
| 388 | + | <li data-k="section actions"> | |
| 389 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 390 | + | <span class="lbl">actions</span> | |
| 391 | + | <span class="k">section</span> | |
| 392 | + | </a> | |
| 393 | + | </li> | |
| 382 | 394 | <li data-k="branch main"> | |
| 383 | 395 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 384 | 396 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-modify-split.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -500,6 +506,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 500 | 506 | <span class="k">section</span> | |
| 501 | 507 | </a> | |
| 502 | 508 | </li> | |
| 509 | + | <li data-k="section actions"> | |
| 510 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 511 | + | <span class="lbl">actions</span> | |
| 512 | + | <span class="k">section</span> | |
| 513 | + | </a> | |
| 514 | + | </li> | |
| 503 | 515 | <li data-k="branch main"> | |
| 504 | 516 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 505 | 517 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-modify.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -500,6 +506,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 500 | 506 | <span class="k">section</span> | |
| 501 | 507 | </a> | |
| 502 | 508 | </li> | |
| 509 | + | <li data-k="section actions"> | |
| 510 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 511 | + | <span class="lbl">actions</span> | |
| 512 | + | <span class="k">section</span> | |
| 513 | + | </a> | |
| 514 | + | </li> | |
| 503 | 515 | <li data-k="branch main"> | |
| 504 | 516 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 505 | 517 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-rename.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -405,6 +411,12 @@ | |||
| 405 | 411 | <span class="k">section</span> | |
| 406 | 412 | </a> | |
| 407 | 413 | </li> | |
| 414 | + | <li data-k="section actions"> | |
| 415 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 416 | + | <span class="lbl">actions</span> | |
| 417 | + | <span class="k">section</span> | |
| 418 | + | </a> | |
| 419 | + | </li> | |
| 408 | 420 | <li data-k="branch main"> | |
| 409 | 421 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 410 | 422 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-root.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -721,6 +727,12 @@ | |||
| 721 | 727 | <span class="k">section</span> | |
| 722 | 728 | </a> | |
| 723 | 729 | </li> | |
| 730 | + | <li data-k="section actions"> | |
| 731 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 732 | + | <span class="lbl">actions</span> | |
| 733 | + | <span class="k">section</span> | |
| 734 | + | </a> | |
| 735 | + | </li> | |
| 724 | 736 | <li data-k="branch main"> | |
| 725 | 737 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 726 | 738 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-trojan.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -514,6 +520,12 @@ | |||
| 514 | 520 | <span class="k">section</span> | |
| 515 | 521 | </a> | |
| 516 | 522 | </li> | |
| 523 | + | <li data-k="section actions"> | |
| 524 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 525 | + | <span class="lbl">actions</span> | |
| 526 | + | <span class="k">section</span> | |
| 527 | + | </a> | |
| 528 | + | </li> | |
| 517 | 529 | <li data-k="branch main"> | |
| 518 | 530 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 519 | 531 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-page2.html +16 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -103,6 +109,8 @@ | |||
| 103 | 109 | <td class="c-subject"> | |
| 104 | 110 | <a href="/long/commit/SHA40">commit number 1</a> | |
| 105 | 111 | </td> | |
| 112 | + | <td class="c-ck"> | |
| 113 | + | </td> | |
| 106 | 114 | <td class="c-who dim">T</td> | |
| 107 | 115 | <td class="c-when nowrap dim"> | |
| 108 | 116 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -115,6 +123,8 @@ | |||
| 115 | 123 | <td class="c-subject"> | |
| 116 | 124 | <a href="/long/commit/SHA40">commit number 0</a> | |
| 117 | 125 | </td> | |
| 126 | + | <td class="c-ck"> | |
| 127 | + | </td> | |
| 118 | 128 | <td class="c-who dim">T</td> | |
| 119 | 129 | <td class="c-when nowrap dim"> | |
| 120 | 130 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -327,6 +337,12 @@ | |||
| 327 | 337 | <span class="k">section</span> | |
| 328 | 338 | </a> | |
| 329 | 339 | </li> | |
| 340 | + | <li data-k="section actions"> | |
| 341 | + | <a href="/long/actions" tabindex="-1"> | |
| 342 | + | <span class="lbl">actions</span> | |
| 343 | + | <span class="k">section</span> | |
| 344 | + | </a> | |
| 345 | + | </li> | |
| 330 | 346 | <li data-k="branch main"> | |
| 331 | 347 | <a href="/long/tree/main" tabindex="-1"> | |
| 332 | 348 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-path.html +16 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -107,6 +113,8 @@ | |||
| 107 | 113 | <td class="c-subject"> | |
| 108 | 114 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 109 | 115 | </td> | |
| 116 | + | <td class="c-ck"> | |
| 117 | + | </td> | |
| 110 | 118 | <td class="c-who dim">T</td> | |
| 111 | 119 | <td class="c-when nowrap dim"> | |
| 112 | 120 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -128,6 +136,8 @@ | |||
| 128 | 136 | <td class="c-subject"> | |
| 129 | 137 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 130 | 138 | </td> | |
| 139 | + | <td class="c-ck"> | |
| 140 | + | </td> | |
| 131 | 141 | <td class="c-who dim">T</td> | |
| 132 | 142 | <td class="c-when nowrap dim"> | |
| 133 | 143 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -342,6 +352,12 @@ | |||
| 342 | 352 | <span class="k">section</span> | |
| 343 | 353 | </a> | |
| 344 | 354 | </li> | |
| 355 | + | <li data-k="section actions"> | |
| 356 | + | <a href="/long/actions" tabindex="-1"> | |
| 357 | + | <span class="lbl">actions</span> | |
| 358 | + | <span class="k">section</span> | |
| 359 | + | </a> | |
| 360 | + | </li> | |
| 345 | 361 | <li data-k="branch main"> | |
| 346 | 362 | <a href="/long/tree/main" tabindex="-1"> | |
| 347 | 363 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-root.html +113 -1
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -106,6 +112,8 @@ | |||
| 106 | 112 | <a href="/long/commit/SHA40">commit number 51</a> | |
| 107 | 113 | <span class="pill">main</span> | |
| 108 | 114 | </td> | |
| 115 | + | <td class="c-ck"> | |
| 116 | + | </td> | |
| 109 | 117 | <td class="c-who dim">T</td> | |
| 110 | 118 | <td class="c-when nowrap dim"> | |
| 111 | 119 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -118,6 +126,8 @@ | |||
| 118 | 126 | <td class="c-subject"> | |
| 119 | 127 | <a href="/long/commit/SHA40">commit number 50</a> | |
| 120 | 128 | </td> | |
| 129 | + | <td class="c-ck"> | |
| 130 | + | </td> | |
| 121 | 131 | <td class="c-who dim">T</td> | |
| 122 | 132 | <td class="c-when nowrap dim"> | |
| 123 | 133 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -139,6 +149,8 @@ | |||
| 139 | 149 | <td class="c-subject"> | |
| 140 | 150 | <a href="/long/commit/SHA40">commit number 49</a> | |
| 141 | 151 | </td> | |
| 152 | + | <td class="c-ck"> | |
| 153 | + | </td> | |
| 142 | 154 | <td class="c-who dim">T</td> | |
| 143 | 155 | <td class="c-when nowrap dim"> | |
| 144 | 156 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -151,6 +163,8 @@ | |||
| 151 | 163 | <td class="c-subject"> | |
| 152 | 164 | <a href="/long/commit/SHA40">commit number 48</a> | |
| 153 | 165 | </td> | |
| 166 | + | <td class="c-ck"> | |
| 167 | + | </td> | |
| 154 | 168 | <td class="c-who dim">T</td> | |
| 155 | 169 | <td class="c-when nowrap dim"> | |
| 156 | 170 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -172,6 +186,8 @@ | |||
| 172 | 186 | <td class="c-subject"> | |
| 173 | 187 | <a href="/long/commit/SHA40">commit number 47</a> | |
| 174 | 188 | </td> | |
| 189 | + | <td class="c-ck"> | |
| 190 | + | </td> | |
| 175 | 191 | <td class="c-who dim">T</td> | |
| 176 | 192 | <td class="c-when nowrap dim"> | |
| 177 | 193 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -184,6 +200,8 @@ | |||
| 184 | 200 | <td class="c-subject"> | |
| 185 | 201 | <a href="/long/commit/SHA40">commit number 46</a> | |
| 186 | 202 | </td> | |
| 203 | + | <td class="c-ck"> | |
| 204 | + | </td> | |
| 187 | 205 | <td class="c-who dim">T</td> | |
| 188 | 206 | <td class="c-when nowrap dim"> | |
| 189 | 207 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -205,6 +223,8 @@ | |||
| 205 | 223 | <td class="c-subject"> | |
| 206 | 224 | <a href="/long/commit/SHA40">commit number 45</a> | |
| 207 | 225 | </td> | |
| 226 | + | <td class="c-ck"> | |
| 227 | + | </td> | |
| 208 | 228 | <td class="c-who dim">T</td> | |
| 209 | 229 | <td class="c-when nowrap dim"> | |
| 210 | 230 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -217,6 +237,8 @@ | |||
| 217 | 237 | <td class="c-subject"> | |
| 218 | 238 | <a href="/long/commit/SHA40">commit number 44</a> | |
| 219 | 239 | </td> | |
| 240 | + | <td class="c-ck"> | |
| 241 | + | </td> | |
| 220 | 242 | <td class="c-who dim">T</td> | |
| 221 | 243 | <td class="c-when nowrap dim"> | |
| 222 | 244 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -238,6 +260,8 @@ | |||
| 238 | 260 | <td class="c-subject"> | |
| 239 | 261 | <a href="/long/commit/SHA40">commit number 43</a> | |
| 240 | 262 | </td> | |
| 263 | + | <td class="c-ck"> | |
| 264 | + | </td> | |
| 241 | 265 | <td class="c-who dim">T</td> | |
| 242 | 266 | <td class="c-when nowrap dim"> | |
| 243 | 267 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -250,6 +274,8 @@ | |||
| 250 | 274 | <td class="c-subject"> | |
| 251 | 275 | <a href="/long/commit/SHA40">commit number 42</a> | |
| 252 | 276 | </td> | |
| 277 | + | <td class="c-ck"> | |
| 278 | + | </td> | |
| 253 | 279 | <td class="c-who dim">T</td> | |
| 254 | 280 | <td class="c-when nowrap dim"> | |
| 255 | 281 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -271,6 +297,8 @@ | |||
| 271 | 297 | <td class="c-subject"> | |
| 272 | 298 | <a href="/long/commit/SHA40">commit number 41</a> | |
| 273 | 299 | </td> | |
| 300 | + | <td class="c-ck"> | |
| 301 | + | </td> | |
| 274 | 302 | <td class="c-who dim">T</td> | |
| 275 | 303 | <td class="c-when nowrap dim"> | |
| 276 | 304 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -283,6 +311,8 @@ | |||
| 283 | 311 | <td class="c-subject"> | |
| 284 | 312 | <a href="/long/commit/SHA40">commit number 40</a> | |
| 285 | 313 | </td> | |
| 314 | + | <td class="c-ck"> | |
| 315 | + | </td> | |
| 286 | 316 | <td class="c-who dim">T</td> | |
| 287 | 317 | <td class="c-when nowrap dim"> | |
| 288 | 318 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -304,6 +334,8 @@ | |||
| 304 | 334 | <td class="c-subject"> | |
| 305 | 335 | <a href="/long/commit/SHA40">commit number 39</a> | |
| 306 | 336 | </td> | |
| 337 | + | <td class="c-ck"> | |
| 338 | + | </td> | |
| 307 | 339 | <td class="c-who dim">T</td> | |
| 308 | 340 | <td class="c-when nowrap dim"> | |
| 309 | 341 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -316,6 +348,8 @@ | |||
| 316 | 348 | <td class="c-subject"> | |
| 317 | 349 | <a href="/long/commit/SHA40">commit number 38</a> | |
| 318 | 350 | </td> | |
| 351 | + | <td class="c-ck"> | |
| 352 | + | </td> | |
| 319 | 353 | <td class="c-who dim">T</td> | |
| 320 | 354 | <td class="c-when nowrap dim"> | |
| 321 | 355 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -337,6 +371,8 @@ | |||
| 337 | 371 | <td class="c-subject"> | |
| 338 | 372 | <a href="/long/commit/SHA40">commit number 37</a> | |
| 339 | 373 | </td> | |
| 374 | + | <td class="c-ck"> | |
| 375 | + | </td> | |
| 340 | 376 | <td class="c-who dim">T</td> | |
| 341 | 377 | <td class="c-when nowrap dim"> | |
| 342 | 378 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -349,6 +385,8 @@ | |||
| 349 | 385 | <td class="c-subject"> | |
| 350 | 386 | <a href="/long/commit/SHA40">commit number 36</a> | |
| 351 | 387 | </td> | |
| 388 | + | <td class="c-ck"> | |
| 389 | + | </td> | |
| 352 | 390 | <td class="c-who dim">T</td> | |
| 353 | 391 | <td class="c-when nowrap dim"> | |
| 354 | 392 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -370,6 +408,8 @@ | |||
| 370 | 408 | <td class="c-subject"> | |
| 371 | 409 | <a href="/long/commit/SHA40">commit number 35</a> | |
| 372 | 410 | </td> | |
| 411 | + | <td class="c-ck"> | |
| 412 | + | </td> | |
| 373 | 413 | <td class="c-who dim">T</td> | |
| 374 | 414 | <td class="c-when nowrap dim"> | |
| 375 | 415 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -382,6 +422,8 @@ | |||
| 382 | 422 | <td class="c-subject"> | |
| 383 | 423 | <a href="/long/commit/SHA40">commit number 34</a> | |
| 384 | 424 | </td> | |
| 425 | + | <td class="c-ck"> | |
| 426 | + | </td> | |
| 385 | 427 | <td class="c-who dim">T</td> | |
| 386 | 428 | <td class="c-when nowrap dim"> | |
| 387 | 429 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -403,6 +445,8 @@ | |||
| 403 | 445 | <td class="c-subject"> | |
| 404 | 446 | <a href="/long/commit/SHA40">commit number 33</a> | |
| 405 | 447 | </td> | |
| 448 | + | <td class="c-ck"> | |
| 449 | + | </td> | |
| 406 | 450 | <td class="c-who dim">T</td> | |
| 407 | 451 | <td class="c-when nowrap dim"> | |
| 408 | 452 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -415,6 +459,8 @@ | |||
| 415 | 459 | <td class="c-subject"> | |
| 416 | 460 | <a href="/long/commit/SHA40">commit number 32</a> | |
| 417 | 461 | </td> | |
| 462 | + | <td class="c-ck"> | |
| 463 | + | </td> | |
| 418 | 464 | <td class="c-who dim">T</td> | |
| 419 | 465 | <td class="c-when nowrap dim"> | |
| 420 | 466 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -436,6 +482,8 @@ | |||
| 436 | 482 | <td class="c-subject"> | |
| 437 | 483 | <a href="/long/commit/SHA40">commit number 31</a> | |
| 438 | 484 | </td> | |
| 485 | + | <td class="c-ck"> | |
| 486 | + | </td> | |
| 439 | 487 | <td class="c-who dim">T</td> | |
| 440 | 488 | <td class="c-when nowrap dim"> | |
| 441 | 489 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -448,6 +496,8 @@ | |||
| 448 | 496 | <td class="c-subject"> | |
| 449 | 497 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 450 | 498 | </td> | |
| 499 | + | <td class="c-ck"> | |
| 500 | + | </td> | |
| 451 | 501 | <td class="c-who dim">T</td> | |
| 452 | 502 | <td class="c-when nowrap dim"> | |
| 453 | 503 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -469,6 +519,8 @@ | |||
| 469 | 519 | <td class="c-subject"> | |
| 470 | 520 | <a href="/long/commit/SHA40">commit number 29</a> | |
| 471 | 521 | </td> | |
| 522 | + | <td class="c-ck"> | |
| 523 | + | </td> | |
| 472 | 524 | <td class="c-who dim">T</td> | |
| 473 | 525 | <td class="c-when nowrap dim"> | |
| 474 | 526 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -481,6 +533,8 @@ | |||
| 481 | 533 | <td class="c-subject"> | |
| 482 | 534 | <a href="/long/commit/SHA40">commit number 28</a> | |
| 483 | 535 | </td> | |
| 536 | + | <td class="c-ck"> | |
| 537 | + | </td> | |
| 484 | 538 | <td class="c-who dim">T</td> | |
| 485 | 539 | <td class="c-when nowrap dim"> | |
| 486 | 540 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -502,6 +556,8 @@ | |||
| 502 | 556 | <td class="c-subject"> | |
| 503 | 557 | <a href="/long/commit/SHA40">commit number 27</a> | |
| 504 | 558 | </td> | |
| 559 | + | <td class="c-ck"> | |
| 560 | + | </td> | |
| 505 | 561 | <td class="c-who dim">T</td> | |
| 506 | 562 | <td class="c-when nowrap dim"> | |
| 507 | 563 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -514,6 +570,8 @@ | |||
| 514 | 570 | <td class="c-subject"> | |
| 515 | 571 | <a href="/long/commit/SHA40">commit number 26</a> | |
| 516 | 572 | </td> | |
| 573 | + | <td class="c-ck"> | |
| 574 | + | </td> | |
| 517 | 575 | <td class="c-who dim">T</td> | |
| 518 | 576 | <td class="c-when nowrap dim"> | |
| 519 | 577 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -535,6 +593,8 @@ | |||
| 535 | 593 | <td class="c-subject"> | |
| 536 | 594 | <a href="/long/commit/SHA40">commit number 25</a> | |
| 537 | 595 | </td> | |
| 596 | + | <td class="c-ck"> | |
| 597 | + | </td> | |
| 538 | 598 | <td class="c-who dim">T</td> | |
| 539 | 599 | <td class="c-when nowrap dim"> | |
| 540 | 600 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -547,6 +607,8 @@ | |||
| 547 | 607 | <td class="c-subject"> | |
| 548 | 608 | <a href="/long/commit/SHA40">commit number 24</a> | |
| 549 | 609 | </td> | |
| 610 | + | <td class="c-ck"> | |
| 611 | + | </td> | |
| 550 | 612 | <td class="c-who dim">T</td> | |
| 551 | 613 | <td class="c-when nowrap dim"> | |
| 552 | 614 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -568,6 +630,8 @@ | |||
| 568 | 630 | <td class="c-subject"> | |
| 569 | 631 | <a href="/long/commit/SHA40">commit number 23</a> | |
| 570 | 632 | </td> | |
| 633 | + | <td class="c-ck"> | |
| 634 | + | </td> | |
| 571 | 635 | <td class="c-who dim">T</td> | |
| 572 | 636 | <td class="c-when nowrap dim"> | |
| 573 | 637 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -580,6 +644,8 @@ | |||
| 580 | 644 | <td class="c-subject"> | |
| 581 | 645 | <a href="/long/commit/SHA40">commit number 22</a> | |
| 582 | 646 | </td> | |
| 647 | + | <td class="c-ck"> | |
| 648 | + | </td> | |
| 583 | 649 | <td class="c-who dim">T</td> | |
| 584 | 650 | <td class="c-when nowrap dim"> | |
| 585 | 651 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -601,6 +667,8 @@ | |||
| 601 | 667 | <td class="c-subject"> | |
| 602 | 668 | <a href="/long/commit/SHA40">commit number 21</a> | |
| 603 | 669 | </td> | |
| 670 | + | <td class="c-ck"> | |
| 671 | + | </td> | |
| 604 | 672 | <td class="c-who dim">T</td> | |
| 605 | 673 | <td class="c-when nowrap dim"> | |
| 606 | 674 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -613,6 +681,8 @@ | |||
| 613 | 681 | <td class="c-subject"> | |
| 614 | 682 | <a href="/long/commit/SHA40">commit number 20</a> | |
| 615 | 683 | </td> | |
| 684 | + | <td class="c-ck"> | |
| 685 | + | </td> | |
| 616 | 686 | <td class="c-who dim">T</td> | |
| 617 | 687 | <td class="c-when nowrap dim"> | |
| 618 | 688 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -634,6 +704,8 @@ | |||
| 634 | 704 | <td class="c-subject"> | |
| 635 | 705 | <a href="/long/commit/SHA40">commit number 19</a> | |
| 636 | 706 | </td> | |
| 707 | + | <td class="c-ck"> | |
| 708 | + | </td> | |
| 637 | 709 | <td class="c-who dim">T</td> | |
| 638 | 710 | <td class="c-when nowrap dim"> | |
| 639 | 711 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -646,6 +718,8 @@ | |||
| 646 | 718 | <td class="c-subject"> | |
| 647 | 719 | <a href="/long/commit/SHA40">commit number 18</a> | |
| 648 | 720 | </td> | |
| 721 | + | <td class="c-ck"> | |
| 722 | + | </td> | |
| 649 | 723 | <td class="c-who dim">T</td> | |
| 650 | 724 | <td class="c-when nowrap dim"> | |
| 651 | 725 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -667,6 +741,8 @@ | |||
| 667 | 741 | <td class="c-subject"> | |
| 668 | 742 | <a href="/long/commit/SHA40">commit number 17</a> | |
| 669 | 743 | </td> | |
| 744 | + | <td class="c-ck"> | |
| 745 | + | </td> | |
| 670 | 746 | <td class="c-who dim">T</td> | |
| 671 | 747 | <td class="c-when nowrap dim"> | |
| 672 | 748 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -679,6 +755,8 @@ | |||
| 679 | 755 | <td class="c-subject"> | |
| 680 | 756 | <a href="/long/commit/SHA40">commit number 16</a> | |
| 681 | 757 | </td> | |
| 758 | + | <td class="c-ck"> | |
| 759 | + | </td> | |
| 682 | 760 | <td class="c-who dim">T</td> | |
| 683 | 761 | <td class="c-when nowrap dim"> | |
| 684 | 762 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -700,6 +778,8 @@ | |||
| 700 | 778 | <td class="c-subject"> | |
| 701 | 779 | <a href="/long/commit/SHA40">commit number 15</a> | |
| 702 | 780 | </td> | |
| 781 | + | <td class="c-ck"> | |
| 782 | + | </td> | |
| 703 | 783 | <td class="c-who dim">T</td> | |
| 704 | 784 | <td class="c-when nowrap dim"> | |
| 705 | 785 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -712,6 +792,8 @@ | |||
| 712 | 792 | <td class="c-subject"> | |
| 713 | 793 | <a href="/long/commit/SHA40">commit number 14</a> | |
| 714 | 794 | </td> | |
| 795 | + | <td class="c-ck"> | |
| 796 | + | </td> | |
| 715 | 797 | <td class="c-who dim">T</td> | |
| 716 | 798 | <td class="c-when nowrap dim"> | |
| 717 | 799 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -733,6 +815,8 @@ | |||
| 733 | 815 | <td class="c-subject"> | |
| 734 | 816 | <a href="/long/commit/SHA40">commit number 13</a> | |
| 735 | 817 | </td> | |
| 818 | + | <td class="c-ck"> | |
| 819 | + | </td> | |
| 736 | 820 | <td class="c-who dim">T</td> | |
| 737 | 821 | <td class="c-when nowrap dim"> | |
| 738 | 822 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -745,6 +829,8 @@ | |||
| 745 | 829 | <td class="c-subject"> | |
| 746 | 830 | <a href="/long/commit/SHA40">commit number 12</a> | |
| 747 | 831 | </td> | |
| 832 | + | <td class="c-ck"> | |
| 833 | + | </td> | |
| 748 | 834 | <td class="c-who dim">T</td> | |
| 749 | 835 | <td class="c-when nowrap dim"> | |
| 750 | 836 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -766,6 +852,8 @@ | |||
| 766 | 852 | <td class="c-subject"> | |
| 767 | 853 | <a href="/long/commit/SHA40">commit number 11</a> | |
| 768 | 854 | </td> | |
| 855 | + | <td class="c-ck"> | |
| 856 | + | </td> | |
| 769 | 857 | <td class="c-who dim">T</td> | |
| 770 | 858 | <td class="c-when nowrap dim"> | |
| 771 | 859 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -778,6 +866,8 @@ | |||
| 778 | 866 | <td class="c-subject"> | |
| 779 | 867 | <a href="/long/commit/SHA40">commit number 10</a> | |
| 780 | 868 | </td> | |
| 869 | + | <td class="c-ck"> | |
| 870 | + | </td> | |
| 781 | 871 | <td class="c-who dim">T</td> | |
| 782 | 872 | <td class="c-when nowrap dim"> | |
| 783 | 873 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -799,6 +889,8 @@ | |||
| 799 | 889 | <td class="c-subject"> | |
| 800 | 890 | <a href="/long/commit/SHA40">commit number 9</a> | |
| 801 | 891 | </td> | |
| 892 | + | <td class="c-ck"> | |
| 893 | + | </td> | |
| 802 | 894 | <td class="c-who dim">T</td> | |
| 803 | 895 | <td class="c-when nowrap dim"> | |
| 804 | 896 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -811,6 +903,8 @@ | |||
| 811 | 903 | <td class="c-subject"> | |
| 812 | 904 | <a href="/long/commit/SHA40">commit number 8</a> | |
| 813 | 905 | </td> | |
| 906 | + | <td class="c-ck"> | |
| 907 | + | </td> | |
| 814 | 908 | <td class="c-who dim">T</td> | |
| 815 | 909 | <td class="c-when nowrap dim"> | |
| 816 | 910 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -832,6 +926,8 @@ | |||
| 832 | 926 | <td class="c-subject"> | |
| 833 | 927 | <a href="/long/commit/SHA40">commit number 7</a> | |
| 834 | 928 | </td> | |
| 929 | + | <td class="c-ck"> | |
| 930 | + | </td> | |
| 835 | 931 | <td class="c-who dim">T</td> | |
| 836 | 932 | <td class="c-when nowrap dim"> | |
| 837 | 933 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -844,6 +940,8 @@ | |||
| 844 | 940 | <td class="c-subject"> | |
| 845 | 941 | <a href="/long/commit/SHA40">commit number 6</a> | |
| 846 | 942 | </td> | |
| 943 | + | <td class="c-ck"> | |
| 944 | + | </td> | |
| 847 | 945 | <td class="c-who dim">T</td> | |
| 848 | 946 | <td class="c-when nowrap dim"> | |
| 849 | 947 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -865,6 +963,8 @@ | |||
| 865 | 963 | <td class="c-subject"> | |
| 866 | 964 | <a href="/long/commit/SHA40">commit number 5</a> | |
| 867 | 965 | </td> | |
| 966 | + | <td class="c-ck"> | |
| 967 | + | </td> | |
| 868 | 968 | <td class="c-who dim">T</td> | |
| 869 | 969 | <td class="c-when nowrap dim"> | |
| 870 | 970 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -877,6 +977,8 @@ | |||
| 877 | 977 | <td class="c-subject"> | |
| 878 | 978 | <a href="/long/commit/SHA40">commit number 4</a> | |
| 879 | 979 | </td> | |
| 980 | + | <td class="c-ck"> | |
| 981 | + | </td> | |
| 880 | 982 | <td class="c-who dim">T</td> | |
| 881 | 983 | <td class="c-when nowrap dim"> | |
| 882 | 984 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -898,6 +1000,8 @@ | |||
| 898 | 1000 | <td class="c-subject"> | |
| 899 | 1001 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 900 | 1002 | </td> | |
| 1003 | + | <td class="c-ck"> | |
| 1004 | + | </td> | |
| 901 | 1005 | <td class="c-who dim">T</td> | |
| 902 | 1006 | <td class="c-when nowrap dim"> | |
| 903 | 1007 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -910,6 +1014,8 @@ | |||
| 910 | 1014 | <td class="c-subject"> | |
| 911 | 1015 | <a href="/long/commit/SHA40">commit number 2</a> | |
| 912 | 1016 | </td> | |
| 1017 | + | <td class="c-ck"> | |
| 1018 | + | </td> | |
| 913 | 1019 | <td class="c-who dim">T</td> | |
| 914 | 1020 | <td class="c-when nowrap dim"> | |
| 915 | 1021 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -959,7 +1065,7 @@ | |||
| 959 | 1065 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 960 | 1066 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 961 | 1067 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 962 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 1068 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 963 | 1069 | gap -> ermiteLive.reload()"> | |
| 964 | 1070 | </span> | |
| 965 | 1071 | </div> | |
| @@ -1131,6 +1237,12 @@ | |||
| 1131 | 1237 | <span class="k">section</span> | |
| 1132 | 1238 | </a> | |
| 1133 | 1239 | </li> | |
| 1240 | + | <li data-k="section actions"> | |
| 1241 | + | <a href="/long/actions" tabindex="-1"> | |
| 1242 | + | <span class="lbl">actions</span> | |
| 1243 | + | <span class="k">section</span> | |
| 1244 | + | </a> | |
| 1245 | + | </li> | |
| 1134 | 1246 | <li data-k="branch main"> | |
| 1135 | 1247 | <a href="/long/tree/main" tabindex="-1"> | |
| 1136 | 1248 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-small.html +29 -1
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -106,6 +112,8 @@ | |||
| 106 | 112 | <a href="/diffs/commit/SHA40">add a very large file</a> | |
| 107 | 113 | <span class="pill">main</span> | |
| 108 | 114 | </td> | |
| 115 | + | <td class="c-ck"> | |
| 116 | + | </td> | |
| 109 | 117 | <td class="c-who dim">T</td> | |
| 110 | 118 | <td class="c-when nowrap dim"> | |
| 111 | 119 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -127,6 +135,8 @@ | |||
| 127 | 135 | <td class="c-subject"> | |
| 128 | 136 | <a href="/diffs/commit/SHA40">add evil.go</a> | |
| 129 | 137 | </td> | |
| 138 | + | <td class="c-ck"> | |
| 139 | + | </td> | |
| 130 | 140 | <td class="c-who dim">T</td> | |
| 131 | 141 | <td class="c-when nowrap dim"> | |
| 132 | 142 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -149,6 +159,8 @@ | |||
| 149 | 159 | <a href="/diffs/commit/SHA40">merge the side branch</a> | |
| 150 | 160 | <span class="pill">merge</span> | |
| 151 | 161 | </td> | |
| 162 | + | <td class="c-ck"> | |
| 163 | + | </td> | |
| 152 | 164 | <td class="c-who dim">T</td> | |
| 153 | 165 | <td class="c-when nowrap dim"> | |
| 154 | 166 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -171,6 +183,8 @@ | |||
| 171 | 183 | <a href="/diffs/commit/SHA40">work on the side</a> | |
| 172 | 184 | <span class="pill">side</span> | |
| 173 | 185 | </td> | |
| 186 | + | <td class="c-ck"> | |
| 187 | + | </td> | |
| 174 | 188 | <td class="c-who dim">T</td> | |
| 175 | 189 | <td class="c-when nowrap dim"> | |
| 176 | 190 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -192,6 +206,8 @@ | |||
| 192 | 206 | <td class="c-subject"> | |
| 193 | 207 | <a href="/diffs/commit/SHA40">change a binary file</a> | |
| 194 | 208 | </td> | |
| 209 | + | <td class="c-ck"> | |
| 210 | + | </td> | |
| 195 | 211 | <td class="c-who dim">T</td> | |
| 196 | 212 | <td class="c-when nowrap dim"> | |
| 197 | 213 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -213,6 +229,8 @@ | |||
| 213 | 229 | <td class="c-subject"> | |
| 214 | 230 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |
| 215 | 231 | </td> | |
| 232 | + | <td class="c-ck"> | |
| 233 | + | </td> | |
| 216 | 234 | <td class="c-who dim">T</td> | |
| 217 | 235 | <td class="c-when nowrap dim"> | |
| 218 | 236 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -236,6 +254,8 @@ | |||
| 236 | 254 | <span class="pill tag"> | |
| 237 | 255 | <span class="sr">tag </span>v1.0.0</span> | |
| 238 | 256 | </td> | |
| 257 | + | <td class="c-ck"> | |
| 258 | + | </td> | |
| 239 | 259 | <td class="c-who dim">T</td> | |
| 240 | 260 | <td class="c-when nowrap dim"> | |
| 241 | 261 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -257,6 +277,8 @@ | |||
| 257 | 277 | <td class="c-subject"> | |
| 258 | 278 | <a href="/diffs/commit/SHA40">root: the first files</a> | |
| 259 | 279 | </td> | |
| 280 | + | <td class="c-ck"> | |
| 281 | + | </td> | |
| 260 | 282 | <td class="c-who dim">T</td> | |
| 261 | 283 | <td class="c-when nowrap dim"> | |
| 262 | 284 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -304,7 +326,7 @@ | |||
| 304 | 326 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 305 | 327 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 306 | 328 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 307 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 329 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 308 | 330 | gap -> ermiteLive.reload()"> | |
| 309 | 331 | </span> | |
| 310 | 332 | </div> | |
| @@ -484,6 +506,12 @@ | |||
| 484 | 506 | <span class="k">section</span> | |
| 485 | 507 | </a> | |
| 486 | 508 | </li> | |
| 509 | + | <li data-k="section actions"> | |
| 510 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 511 | + | <span class="lbl">actions</span> | |
| 512 | + | <span class="k">section</span> | |
| 513 | + | </a> | |
| 514 | + | </li> | |
| 487 | 515 | <li data-k="branch main"> | |
| 488 | 516 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 489 | 517 | <span class="lbl">main</span> | |
M internal/app/testdata/web/empty.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/home.html +3 -1
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -113,7 +115,7 @@ | |||
| 113 | 115 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 114 | 116 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 115 | 117 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 116 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 118 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 117 | 119 | gap -> ermiteLive.reload()"> | |
| 118 | 120 | </span> | |
| 119 | 121 | </div> | |
M internal/app/testdata/web/not-found-page.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/not-found.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/refs.html +13 -1
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -77,6 +79,10 @@ | |||
| 77 | 79 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 78 | 80 | </a> | |
| 79 | 81 | </li> | |
| 82 | + | <li> | |
| 83 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 84 | + | </a> | |
| 85 | + | </li> | |
| 80 | 86 | </ul> | |
| 81 | 87 | </nav> | |
| 82 | 88 | </header> | |
| @@ -196,7 +202,7 @@ | |||
| 196 | 202 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 197 | 203 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 198 | 204 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 199 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 205 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 200 | 206 | gap -> ermiteLive.reload()"> | |
| 201 | 207 | </span> | |
| 202 | 208 | </div> | |
| @@ -380,6 +386,12 @@ | |||
| 380 | 386 | <span class="k">section</span> | |
| 381 | 387 | </a> | |
| 382 | 388 | </li> | |
| 389 | + | <li data-k="section actions"> | |
| 390 | + | <a href="/demo/actions" tabindex="-1"> | |
| 391 | + | <span class="lbl">actions</span> | |
| 392 | + | <span class="k">section</span> | |
| 393 | + | </a> | |
| 394 | + | </li> | |
| 383 | 395 | <li data-k="branch feature/x"> | |
| 384 | 396 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 385 | 397 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/summary.html +17 -1
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -74,6 +76,10 @@ | |||
| 74 | 76 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 75 | 77 | </a> | |
| 76 | 78 | </li> | |
| 79 | + | <li> | |
| 80 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 81 | + | </a> | |
| 82 | + | </li> | |
| 77 | 83 | </ul> | |
| 78 | 84 | </nav> | |
| 79 | 85 | </header> | |
| @@ -166,6 +172,8 @@ | |||
| 166 | 172 | <span class="pill tag"> | |
| 167 | 173 | <span class="sr">tag </span>v0.2.0</span> | |
| 168 | 174 | </td> | |
| 175 | + | <td class="c-ck"> | |
| 176 | + | </td> | |
| 169 | 177 | <td class="c-who dim">T</td> | |
| 170 | 178 | <td class="c-when nowrap dim"> | |
| 171 | 179 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -180,6 +188,8 @@ | |||
| 180 | 188 | <span class="pill tag"> | |
| 181 | 189 | <span class="sr">tag </span>v0.1.0</span> | |
| 182 | 190 | </td> | |
| 191 | + | <td class="c-ck"> | |
| 192 | + | </td> | |
| 183 | 193 | <td class="c-who dim">T</td> | |
| 184 | 194 | <td class="c-when nowrap dim"> | |
| 185 | 195 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -215,7 +225,7 @@ | |||
| 215 | 225 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 216 | 226 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 217 | 227 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 218 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 228 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 219 | 229 | gap -> ermiteLive.reload()"> | |
| 220 | 230 | </span> | |
| 221 | 231 | </div> | |
| @@ -399,6 +409,12 @@ | |||
| 399 | 409 | <span class="k">section</span> | |
| 400 | 410 | </a> | |
| 401 | 411 | </li> | |
| 412 | + | <li data-k="section actions"> | |
| 413 | + | <a href="/demo/actions" tabindex="-1"> | |
| 414 | + | <span class="lbl">actions</span> | |
| 415 | + | <span class="k">section</span> | |
| 416 | + | </a> | |
| 417 | + | </li> | |
| 402 | 418 | <li data-k="branch feature/x"> | |
| 403 | 419 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 404 | 420 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-nested.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -86,6 +88,10 @@ | |||
| 86 | 88 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 87 | 89 | </a> | |
| 88 | 90 | </li> | |
| 91 | + | <li> | |
| 92 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 93 | + | </a> | |
| 94 | + | </li> | |
| 89 | 95 | </ul> | |
| 90 | 96 | </nav> | |
| 91 | 97 | </header> | |
| @@ -328,6 +334,12 @@ | |||
| 328 | 334 | <span class="k">section</span> | |
| 329 | 335 | </a> | |
| 330 | 336 | </li> | |
| 337 | + | <li data-k="section actions"> | |
| 338 | + | <a href="/files/actions" tabindex="-1"> | |
| 339 | + | <span class="lbl">actions</span> | |
| 340 | + | <span class="k">section</span> | |
| 341 | + | </a> | |
| 342 | + | </li> | |
| 331 | 343 | <li data-k="branch feature/x"> | |
| 332 | 344 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 333 | 345 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-root.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -80,6 +82,10 @@ | |||
| 80 | 82 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 81 | 83 | </a> | |
| 82 | 84 | </li> | |
| 85 | + | <li> | |
| 86 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 87 | + | </a> | |
| 88 | + | </li> | |
| 83 | 89 | </ul> | |
| 84 | 90 | </nav> | |
| 85 | 91 | </header> | |
| @@ -555,6 +561,12 @@ | |||
| 555 | 561 | <span class="k">section</span> | |
| 556 | 562 | </a> | |
| 557 | 563 | </li> | |
| 564 | + | <li data-k="section actions"> | |
| 565 | + | <a href="/files/actions" tabindex="-1"> | |
| 566 | + | <span class="lbl">actions</span> | |
| 567 | + | <span class="k">section</span> | |
| 568 | + | </a> | |
| 569 | + | </li> | |
| 558 | 570 | <li data-k="branch feature/x"> | |
| 559 | 571 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 560 | 572 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-slash-ref.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -325,6 +331,12 @@ | |||
| 325 | 331 | <span class="k">section</span> | |
| 326 | 332 | </a> | |
| 327 | 333 | </li> | |
| 334 | + | <li data-k="section actions"> | |
| 335 | + | <a href="/files/actions" tabindex="-1"> | |
| 336 | + | <span class="lbl">actions</span> | |
| 337 | + | <span class="k">section</span> | |
| 338 | + | </a> | |
| 339 | + | </li> | |
| 328 | 340 | <li data-k="branch feature/x"> | |
| 329 | 341 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 330 | 342 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-subdir.html +12 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -83,6 +85,10 @@ | |||
| 83 | 85 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 84 | 86 | </a> | |
| 85 | 87 | </li> | |
| 88 | + | <li> | |
| 89 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 86 | 92 | </ul> | |
| 87 | 93 | </nav> | |
| 88 | 94 | </header> | |
| @@ -350,6 +356,12 @@ | |||
| 350 | 356 | <span class="k">section</span> | |
| 351 | 357 | </a> | |
| 352 | 358 | </li> | |
| 359 | + | <li data-k="section actions"> | |
| 360 | + | <a href="/files/actions" tabindex="-1"> | |
| 361 | + | <span class="lbl">actions</span> | |
| 362 | + | <span class="k">section</span> | |
| 363 | + | </a> | |
| 364 | + | </li> | |
| 353 | 365 | <li data-k="branch feature/x"> | |
| 354 | 366 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 355 | 367 | <span class="lbl">feature/x</span> | |
M internal/app/web.go +1 -0
| @@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 33 | 33 | Repos: f.Repos, | |
| 34 | 34 | Browse: f.Browse, | |
| 35 | 35 | MergeRequests: f.MergeRequests, | |
| 36 | + | Runs: f.CI, | |
| 36 | 37 | Events: f.EventLog, | |
| 37 | 38 | PublicURL: cfg.PublicURL, | |
| 38 | 39 | SourceURL: cfg.SourceURL, | |
M internal/config/config.go +51 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "path/filepath" | |
| 15 | 15 | "strconv" | |
| 16 | 16 | "strings" | |
| 17 | + | "time" | |
| 17 | 18 | "unicode/utf8" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -33,6 +34,11 @@ const ( | |||
| 33 | 34 | // commits ermite creates (squash merges, ADR 0022). | |
| 34 | 35 | EnvCommitterName = "ERMITE_COMMITTER_NAME" | |
| 35 | 36 | EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL" | |
| 37 | + | // EnvOwner is the owner login CI shows in the github context | |
| 38 | + | // ("<owner>/<repo>"); it follows the rules of repository names. | |
| 39 | + | EnvOwner = "ERMITE_OWNER" | |
| 40 | + | // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept. | |
| 41 | + | EnvCILogRetention = "ERMITE_CI_LOG_RETENTION" | |
| 36 | 42 | ) | |
| 37 | 43 | ||
| 38 | 44 | // Defaults. | |
| @@ -44,6 +50,8 @@ const ( | |||
| 44 | 50 | DefaultLogFormat = "json" | |
| 45 | 51 | // DefaultCommitterName is the committer name of the commits ermite creates. | |
| 46 | 52 | DefaultCommitterName = "ermite" | |
| 53 | + | // DefaultOwner is the owner login when ERMITE_OWNER is not set. | |
| 54 | + | DefaultOwner = "owner" | |
| 47 | 55 | ) | |
| 48 | 56 | ||
| 49 | 57 | // Values of ERMITE_GIT_READER. | |
| @@ -85,6 +93,11 @@ type Config struct { | |||
| 85 | 93 | // commits ermite creates. The email defaults to "ermite@<host of | |
| 86 | 94 | // PublicURL>". | |
| 87 | 95 | CommitterName, CommitterEmail string | |
| 96 | + | // Owner is the owner login of the github context of CI runs. | |
| 97 | + | Owner string | |
| 98 | + | // CIRetention is how long CI data is kept; 0 (variable unset) leaves the | |
| 99 | + | // default to the CI use case. | |
| 100 | + | CIRetention time.Duration | |
| 88 | 101 | } | |
| 89 | 102 | ||
| 90 | 103 | // Error lists every configuration problem found, in variable order. | |
| @@ -218,12 +231,50 @@ func Load(get func(string) string) (Config, error) { | |||
| 218 | 231 | c.CommitterEmail = "ermite@" + u.Hostname() | |
| 219 | 232 | } | |
| 220 | 233 | ||
| 234 | + | // The name rules of the owner are those of repository names (the domain); | |
| 235 | + | // config imports nothing, so internal/app checks them (see loadConfig). | |
| 236 | + | c.Owner = strings.TrimSpace(get(EnvOwner)) | |
| 237 | + | if c.Owner == "" { | |
| 238 | + | c.Owner = DefaultOwner | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" { | |
| 242 | + | d, err := parseRetention(raw) | |
| 243 | + | if err != nil { | |
| 244 | + | bad(EnvCILogRetention, "%v", err) | |
| 245 | + | } else { | |
| 246 | + | c.CIRetention = d | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 221 | 250 | if len(problems) > 0 { | |
| 222 | 251 | return Config{}, &Error{Problems: problems} | |
| 223 | 252 | } | |
| 224 | 253 | return c, nil | |
| 225 | 254 | } | |
| 226 | 255 | ||
| 256 | + | // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"), | |
| 257 | + | // at least one hour. | |
| 258 | + | func parseRetention(raw string) (time.Duration, error) { | |
| 259 | + | var d time.Duration | |
| 260 | + | if days, ok := strings.CutSuffix(raw, "d"); ok { | |
| 261 | + | n, err := strconv.Atoi(days) | |
| 262 | + | if err != nil || n < 1 || n > 36500 { | |
| 263 | + | return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw) | |
| 264 | + | } | |
| 265 | + | d = time.Duration(n) * 24 * time.Hour | |
| 266 | + | } else { | |
| 267 | + | var err error | |
| 268 | + | if d, err = time.ParseDuration(raw); err != nil { | |
| 269 | + | return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | if d < time.Hour { | |
| 273 | + | return 0, fmt.Errorf("must be at least one hour (got %q)", raw) | |
| 274 | + | } | |
| 275 | + | return d, nil | |
| 276 | + | } | |
| 277 | + | ||
| 227 | 278 | // validateIdentity accepts a name or an email address for a Git identity: valid | |
| 228 | 279 | // UTF-8 without control characters or the angle brackets and line breaks that | |
| 229 | 280 | // Git does not allow in an identity. | |
M internal/config/config_test.go +45 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "net/netip" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/stretchr/testify/assert" | |
| 13 | 14 | "github.com/stretchr/testify/require" | |
| @@ -208,3 +209,47 @@ func TestLoadGitReader(t *testing.T) { | |||
| 208 | 209 | assert.Equal(t, want, c.GitReader, in) | |
| 209 | 210 | } | |
| 210 | 211 | } | |
| 212 | + | ||
| 213 | + | func TestOwnerAndCIRetention(t *testing.T) { | |
| 214 | + | base := map[string]string{ | |
| 215 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |
| 216 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |
| 217 | + | } | |
| 218 | + | load := func(extra map[string]string) (config.Config, error) { | |
| 219 | + | env := map[string]string{} | |
| 220 | + | for k, v := range base { | |
| 221 | + | env[k] = v | |
| 222 | + | } | |
| 223 | + | for k, v := range extra { | |
| 224 | + | env[k] = v | |
| 225 | + | } | |
| 226 | + | return config.Load(func(k string) string { return env[k] }) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | c, err := load(nil) | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | if c.Owner != "owner" || c.CIRetention != 0 { | |
| 234 | + | t.Fatalf("defaults (retention is left to the use case): owner %q, retention %v", c.Owner, c.CIRetention) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | c, err = load(map[string]string{config.EnvOwner: "bnema", config.EnvCILogRetention: "30d"}) | |
| 238 | + | if err != nil { | |
| 239 | + | t.Fatal(err) | |
| 240 | + | } | |
| 241 | + | if c.Owner != "bnema" || c.CIRetention != 30*24*time.Hour { | |
| 242 | + | t.Fatalf("set: owner %q, retention %v", c.Owner, c.CIRetention) | |
| 243 | + | } | |
| 244 | + | if c, err = load(map[string]string{config.EnvCILogRetention: "48h"}); err != nil || c.CIRetention != 48*time.Hour { | |
| 245 | + | t.Fatalf("go duration: %v, %v", c.CIRetention, err) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | for _, extra := range []map[string]string{ | |
| 249 | + | {config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"}, | |
| 250 | + | } { | |
| 251 | + | if _, err := load(extra); err == nil { | |
| 252 | + | t.Errorf("%v: want an error", extra) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
A internal/domain/ci.go +589 -0
| @@ -0,0 +1,589 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 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). | |
| 16 | + | type CIStatus string | |
| 17 | + | ||
| 18 | + | // Statuses. | |
| 19 | + | const ( | |
| 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. | |
| 27 | + | type CIConclusion string | |
| 28 | + | ||
| 29 | + | // Conclusions. | |
| 30 | + | const ( | |
| 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. | |
| 43 | + | func (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. | |
| 48 | + | func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut } | |
| 49 | + | ||
| 50 | + | // CIStepState is where a step is in its life. | |
| 51 | + | type CIStepState string | |
| 52 | + | ||
| 53 | + | // Step states. | |
| 54 | + | const ( | |
| 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. | |
| 62 | + | type CIJobPhase string | |
| 63 | + | ||
| 64 | + | // Job phases. | |
| 65 | + | const ( | |
| 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. | |
| 79 | + | type CIIsolation string | |
| 80 | + | ||
| 81 | + | // Isolations. | |
| 82 | + | const ( | |
| 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. | |
| 90 | + | type CICancelReason string | |
| 91 | + | ||
| 92 | + | // Cancel reasons. | |
| 93 | + | const ( | |
| 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). | |
| 101 | + | const ( | |
| 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. | |
| 126 | + | const ( | |
| 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. | |
| 142 | + | const ( | |
| 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. | |
| 152 | + | func 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. | |
| 157 | + | type CIRun struct { | |
| 158 | + | Repo RepoName | |
| 159 | + | ID int64 | |
| 160 | + | Number int64 | |
| 161 | + | Attempt int32 | |
| 162 | + | WorkflowPath string | |
| 163 | + | WorkflowName string | |
| 164 | + | Title string | |
| 165 | + | Event string | |
| 166 | + | Ref string | |
| 167 | + | Sha string | |
| 168 | + | Actor string | |
| 169 | + | Status CIStatus | |
| 170 | + | Conclusion CIConclusion | |
| 171 | + | Error string | |
| 172 | + | CreatedAt time.Time | |
| 173 | + | StartedAt time.Time | |
| 174 | + | CompletedAt time.Time | |
| 175 | + | CancelRequested bool | |
| 176 | + | CancelRequestedAt time.Time | |
| 177 | + | // Version is the version of the stored record, which grows at every | |
| 178 | + | // change and never goes back. | |
| 179 | + | Version uint64 | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // CIRunSource is what a run was planned from: the workflow file at the sha of | |
| 183 | + | // the run, and the "github" context without the parts that depend on the job | |
| 184 | + | // and the attempt. | |
| 185 | + | type CIRunSource struct { | |
| 186 | + | RunID int64 | |
| 187 | + | Repo RepoName | |
| 188 | + | WorkflowYAML string | |
| 189 | + | GithubJSON string | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // CIStep is a step of a job with where its lines are in the job log. | |
| 193 | + | type CIStep struct { | |
| 194 | + | Number int32 | |
| 195 | + | Name string | |
| 196 | + | State CIStepState | |
| 197 | + | Conclusion CIConclusion | |
| 198 | + | StartedAt time.Time | |
| 199 | + | CompletedAt time.Time | |
| 200 | + | LogStart int64 | |
| 201 | + | // HasLines is set once a line of the step reached the log; LogStart is | |
| 202 | + | // then the number of its first line. LogEnd is not stored: see StepRanges. | |
| 203 | + | HasLines bool | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // CIJob is one job instance (one matrix combination) in one attempt. | |
| 207 | + | type CIJob struct { | |
| 208 | + | ID string | |
| 209 | + | RunID int64 | |
| 210 | + | // Attempt is the attempt in which the job executed, RunAttempt the attempt | |
| 211 | + | // of the run the record belongs to: they differ for a job kept by a re-run | |
| 212 | + | // of the failed jobs. | |
| 213 | + | Attempt int32 | |
| 214 | + | RunAttempt int32 | |
| 215 | + | Repo RepoName | |
| 216 | + | Key string | |
| 217 | + | Name string | |
| 218 | + | Phase CIJobPhase | |
| 219 | + | Conclusion CIConclusion | |
| 220 | + | Error string | |
| 221 | + | Labels []string | |
| 222 | + | Needs []string | |
| 223 | + | RunnerID string | |
| 224 | + | RunnerName string | |
| 225 | + | Isolation CIIsolation | |
| 226 | + | Steps []CIStep | |
| 227 | + | ||
| 228 | + | CreatedAt time.Time | |
| 229 | + | StartedAt time.Time | |
| 230 | + | CompletedAt time.Time | |
| 231 | + | ||
| 232 | + | // Index is the position of the job in the workflow file, Combo the | |
| 233 | + | // position of the instance in its matrix. | |
| 234 | + | Index int | |
| 235 | + | Combo int | |
| 236 | + | // Planned is set once runs-on, the name and the contexts are evaluated. | |
| 237 | + | // A job that is not planned has not been expanded yet. | |
| 238 | + | Planned bool | |
| 239 | + | MatrixJSON string | |
| 240 | + | // ContextsJSON is the JSON object of contexts given to the runner, computed | |
| 241 | + | // when the job is planned. | |
| 242 | + | ContextsJSON string | |
| 243 | + | // ComboTotal is the number of instances of the job's matrix. | |
| 244 | + | ComboTotal int | |
| 245 | + | // TimeoutMinutes is the timeout of the job, at least 1. | |
| 246 | + | TimeoutMinutes int32 | |
| 247 | + | FailFast bool | |
| 248 | + | // MaxParallel is the max-parallel of the strategy, 0 for no limit. | |
| 249 | + | MaxParallel int | |
| 250 | + | Outputs map[string]string | |
| 251 | + | ||
| 252 | + | // TokenHash is the hash of the job token of the current assignment; zero | |
| 253 | + | // when there is none. | |
| 254 | + | TokenHash TokenHash | |
| 255 | + | TokenValidUntil time.Time | |
| 256 | + | ReadyAt time.Time | |
| 257 | + | AssignedAt time.Time | |
| 258 | + | AckedAt time.Time | |
| 259 | + | // AssignCancels counts the assignments cancelled in a row. | |
| 260 | + | AssignCancels int | |
| 261 | + | ||
| 262 | + | CancelReason CICancelReason | |
| 263 | + | CancelMessage string | |
| 264 | + | CancelRequestedAt time.Time | |
| 265 | + | ||
| 266 | + | Version uint64 | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Status is the status owners see: a job is queued until its runner | |
| 270 | + | // acknowledged it. | |
| 271 | + | func (j CIJob) Status() CIStatus { | |
| 272 | + | switch j.Phase { | |
| 273 | + | case CIPhaseRunning: | |
| 274 | + | return CIInProgress | |
| 275 | + | case CIPhaseEnded: | |
| 276 | + | return CICompleted | |
| 277 | + | } | |
| 278 | + | return CIQueued | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // Active reports whether the job is given to a runner and not over. | |
| 282 | + | func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning } | |
| 283 | + | ||
| 284 | + | // Ended reports whether the job is over. | |
| 285 | + | func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded } | |
| 286 | + | ||
| 287 | + | // HasToken reports whether a job token is set. | |
| 288 | + | func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} } | |
| 289 | + | ||
| 290 | + | // CICheck is the result of one job on one commit. | |
| 291 | + | type CICheck struct { | |
| 292 | + | ID string | |
| 293 | + | Repo RepoName | |
| 294 | + | Sha string | |
| 295 | + | Name string | |
| 296 | + | Status CIStatus | |
| 297 | + | Conclusion CIConclusion | |
| 298 | + | RunID int64 | |
| 299 | + | JobID string | |
| 300 | + | Attempt int32 | |
| 301 | + | WorkflowName string | |
| 302 | + | CreatedAt time.Time | |
| 303 | + | StartedAt time.Time | |
| 304 | + | CompletedAt time.Time | |
| 305 | + | Version uint64 | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // CIRunner is a registered CI runner. TokenHash is never sent to clients. | |
| 309 | + | type CIRunner struct { | |
| 310 | + | ID string | |
| 311 | + | Name string | |
| 312 | + | Labels []string | |
| 313 | + | Isolation CIIsolation | |
| 314 | + | Version string | |
| 315 | + | Arch string | |
| 316 | + | OS string | |
| 317 | + | RegisteredAt time.Time | |
| 318 | + | TokenHash TokenHash | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs. | |
| 322 | + | const ( | |
| 323 | + | RunnerTokenPrefix = "ermiterun_" | |
| 324 | + | JobTokenPrefix = "ermitejob_" | |
| 325 | + | // CITokenRandomLen is the length of the base64url part of both. | |
| 326 | + | CITokenRandomLen = 43 | |
| 327 | + | ) | |
| 328 | + | ||
| 329 | + | // IsRunnerToken and IsJobToken report whether s has the shape of the secret. | |
| 330 | + | func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) } | |
| 331 | + | func IsJobToken(s string) bool { return hasTokenShape(s, JobTokenPrefix) } | |
| 332 | + | ||
| 333 | + | func hasTokenShape(s, prefix string) bool { | |
| 334 | + | rest, ok := strings.CutPrefix(s, prefix) | |
| 335 | + | if !ok || len(rest) != CITokenRandomLen { | |
| 336 | + | return false | |
| 337 | + | } | |
| 338 | + | for _, r := range rest { | |
| 339 | + | switch { | |
| 340 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': | |
| 341 | + | default: | |
| 342 | + | return false | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | return true | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in | |
| 349 | + | // every attempt of the run. | |
| 350 | + | func NewJobID(runID int64, index, combo int) string { | |
| 351 | + | return fmt.Sprintf("%d-%d-%d", runID, index, combo) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // ParseJobID returns the run of a job id, or ok false when s is not one. | |
| 355 | + | func ParseJobID(s string) (runID int64, index, combo int, ok bool) { | |
| 356 | + | parts := strings.Split(s, "-") | |
| 357 | + | if len(parts) != 3 { | |
| 358 | + | return 0, 0, 0, false | |
| 359 | + | } | |
| 360 | + | r, err1 := strconv.ParseInt(parts[0], 10, 64) | |
| 361 | + | i, err2 := strconv.Atoi(parts[1]) | |
| 362 | + | c, err3 := strconv.Atoi(parts[2]) | |
| 363 | + | if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s { | |
| 364 | + | return 0, 0, 0, false | |
| 365 | + | } | |
| 366 | + | return r, i, c, true | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) | |
| 370 | + | ||
| 371 | + | // ParseRunnerName validates a runner name. | |
| 372 | + | func ParseRunnerName(s string) error { | |
| 373 | + | if !labelRE.MatchString(s) { | |
| 374 | + | return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid) | |
| 375 | + | } | |
| 376 | + | return nil | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // ParseRunnerLabels validates labels and returns them lowercased, without | |
| 380 | + | // duplicates, in order of first appearance. | |
| 381 | + | func ParseRunnerLabels(labels []string) ([]string, error) { | |
| 382 | + | if len(labels) > CIMaxLabels { | |
| 383 | + | return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels) | |
| 384 | + | } | |
| 385 | + | var out []string | |
| 386 | + | for _, l := range labels { | |
| 387 | + | if !labelRE.MatchString(l) { | |
| 388 | + | return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen) | |
| 389 | + | } | |
| 390 | + | l = strings.ToLower(l) | |
| 391 | + | if !slices.Contains(out, l) { | |
| 392 | + | out = append(out, l) | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | return out, nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // RunnerLabels returns the labels a runner matches: the ones it chose plus | |
| 399 | + | // the implicit "self-hosted", its os and its arch, lowercase and sorted. | |
| 400 | + | func RunnerLabels(chosen []string, os, arch string) []string { | |
| 401 | + | out := slices.Clone(chosen) | |
| 402 | + | for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} { | |
| 403 | + | if !slices.Contains(out, l) { | |
| 404 | + | out = append(out, l) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | slices.Sort(out) | |
| 408 | + | return out | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // LabelsMatch reports whether every wanted label is among have, ignoring case. | |
| 412 | + | func LabelsMatch(want, have []string) bool { | |
| 413 | + | for _, w := range want { | |
| 414 | + | if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) { | |
| 415 | + | return false | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | return true | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`) | |
| 422 | + | ||
| 423 | + | // CheckOutputs validates the outputs of a job: names, case-insensitive | |
| 424 | + | // uniqueness and the 1 MiB limits. | |
| 425 | + | func CheckOutputs(outputs map[string]string) error { | |
| 426 | + | seen := make(map[string]bool, len(outputs)) | |
| 427 | + | total := 0 | |
| 428 | + | for k, v := range outputs { | |
| 429 | + | if !outputNameRE.MatchString(k) { | |
| 430 | + | return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k) | |
| 431 | + | } | |
| 432 | + | lk := strings.ToLower(k) | |
| 433 | + | if seen[lk] { | |
| 434 | + | return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k) | |
| 435 | + | } | |
| 436 | + | seen[lk] = true | |
| 437 | + | if len(v) > CIMaxOutputBytes { | |
| 438 | + | return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k) | |
| 439 | + | } | |
| 440 | + | total += len(k) + len(v) | |
| 441 | + | } | |
| 442 | + | if total > CIMaxOutputBytes { | |
| 443 | + | return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid) | |
| 444 | + | } | |
| 445 | + | return nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // JobEndedError is returned for a call that needs a running job when the job | |
| 449 | + | // has ended. It matches ErrPreconditionFailed; the API adds the JobEnded | |
| 450 | + | // detail. | |
| 451 | + | type JobEndedError struct { | |
| 452 | + | Conclusion CIConclusion | |
| 453 | + | Message string | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | // Error implements error. | |
| 457 | + | func (e *JobEndedError) Error() string { | |
| 458 | + | return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // Is makes errors.Is(err, ErrPreconditionFailed) true. | |
| 462 | + | func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed } | |
| 463 | + | ||
| 464 | + | // IsJobEnded returns the JobEndedError in err. | |
| 465 | + | func IsJobEnded(err error) (*JobEndedError, bool) { | |
| 466 | + | var e *JobEndedError | |
| 467 | + | if errors.As(err, &e) { | |
| 468 | + | return e, true | |
| 469 | + | } | |
| 470 | + | return nil, false | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // ErrOutOfRange means an offset is beyond what the server holds. | |
| 474 | + | var ErrOutOfRange = errors.New("out of range") | |
| 475 | + | ||
| 476 | + | // CILogLine is one line of a job log. | |
| 477 | + | type CILogLine struct { | |
| 478 | + | Time time.Time | |
| 479 | + | // Text has no line feed. | |
| 480 | + | Text string | |
| 481 | + | // Continued marks a piece of a line that was too long to send whole. | |
| 482 | + | Continued bool | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // StepRange is where the lines of a step are in the job log. | |
| 486 | + | type StepRange struct { | |
| 487 | + | Start, End int64 | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // StepRanges returns the range of every step of the job for a log of lines | |
| 491 | + | // lines: a step with lines ends where the next step with lines begins (the | |
| 492 | + | // last ends at the end of the log); a step without lines is empty, placed | |
| 493 | + | // where the previous step ends. | |
| 494 | + | func StepRanges(steps []CIStep, lines int64) []StepRange { | |
| 495 | + | out := make([]StepRange, len(steps)) | |
| 496 | + | var pos int64 | |
| 497 | + | for i, s := range steps { | |
| 498 | + | if !s.HasLines { | |
| 499 | + | out[i] = StepRange{Start: pos, End: pos} | |
| 500 | + | continue | |
| 501 | + | } | |
| 502 | + | end := lines | |
| 503 | + | for _, n := range steps[i+1:] { | |
| 504 | + | if n.HasLines { | |
| 505 | + | end = n.LogStart | |
| 506 | + | break | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)} | |
| 510 | + | pos = out[i].End | |
| 511 | + | } | |
| 512 | + | return out | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // StepOfLine returns the number of the step a log line belongs to, 0 when no | |
| 516 | + | // step has that line. | |
| 517 | + | func StepOfLine(steps []CIStep, line int64) int32 { | |
| 518 | + | var found int32 | |
| 519 | + | for _, s := range steps { | |
| 520 | + | if s.HasLines && s.LogStart <= line { | |
| 521 | + | found = s.Number | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | return found | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | // RunUpdated is published when a CI run is created or changes. It carries the | |
| 528 | + | // visibility and the creation time of the repository, like PushEvent. | |
| 529 | + | type RunUpdated struct { | |
| 530 | + | Run CIRun | |
| 531 | + | At time.Time | |
| 532 | + | Visibility Visibility | |
| 533 | + | CreatedAt time.Time | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // EventKind implements Event. | |
| 537 | + | func (RunUpdated) EventKind() string { return "ci.run_updated" } | |
| 538 | + | ||
| 539 | + | // JobUpdated is published when a CI job is created or changes (status, | |
| 540 | + | // conclusion, runner or steps; never per log line). Lines is the number of | |
| 541 | + | // lines of the job log at that time, for the end of the step in progress. | |
| 542 | + | type JobUpdated struct { | |
| 543 | + | Job CIJob | |
| 544 | + | Lines int64 | |
| 545 | + | At time.Time | |
| 546 | + | Visibility Visibility | |
| 547 | + | CreatedAt time.Time | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | // EventKind implements Event. | |
| 551 | + | func (JobUpdated) EventKind() string { return "ci.job_updated" } | |
| 552 | + | ||
| 553 | + | // CheckState is the combined state of the checks of a commit. | |
| 554 | + | type CheckState string | |
| 555 | + | ||
| 556 | + | // Combined check states. | |
| 557 | + | const ( | |
| 558 | + | // CheckStateNone: the commit has no check. | |
| 559 | + | CheckStateNone CheckState = "" | |
| 560 | + | // CheckStatePending: a check is queued or in progress, none has failed. | |
| 561 | + | CheckStatePending CheckState = "pending" | |
| 562 | + | // CheckStateFailure: a check failed (or needs an action), whatever the others. | |
| 563 | + | CheckStateFailure CheckState = "failure" | |
| 564 | + | // CheckStateSuccess: every check passed. | |
| 565 | + | CheckStateSuccess CheckState = "success" | |
| 566 | + | ) | |
| 567 | + | ||
| 568 | + | // CombineChecks reduces the checks of a commit to one state, the one the | |
| 569 | + | // merge rule uses too: a failed check makes the commit failing even while | |
| 570 | + | // others are pending; else a pending check makes it pending; else it is a | |
| 571 | + | // success. | |
| 572 | + | func CombineChecks(checks []CICheck) CheckState { | |
| 573 | + | if len(checks) == 0 { | |
| 574 | + | return CheckStateNone | |
| 575 | + | } | |
| 576 | + | pending := false | |
| 577 | + | for _, c := range checks { | |
| 578 | + | switch { | |
| 579 | + | case c.Status != CICompleted: | |
| 580 | + | pending = true | |
| 581 | + | case !c.Conclusion.PassesMerge(): | |
| 582 | + | return CheckStateFailure | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | if pending { | |
| 586 | + | return CheckStatePending | |
| 587 | + | } | |
| 588 | + | return CheckStateSuccess | |
| 589 | + | } | |
A internal/ports/ci.go +382 -0
| @@ -0,0 +1,382 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strconv" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // CIRunDraft is what CreateRun stores: a run with its source, its jobs and | |
| 15 | + | // their checks, built once the store has chosen the ids. | |
| 16 | + | type CIRunDraft struct { | |
| 17 | + | Run domain.CIRun | |
| 18 | + | Source domain.CIRunSource | |
| 19 | + | Jobs []domain.CIJob | |
| 20 | + | Checks []domain.CICheck | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CIWrite is a set of records written in one atomic batch. A run or a job with | |
| 24 | + | // Version 0 must not exist yet; any other must be unchanged since it was read. | |
| 25 | + | // Checks are derived from the jobs and upserted without a precondition. | |
| 26 | + | // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the | |
| 27 | + | // repository no longer exists. | |
| 28 | + | type CIWrite struct { | |
| 29 | + | Runs []domain.CIRun | |
| 30 | + | Jobs []domain.CIJob | |
| 31 | + | Checks []domain.CICheck | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | |
| 35 | + | // (the queue of ready jobs, the active jobs, the open runs, the job tokens) | |
| 36 | + | // from the records it writes. | |
| 37 | + | type CIStore interface { | |
| 38 | + | // CreateRun takes the next run id and the next number of the workflow in | |
| 39 | + | // the repository (both never reused), calls build with them and stores | |
| 40 | + | // the draft it returns, in one batch that fails with domain.ErrNotFound | |
| 41 | + | // once the repository is deleted. The records come back with their | |
| 42 | + | // versions. | |
| 43 | + | CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error) | |
| 44 | + | // Apply writes the records in one batch and returns the version they all | |
| 45 | + | // have afterwards. | |
| 46 | + | Apply(ctx context.Context, w CIWrite) (version uint64, err error) | |
| 47 | + | // GetRun returns the run, or domain.ErrNotFound. | |
| 48 | + | GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) | |
| 49 | + | // GetRunSource returns what the run was planned from, or | |
| 50 | + | // domain.ErrNotFound. It is how a run is found from its id alone. | |
| 51 | + | GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) | |
| 52 | + | // ListRuns returns the runs of the repository with an id below before (0 | |
| 53 | + | // for no bound), newest first, at most limit. | |
| 54 | + | ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) | |
| 55 | + | // RunIDs returns the ids of the runs of the repository. | |
| 56 | + | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 57 | + | // ListOpenRuns returns the runs that have not completed. | |
| 58 | + | ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) | |
| 59 | + | // GetJob returns a job of a run at an attempt of the run, or | |
| 60 | + | // domain.ErrNotFound. | |
| 61 | + | GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) | |
| 62 | + | // ListJobs returns the jobs of a run at an attempt, ordered by index then | |
| 63 | + | // combination. | |
| 64 | + | ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) | |
| 65 | + | // FindJobByToken returns the job whose current token has that hash and | |
| 66 | + | // whose token is still valid at now, or domain.ErrNotFound. | |
| 67 | + | FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) | |
| 68 | + | // ListReadyJobs returns the jobs that wait for a runner, oldest first, at | |
| 69 | + | // most limit. | |
| 70 | + | ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) | |
| 71 | + | // ListActiveJobs returns the jobs given to a runner and not over. | |
| 72 | + | ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) | |
| 73 | + | // ListChecks returns the checks of a commit, at most limit; more is true | |
| 74 | + | // when there are more. | |
| 75 | + | ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) (checks []domain.CICheck, more bool, err error) | |
| 76 | + | // Sweep removes the runs completed before the time, with their jobs, | |
| 77 | + | // checks and sources (not their numbers), and returns their ids so that | |
| 78 | + | // the logs can go too. | |
| 79 | + | Sweep(ctx context.Context, before time.Time) ([]int64, error) | |
| 80 | + | // CreateRunner stores the runner and the index of its token. A name in use | |
| 81 | + | // is domain.ErrAlreadyExists, or with replace the old runner is removed in | |
| 82 | + | // the same batch and returned. | |
| 83 | + | CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) (removed []domain.CIRunner, err error) | |
| 84 | + | // GetRunner returns the runner, or domain.ErrNotFound. | |
| 85 | + | GetRunner(ctx context.Context, id string) (domain.CIRunner, error) | |
| 86 | + | // FindRunnerByToken returns the runner whose token has that hash, or | |
| 87 | + | // domain.ErrNotFound. | |
| 88 | + | FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) | |
| 89 | + | // ListRunners returns every runner ordered by name. | |
| 90 | + | ListRunners(ctx context.Context) ([]domain.CIRunner, error) | |
| 91 | + | // RemoveRunner removes the runner and its token, or returns | |
| 92 | + | // domain.ErrNotFound. | |
| 93 | + | RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // CIRunCleaner is what repository deletion needs from CI: the runs of the | |
| 97 | + | // repository before its records go, and the removal of their logs after. | |
| 98 | + | // *usecase.CI implements it. | |
| 99 | + | type CIRunCleaner interface { | |
| 100 | + | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 101 | + | RemoveLogs(ctx context.Context, runIDs []int64) error | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // CIChecksReader is the read side of the checks, for the merge rule and the | |
| 105 | + | // pages that show them. *usecase.CI implements it. | |
| 106 | + | type CIChecksReader interface { | |
| 107 | + | // CommitChecks returns the checks of a commit, without access control (the | |
| 108 | + | // caller has checked it). More than 1,000 is domain.ErrResourceExhausted, | |
| 109 | + | // not a part of them: a rule must not decide on some of the checks. | |
| 110 | + | CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // CILogRef names the log of one execution of a job. | |
| 114 | + | type CILogRef struct { | |
| 115 | + | RunID int64 | |
| 116 | + | JobID string | |
| 117 | + | Attempt int32 | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // ErrCILogLimit is returned by CILogStore.Append when the lines do not fit in | |
| 121 | + | // the log limit; nothing was appended. | |
| 122 | + | var ErrCILogLimit = errors.New("log limit reached") | |
| 123 | + | ||
| 124 | + | // ErrCILogGap wraps domain.ErrOutOfRange when an offset is above the count. | |
| 125 | + | type ErrCILogGap struct{ Count int64 } | |
| 126 | + | ||
| 127 | + | // Error implements error. | |
| 128 | + | func (e *ErrCILogGap) Error() string { | |
| 129 | + | return "out of range: the log holds lines up to the offset of " + strconv.FormatInt(e.Count, 10) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Is makes errors.Is(err, domain.ErrOutOfRange) true. | |
| 133 | + | func (e *ErrCILogGap) Is(target error) bool { return target == domain.ErrOutOfRange } | |
| 134 | + | ||
| 135 | + | // CILogStore keeps the logs of the jobs: append-only files, one line per log | |
| 136 | + | // line, never in the metadata store. Lines are numbered from 0. | |
| 137 | + | type CILogStore interface { | |
| 138 | + | // Append appends the lines at offset (the number of the first line), with | |
| 139 | + | // the idempotence of AppendLog: lines already stored are skipped, a gap is | |
| 140 | + | // an *ErrCILogGap. It returns the number of lines after the call and how | |
| 141 | + | // many of the request were new. A log that would exceed limit bytes gets | |
| 142 | + | // nothing and ErrCILogLimit. | |
| 143 | + | Append(ctx context.Context, ref CILogRef, offset int64, lines []domain.CILogLine, limit int64) (next int64, added int, err error) | |
| 144 | + | // AppendForced appends lines whatever the limit (the last line of a log | |
| 145 | + | // that reached it). | |
| 146 | + | AppendForced(ctx context.Context, ref CILogRef, lines []domain.CILogLine) (next int64, err error) | |
| 147 | + | // Count returns the number of lines held, 0 for a log that does not exist. | |
| 148 | + | Count(ctx context.Context, ref CILogRef) (int64, error) | |
| 149 | + | // Read returns lines from the one numbered from, at most maxLines lines and | |
| 150 | + | // about maxBytes of text. It returns domain.ErrNotFound when the log does | |
| 151 | + | // not exist and domain.ErrOutOfRange when from is above the count. | |
| 152 | + | Read(ctx context.Context, ref CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) | |
| 153 | + | // Changed returns a channel that is closed at the next append to the log. | |
| 154 | + | Changed(ref CILogRef) <-chan struct{} | |
| 155 | + | // Reset empties the log. | |
| 156 | + | Reset(ctx context.Context, ref CILogRef) error | |
| 157 | + | // RemoveRuns deletes the logs of the runs. | |
| 158 | + | RemoveRuns(ctx context.Context, runIDs []int64) error | |
| 159 | + | // RunIDs lists the runs that have a log directory, for the orphan sweep. | |
| 160 | + | RunIDs(ctx context.Context) ([]int64, error) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | |
| 164 | + | // queues the work and returns: planning happens in the background and its | |
| 165 | + | // errors never reach the push. | |
| 166 | + | type WorkflowTrigger interface { | |
| 167 | + | OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // CIGit is what planning needs from Git beyond RepoReader and MergeGit. | |
| 171 | + | type CIGit interface { | |
| 172 | + | // ChangedPaths returns the paths changed from base to head (a zero base | |
| 173 | + | // means the empty tree), in the order of the diff and without renames | |
| 174 | + | // detection, at most max; truncated is true when there were more. | |
| 175 | + | ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // --- inbound --- | |
| 179 | + | ||
| 180 | + | // RegisterRunnerParams describe a runner to register. | |
| 181 | + | type RegisterRunnerParams struct { | |
| 182 | + | Name string | |
| 183 | + | Labels []string | |
| 184 | + | Version string | |
| 185 | + | Isolation domain.CIIsolation | |
| 186 | + | Arch, OS string | |
| 187 | + | Replace bool | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // RegisteredRunner is a new runner with its secret, shown once. | |
| 191 | + | type RegisteredRunner struct { | |
| 192 | + | Runner domain.CIRunner | |
| 193 | + | Token string | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // RunnerJob is a job given to a runner. | |
| 197 | + | type RunnerJob struct { | |
| 198 | + | Job domain.CIJob | |
| 199 | + | Token string | |
| 200 | + | Repo domain.RepoName | |
| 201 | + | Sha, Ref string | |
| 202 | + | WorkflowPath string | |
| 203 | + | WorkflowYAML string | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // FetchJobParams selects the job a runner asks for. | |
| 207 | + | type FetchJobParams struct { | |
| 208 | + | Labels []string | |
| 209 | + | PollID string | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // FetchJobResult is the answer of a poll. | |
| 213 | + | type FetchJobResult struct { | |
| 214 | + | Job *RunnerJob | |
| 215 | + | // RetryAfter is how long the runner waits before polling again. | |
| 216 | + | RetryAfter time.Duration | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // StepUpdate is the state of one step as a runner reports it. | |
| 220 | + | type StepUpdate struct { | |
| 221 | + | Number int32 | |
| 222 | + | Name string | |
| 223 | + | State domain.CIStepState | |
| 224 | + | Conclusion domain.CIConclusion | |
| 225 | + | StartedAt time.Time | |
| 226 | + | CompletedAt time.Time | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // CompleteJobParams end a job. | |
| 230 | + | type CompleteJobParams struct { | |
| 231 | + | JobID string | |
| 232 | + | Conclusion domain.CIConclusion | |
| 233 | + | Outputs map[string]string | |
| 234 | + | RunnerShutdown bool | |
| 235 | + | Steps []StepUpdate | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // CompleteJobResult is the stored result of a job. | |
| 239 | + | type CompleteJobResult struct { | |
| 240 | + | Conclusion domain.CIConclusion | |
| 241 | + | Error string | |
| 242 | + | Applied bool | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // JobSignal is one message of WatchJob. | |
| 246 | + | type JobSignal struct { | |
| 247 | + | Keepalive bool | |
| 248 | + | // Cancel is set for a cancel request. | |
| 249 | + | Cancel *JobCancel | |
| 250 | + | // Ended is set when the job has ended. | |
| 251 | + | Ended *domain.JobEndedError | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // JobCancel is a cancel request for a running job. | |
| 255 | + | type JobCancel struct { | |
| 256 | + | Reason domain.CICancelReason | |
| 257 | + | Message string | |
| 258 | + | RequestedAt time.Time | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // RunnerService is what runners call. The secret is the bearer secret of the | |
| 262 | + | // call: the runner token for FetchJob, the job token for the other calls. | |
| 263 | + | // A bad secret is domain.ErrUnauthenticated; a job token of another job than | |
| 264 | + | // the one named is domain.ErrForbidden. *usecase.CI implements it. | |
| 265 | + | type RunnerService interface { | |
| 266 | + | Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error) | |
| 267 | + | FetchJob(ctx context.Context, secret string, params FetchJobParams) (FetchJobResult, error) | |
| 268 | + | UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error | |
| 269 | + | AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error) | |
| 270 | + | Heartbeat(ctx context.Context, secret, jobID string) error | |
| 271 | + | // WatchJob calls ready once the job token is valid and the stream is | |
| 272 | + | // registered, before any message: the adapter flushes the response headers | |
| 273 | + | // then, because a client's call returns only when they arrive. | |
| 274 | + | WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error | |
| 275 | + | CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // JobView is a job as owners see it. | |
| 279 | + | type JobView struct { | |
| 280 | + | domain.CIJob | |
| 281 | + | // Lines is the number of lines of its log now. | |
| 282 | + | Lines int64 | |
| 283 | + | // RunnerGone is set when the runner of the job was removed since. | |
| 284 | + | RunnerGone bool | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // StepRanges returns where the lines of every step are in the log. | |
| 288 | + | func (v JobView) StepRanges() []domain.StepRange { return domain.StepRanges(v.Steps, v.Lines) } | |
| 289 | + | ||
| 290 | + | // ListRunsParams select a page of runs. | |
| 291 | + | type ListRunsParams struct { | |
| 292 | + | PageSize int | |
| 293 | + | PageToken string | |
| 294 | + | // Workflow restricts the result to a workflow file: its path or its file | |
| 295 | + | // name. Branch restricts it to the runs of a branch, by short name. Status | |
| 296 | + | // restricts it to the runs of that status. Empty means no restriction. | |
| 297 | + | Workflow string | |
| 298 | + | Branch string | |
| 299 | + | Status domain.CIStatus | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // RunPage is a page of runs, newest first. | |
| 303 | + | type RunPage struct { | |
| 304 | + | Runs []domain.CIRun | |
| 305 | + | NextPageToken string | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // RunDetail is a run with its jobs. | |
| 309 | + | type RunDetail struct { | |
| 310 | + | Run domain.CIRun | |
| 311 | + | Jobs []JobView | |
| 312 | + | LatestAttempt int32 | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // LogBatch is a message of a log stream: consecutive lines from Offset. | |
| 316 | + | type LogBatch struct { | |
| 317 | + | Offset int64 | |
| 318 | + | Lines []JobLogLine | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // JobLogLine is a log line with its step. | |
| 322 | + | type JobLogLine struct { | |
| 323 | + | Step int32 | |
| 324 | + | domain.CILogLine | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // GetJobLogParams select the log to stream. | |
| 328 | + | type GetJobLogParams struct { | |
| 329 | + | JobID string | |
| 330 | + | // Attempt is the attempt of the job, 0 for the latest. | |
| 331 | + | Attempt int32 | |
| 332 | + | Offset int64 | |
| 333 | + | Follow bool | |
| 334 | + | // Ready, when not nil, is called once the access is checked and the | |
| 335 | + | // stream is counted, before any batch: the adapter flushes the response | |
| 336 | + | // headers then (a follow on a job with no line yet would keep the client's | |
| 337 | + | // call waiting for the first one). | |
| 338 | + | Ready func() | |
| 339 | + | // Client identifies where the call comes from, for the limit of streams | |
| 340 | + | // per client; empty counts against the total only. | |
| 341 | + | Client string | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // CheckView is a check with what owners need to read it. | |
| 345 | + | type CheckView struct { | |
| 346 | + | domain.CICheck | |
| 347 | + | // WaitingForRunner is set for a queued check whose job no registered | |
| 348 | + | // runner can take (no runner has all its labels). | |
| 349 | + | WaitingForRunner bool | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // ChecksResult are the checks of a commit. | |
| 353 | + | type ChecksResult struct { | |
| 354 | + | Checks []CheckView | |
| 355 | + | Truncated bool | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // RunnerPage is a page of runners. | |
| 359 | + | type RunnerPage struct { | |
| 360 | + | Runners []RunnerView | |
| 361 | + | NextPageToken string | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // RunnerView is a runner with what is known of it now. | |
| 365 | + | type RunnerView struct { | |
| 366 | + | domain.CIRunner | |
| 367 | + | LastSeen time.Time | |
| 368 | + | Busy bool | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // RunService is the owner side of CI. *usecase.CI implements it. | |
| 372 | + | type RunService interface { | |
| 373 | + | ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error) | |
| 374 | + | GetRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (RunDetail, error) | |
| 375 | + | GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (JobView, error) | |
| 376 | + | GetJobLog(ctx context.Context, p domain.Principal, repo domain.RepoName, params GetJobLogParams, send func(LogBatch) error) error | |
| 377 | + | CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error) | |
| 378 | + | RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) | |
| 379 | + | ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error) | |
| 380 | + | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | |
| 381 | + | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | |
| 382 | + | } | |
M internal/ports/dto.go +2 -2
| @@ -137,8 +137,8 @@ type WatchEvent struct { | |||
| 137 | 137 | // Event is nil then. | |
| 138 | 138 | Gap bool | |
| 139 | 139 | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, | |
| 140 | - | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |
| 141 | - | // CommentAdded. | |
| 140 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed, | |
| 141 | + | // CommentAdded, RunUpdated or JobUpdated. | |
| 142 | 142 | Event domain.Event | |
| 143 | 143 | // Repo is the state of the repository at delivery, for RepoCreated and | |
| 144 | 144 | // RepoUpdated. | |
A internal/ports/mocks/CIChecksReader.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewCIChecksReader creates a new instance of CIChecksReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewCIChecksReader(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CIChecksReader { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CIChecksReader{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CIChecksReader is an autogenerated mock type for the CIChecksReader type | |
| 38 | + | type CIChecksReader struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CIChecksReader_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CIChecksReader) EXPECT() *CIChecksReader_Expecter { | |
| 47 | + | return &CIChecksReader_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // CommitChecks provides a mock function for the type CIChecksReader | |
| 51 | + | func (_mock *CIChecksReader) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo, sha) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for CommitChecks") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []domain.CICheck | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ([]domain.CICheck, error)); ok { | |
| 61 | + | return returnFunc(ctx, repo, sha) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) []domain.CICheck); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, sha) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]domain.CICheck) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, repo, sha) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // CIChecksReader_CommitChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitChecks' | |
| 79 | + | type CIChecksReader_CommitChecks_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // CommitChecks is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - sha string | |
| 87 | + | func (_e *CIChecksReader_Expecter) CommitChecks(ctx any, repo any, sha any) *CIChecksReader_CommitChecks_Call { | |
| 88 | + | return &CIChecksReader_CommitChecks_Call{Call: _e.mock.On("CommitChecks", ctx, repo, sha)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *CIChecksReader_CommitChecks_Call) Run(run func(ctx context.Context, repo domain.RepoName, sha string)) *CIChecksReader_CommitChecks_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 context.Context | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(context.Context) | |
| 96 | + | } | |
| 97 | + | var arg1 domain.RepoName | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(domain.RepoName) | |
| 100 | + | } | |
| 101 | + | var arg2 string | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].(string) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | arg2, | |
| 109 | + | ) | |
| 110 | + | }) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *CIChecksReader_CommitChecks_Call) Return(cIChecks []domain.CICheck, err error) *CIChecksReader_CommitChecks_Call { | |
| 115 | + | _c.Call.Return(cIChecks, err) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *CIChecksReader_CommitChecks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)) *CIChecksReader_CommitChecks_Call { | |
| 120 | + | _c.Call.Return(run) | |
| 121 | + | return _c | |
| 122 | + | } | |
A internal/ports/mocks/CIGit.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewCIGit creates a new instance of CIGit. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewCIGit(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CIGit { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CIGit{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CIGit is an autogenerated mock type for the CIGit type | |
| 38 | + | type CIGit struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CIGit_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CIGit) EXPECT() *CIGit_Expecter { | |
| 47 | + | return &CIGit_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ChangedPaths provides a mock function for the type CIGit | |
| 51 | + | func (_mock *CIGit) ChangedPaths(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int) ([]string, bool, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo, base, head, max) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for ChangedPaths") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []string | |
| 59 | + | var r1 bool | |
| 60 | + | var r2 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) ([]string, bool, error)); ok { | |
| 62 | + | return returnFunc(ctx, repo, base, head, max) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) []string); ok { | |
| 65 | + | r0 = returnFunc(ctx, repo, base, head, max) | |
| 66 | + | } else { | |
| 67 | + | if ret.Get(0) != nil { | |
| 68 | + | r0 = ret.Get(0).([]string) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) bool); ok { | |
| 72 | + | r1 = returnFunc(ctx, repo, base, head, max) | |
| 73 | + | } else { | |
| 74 | + | r1 = ret.Get(1).(bool) | |
| 75 | + | } | |
| 76 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok { | |
| 77 | + | r2 = returnFunc(ctx, repo, base, head, max) | |
| 78 | + | } else { | |
| 79 | + | r2 = ret.Error(2) | |
| 80 | + | } | |
| 81 | + | return r0, r1, r2 | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // CIGit_ChangedPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedPaths' | |
| 85 | + | type CIGit_ChangedPaths_Call struct { | |
| 86 | + | *mock.Call | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // ChangedPaths is a helper method to define mock.On call | |
| 90 | + | // - ctx context.Context | |
| 91 | + | // - repo domain.RepoName | |
| 92 | + | // - base domain.ObjectID | |
| 93 | + | // - head domain.ObjectID | |
| 94 | + | // - max int | |
| 95 | + | func (_e *CIGit_Expecter) ChangedPaths(ctx any, repo any, base any, head any, max any) *CIGit_ChangedPaths_Call { | |
| 96 | + | return &CIGit_ChangedPaths_Call{Call: _e.mock.On("ChangedPaths", ctx, repo, base, head, max)} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *CIGit_ChangedPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int)) *CIGit_ChangedPaths_Call { | |
| 100 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 101 | + | var arg0 context.Context | |
| 102 | + | if args[0] != nil { | |
| 103 | + | arg0 = args[0].(context.Context) | |
| 104 | + | } | |
| 105 | + | var arg1 domain.RepoName | |
| 106 | + | if args[1] != nil { | |
| 107 | + | arg1 = args[1].(domain.RepoName) | |
| 108 | + | } | |
| 109 | + | var arg2 domain.ObjectID | |
| 110 | + | if args[2] != nil { | |
| 111 | + | arg2 = args[2].(domain.ObjectID) | |
| 112 | + | } | |
| 113 | + | var arg3 domain.ObjectID | |
| 114 | + | if args[3] != nil { | |
| 115 | + | arg3 = args[3].(domain.ObjectID) | |
| 116 | + | } | |
| 117 | + | var arg4 int | |
| 118 | + | if args[4] != nil { | |
| 119 | + | arg4 = args[4].(int) | |
| 120 | + | } | |
| 121 | + | run( | |
| 122 | + | arg0, | |
| 123 | + | arg1, | |
| 124 | + | arg2, | |
| 125 | + | arg3, | |
| 126 | + | arg4, | |
| 127 | + | ) | |
| 128 | + | }) | |
| 129 | + | return _c | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (_c *CIGit_ChangedPaths_Call) Return(paths []string, truncated bool, err error) *CIGit_ChangedPaths_Call { | |
| 133 | + | _c.Call.Return(paths, truncated, err) | |
| 134 | + | return _c | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int) ([]string, bool, error)) *CIGit_ChangedPaths_Call { | |
| 138 | + | _c.Call.Return(run) | |
| 139 | + | return _c | |
| 140 | + | } | |
A internal/ports/mocks/CILogStore.go +592 -0
| @@ -0,0 +1,592 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewCILogStore creates a new instance of CILogStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewCILogStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *CILogStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &CILogStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // CILogStore is an autogenerated mock type for the CILogStore type | |
| 39 | + | type CILogStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type CILogStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *CILogStore) EXPECT() *CILogStore_Expecter { | |
| 48 | + | return &CILogStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Append provides a mock function for the type CILogStore | |
| 52 | + | func (_mock *CILogStore) Append(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error) { | |
| 53 | + | ret := _mock.Called(ctx, ref, offset, lines, limit) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Append") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 int64 | |
| 60 | + | var r1 int | |
| 61 | + | var r2 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) (int64, int, error)); ok { | |
| 63 | + | return returnFunc(ctx, ref, offset, lines, limit) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) int64); ok { | |
| 66 | + | r0 = returnFunc(ctx, ref, offset, lines, limit) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(int64) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) int); ok { | |
| 71 | + | r1 = returnFunc(ctx, ref, offset, lines, limit) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Get(1).(int) | |
| 74 | + | } | |
| 75 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) error); ok { | |
| 76 | + | r2 = returnFunc(ctx, ref, offset, lines, limit) | |
| 77 | + | } else { | |
| 78 | + | r2 = ret.Error(2) | |
| 79 | + | } | |
| 80 | + | return r0, r1, r2 | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // CILogStore_Append_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Append' | |
| 84 | + | type CILogStore_Append_Call struct { | |
| 85 | + | *mock.Call | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Append is a helper method to define mock.On call | |
| 89 | + | // - ctx context.Context | |
| 90 | + | // - ref ports.CILogRef | |
| 91 | + | // - offset int64 | |
| 92 | + | // - lines []domain.CILogLine | |
| 93 | + | // - limit int64 | |
| 94 | + | func (_e *CILogStore_Expecter) Append(ctx any, ref any, offset any, lines any, limit any) *CILogStore_Append_Call { | |
| 95 | + | return &CILogStore_Append_Call{Call: _e.mock.On("Append", ctx, ref, offset, lines, limit)} | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *CILogStore_Append_Call) Run(run func(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64)) *CILogStore_Append_Call { | |
| 99 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 100 | + | var arg0 context.Context | |
| 101 | + | if args[0] != nil { | |
| 102 | + | arg0 = args[0].(context.Context) | |
| 103 | + | } | |
| 104 | + | var arg1 ports.CILogRef | |
| 105 | + | if args[1] != nil { | |
| 106 | + | arg1 = args[1].(ports.CILogRef) | |
| 107 | + | } | |
| 108 | + | var arg2 int64 | |
| 109 | + | if args[2] != nil { | |
| 110 | + | arg2 = args[2].(int64) | |
| 111 | + | } | |
| 112 | + | var arg3 []domain.CILogLine | |
| 113 | + | if args[3] != nil { | |
| 114 | + | arg3 = args[3].([]domain.CILogLine) | |
| 115 | + | } | |
| 116 | + | var arg4 int64 | |
| 117 | + | if args[4] != nil { | |
| 118 | + | arg4 = args[4].(int64) | |
| 119 | + | } | |
| 120 | + | run( | |
| 121 | + | arg0, | |
| 122 | + | arg1, | |
| 123 | + | arg2, | |
| 124 | + | arg3, | |
| 125 | + | arg4, | |
| 126 | + | ) | |
| 127 | + | }) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *CILogStore_Append_Call) Return(next int64, added int, err error) *CILogStore_Append_Call { | |
| 132 | + | _c.Call.Return(next, added, err) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *CILogStore_Append_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error)) *CILogStore_Append_Call { | |
| 137 | + | _c.Call.Return(run) | |
| 138 | + | return _c | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // AppendForced provides a mock function for the type CILogStore | |
| 142 | + | func (_mock *CILogStore) AppendForced(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error) { | |
| 143 | + | ret := _mock.Called(ctx, ref, lines) | |
| 144 | + | ||
| 145 | + | if len(ret) == 0 { | |
| 146 | + | panic("no return value specified for AppendForced") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | var r0 int64 | |
| 150 | + | var r1 error | |
| 151 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, []domain.CILogLine) (int64, error)); ok { | |
| 152 | + | return returnFunc(ctx, ref, lines) | |
| 153 | + | } | |
| 154 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, []domain.CILogLine) int64); ok { | |
| 155 | + | r0 = returnFunc(ctx, ref, lines) | |
| 156 | + | } else { | |
| 157 | + | r0 = ret.Get(0).(int64) | |
| 158 | + | } | |
| 159 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, []domain.CILogLine) error); ok { | |
| 160 | + | r1 = returnFunc(ctx, ref, lines) | |
| 161 | + | } else { | |
| 162 | + | r1 = ret.Error(1) | |
| 163 | + | } | |
| 164 | + | return r0, r1 | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // CILogStore_AppendForced_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendForced' | |
| 168 | + | type CILogStore_AppendForced_Call struct { | |
| 169 | + | *mock.Call | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // AppendForced is a helper method to define mock.On call | |
| 173 | + | // - ctx context.Context | |
| 174 | + | // - ref ports.CILogRef | |
| 175 | + | // - lines []domain.CILogLine | |
| 176 | + | func (_e *CILogStore_Expecter) AppendForced(ctx any, ref any, lines any) *CILogStore_AppendForced_Call { | |
| 177 | + | return &CILogStore_AppendForced_Call{Call: _e.mock.On("AppendForced", ctx, ref, lines)} | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (_c *CILogStore_AppendForced_Call) Run(run func(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine)) *CILogStore_AppendForced_Call { | |
| 181 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 182 | + | var arg0 context.Context | |
| 183 | + | if args[0] != nil { | |
| 184 | + | arg0 = args[0].(context.Context) | |
| 185 | + | } | |
| 186 | + | var arg1 ports.CILogRef | |
| 187 | + | if args[1] != nil { | |
| 188 | + | arg1 = args[1].(ports.CILogRef) | |
| 189 | + | } | |
| 190 | + | var arg2 []domain.CILogLine | |
| 191 | + | if args[2] != nil { | |
| 192 | + | arg2 = args[2].([]domain.CILogLine) | |
| 193 | + | } | |
| 194 | + | run( | |
| 195 | + | arg0, | |
| 196 | + | arg1, | |
| 197 | + | arg2, | |
| 198 | + | ) | |
| 199 | + | }) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *CILogStore_AppendForced_Call) Return(next int64, err error) *CILogStore_AppendForced_Call { | |
| 204 | + | _c.Call.Return(next, err) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *CILogStore_AppendForced_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error)) *CILogStore_AppendForced_Call { | |
| 209 | + | _c.Call.Return(run) | |
| 210 | + | return _c | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Changed provides a mock function for the type CILogStore | |
| 214 | + | func (_mock *CILogStore) Changed(ref ports.CILogRef) <-chan struct{} { | |
| 215 | + | ret := _mock.Called(ref) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for Changed") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 <-chan struct{} | |
| 222 | + | if returnFunc, ok := ret.Get(0).(func(ports.CILogRef) <-chan struct{}); ok { | |
| 223 | + | r0 = returnFunc(ref) | |
| 224 | + | } else { | |
| 225 | + | if ret.Get(0) != nil { | |
| 226 | + | r0 = ret.Get(0).(<-chan struct{}) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | return r0 | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // CILogStore_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed' | |
| 233 | + | type CILogStore_Changed_Call struct { | |
| 234 | + | *mock.Call | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Changed is a helper method to define mock.On call | |
| 238 | + | // - ref ports.CILogRef | |
| 239 | + | func (_e *CILogStore_Expecter) Changed(ref any) *CILogStore_Changed_Call { | |
| 240 | + | return &CILogStore_Changed_Call{Call: _e.mock.On("Changed", ref)} | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func (_c *CILogStore_Changed_Call) Run(run func(ref ports.CILogRef)) *CILogStore_Changed_Call { | |
| 244 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 245 | + | var arg0 ports.CILogRef | |
| 246 | + | if args[0] != nil { | |
| 247 | + | arg0 = args[0].(ports.CILogRef) | |
| 248 | + | } | |
| 249 | + | run( | |
| 250 | + | arg0, | |
| 251 | + | ) | |
| 252 | + | }) | |
| 253 | + | return _c | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (_c *CILogStore_Changed_Call) Return(valCh <-chan struct{}) *CILogStore_Changed_Call { | |
| 257 | + | _c.Call.Return(valCh) | |
| 258 | + | return _c | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (_c *CILogStore_Changed_Call) RunAndReturn(run func(ref ports.CILogRef) <-chan struct{}) *CILogStore_Changed_Call { | |
| 262 | + | _c.Call.Return(run) | |
| 263 | + | return _c | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Count provides a mock function for the type CILogStore | |
| 267 | + | func (_mock *CILogStore) Count(ctx context.Context, ref ports.CILogRef) (int64, error) { | |
| 268 | + | ret := _mock.Called(ctx, ref) | |
| 269 | + | ||
| 270 | + | if len(ret) == 0 { | |
| 271 | + | panic("no return value specified for Count") | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | var r0 int64 | |
| 275 | + | var r1 error | |
| 276 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) (int64, error)); ok { | |
| 277 | + | return returnFunc(ctx, ref) | |
| 278 | + | } | |
| 279 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) int64); ok { | |
| 280 | + | r0 = returnFunc(ctx, ref) | |
| 281 | + | } else { | |
| 282 | + | r0 = ret.Get(0).(int64) | |
| 283 | + | } | |
| 284 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef) error); ok { | |
| 285 | + | r1 = returnFunc(ctx, ref) | |
| 286 | + | } else { | |
| 287 | + | r1 = ret.Error(1) | |
| 288 | + | } | |
| 289 | + | return r0, r1 | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // CILogStore_Count_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Count' | |
| 293 | + | type CILogStore_Count_Call struct { | |
| 294 | + | *mock.Call | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Count is a helper method to define mock.On call | |
| 298 | + | // - ctx context.Context | |
| 299 | + | // - ref ports.CILogRef | |
| 300 | + | func (_e *CILogStore_Expecter) Count(ctx any, ref any) *CILogStore_Count_Call { | |
| 301 | + | return &CILogStore_Count_Call{Call: _e.mock.On("Count", ctx, ref)} | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (_c *CILogStore_Count_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CILogStore_Count_Call { | |
| 305 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 306 | + | var arg0 context.Context | |
| 307 | + | if args[0] != nil { | |
| 308 | + | arg0 = args[0].(context.Context) | |
| 309 | + | } | |
| 310 | + | var arg1 ports.CILogRef | |
| 311 | + | if args[1] != nil { | |
| 312 | + | arg1 = args[1].(ports.CILogRef) | |
| 313 | + | } | |
| 314 | + | run( | |
| 315 | + | arg0, | |
| 316 | + | arg1, | |
| 317 | + | ) | |
| 318 | + | }) | |
| 319 | + | return _c | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func (_c *CILogStore_Count_Call) Return(n int64, err error) *CILogStore_Count_Call { | |
| 323 | + | _c.Call.Return(n, err) | |
| 324 | + | return _c | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func (_c *CILogStore_Count_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) (int64, error)) *CILogStore_Count_Call { | |
| 328 | + | _c.Call.Return(run) | |
| 329 | + | return _c | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // Read provides a mock function for the type CILogStore | |
| 333 | + | func (_mock *CILogStore) Read(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int) ([]domain.CILogLine, error) { | |
| 334 | + | ret := _mock.Called(ctx, ref, from, maxLines, maxBytes) | |
| 335 | + | ||
| 336 | + | if len(ret) == 0 { | |
| 337 | + | panic("no return value specified for Read") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | var r0 []domain.CILogLine | |
| 341 | + | var r1 error | |
| 342 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, int, int) ([]domain.CILogLine, error)); ok { | |
| 343 | + | return returnFunc(ctx, ref, from, maxLines, maxBytes) | |
| 344 | + | } | |
| 345 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, int, int) []domain.CILogLine); ok { | |
| 346 | + | r0 = returnFunc(ctx, ref, from, maxLines, maxBytes) | |
| 347 | + | } else { | |
| 348 | + | if ret.Get(0) != nil { | |
| 349 | + | r0 = ret.Get(0).([]domain.CILogLine) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int64, int, int) error); ok { | |
| 353 | + | r1 = returnFunc(ctx, ref, from, maxLines, maxBytes) | |
| 354 | + | } else { | |
| 355 | + | r1 = ret.Error(1) | |
| 356 | + | } | |
| 357 | + | return r0, r1 | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // CILogStore_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' | |
| 361 | + | type CILogStore_Read_Call struct { | |
| 362 | + | *mock.Call | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // Read is a helper method to define mock.On call | |
| 366 | + | // - ctx context.Context | |
| 367 | + | // - ref ports.CILogRef | |
| 368 | + | // - from int64 | |
| 369 | + | // - maxLines int | |
| 370 | + | // - maxBytes int | |
| 371 | + | func (_e *CILogStore_Expecter) Read(ctx any, ref any, from any, maxLines any, maxBytes any) *CILogStore_Read_Call { | |
| 372 | + | return &CILogStore_Read_Call{Call: _e.mock.On("Read", ctx, ref, from, maxLines, maxBytes)} | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func (_c *CILogStore_Read_Call) Run(run func(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int)) *CILogStore_Read_Call { | |
| 376 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 377 | + | var arg0 context.Context | |
| 378 | + | if args[0] != nil { | |
| 379 | + | arg0 = args[0].(context.Context) | |
| 380 | + | } | |
| 381 | + | var arg1 ports.CILogRef | |
| 382 | + | if args[1] != nil { | |
| 383 | + | arg1 = args[1].(ports.CILogRef) | |
| 384 | + | } | |
| 385 | + | var arg2 int64 | |
| 386 | + | if args[2] != nil { | |
| 387 | + | arg2 = args[2].(int64) | |
| 388 | + | } | |
| 389 | + | var arg3 int | |
| 390 | + | if args[3] != nil { | |
| 391 | + | arg3 = args[3].(int) | |
| 392 | + | } | |
| 393 | + | var arg4 int | |
| 394 | + | if args[4] != nil { | |
| 395 | + | arg4 = args[4].(int) | |
| 396 | + | } | |
| 397 | + | run( | |
| 398 | + | arg0, | |
| 399 | + | arg1, | |
| 400 | + | arg2, | |
| 401 | + | arg3, | |
| 402 | + | arg4, | |
| 403 | + | ) | |
| 404 | + | }) | |
| 405 | + | return _c | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func (_c *CILogStore_Read_Call) Return(cILogLines []domain.CILogLine, err error) *CILogStore_Read_Call { | |
| 409 | + | _c.Call.Return(cILogLines, err) | |
| 410 | + | return _c | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func (_c *CILogStore_Read_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int) ([]domain.CILogLine, error)) *CILogStore_Read_Call { | |
| 414 | + | _c.Call.Return(run) | |
| 415 | + | return _c | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // RemoveRuns provides a mock function for the type CILogStore | |
| 419 | + | func (_mock *CILogStore) RemoveRuns(ctx context.Context, runIDs []int64) error { | |
| 420 | + | ret := _mock.Called(ctx, runIDs) | |
| 421 | + | ||
| 422 | + | if len(ret) == 0 { | |
| 423 | + | panic("no return value specified for RemoveRuns") | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | var r0 error | |
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok { | |
| 428 | + | r0 = returnFunc(ctx, runIDs) | |
| 429 | + | } else { | |
| 430 | + | r0 = ret.Error(0) | |
| 431 | + | } | |
| 432 | + | return r0 | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // CILogStore_RemoveRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRuns' | |
| 436 | + | type CILogStore_RemoveRuns_Call struct { | |
| 437 | + | *mock.Call | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // RemoveRuns is a helper method to define mock.On call | |
| 441 | + | // - ctx context.Context | |
| 442 | + | // - runIDs []int64 | |
| 443 | + | func (_e *CILogStore_Expecter) RemoveRuns(ctx any, runIDs any) *CILogStore_RemoveRuns_Call { | |
| 444 | + | return &CILogStore_RemoveRuns_Call{Call: _e.mock.On("RemoveRuns", ctx, runIDs)} | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func (_c *CILogStore_RemoveRuns_Call) Run(run func(ctx context.Context, runIDs []int64)) *CILogStore_RemoveRuns_Call { | |
| 448 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 449 | + | var arg0 context.Context | |
| 450 | + | if args[0] != nil { | |
| 451 | + | arg0 = args[0].(context.Context) | |
| 452 | + | } | |
| 453 | + | var arg1 []int64 | |
| 454 | + | if args[1] != nil { | |
| 455 | + | arg1 = args[1].([]int64) | |
| 456 | + | } | |
| 457 | + | run( | |
| 458 | + | arg0, | |
| 459 | + | arg1, | |
| 460 | + | ) | |
| 461 | + | }) | |
| 462 | + | return _c | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func (_c *CILogStore_RemoveRuns_Call) Return(err error) *CILogStore_RemoveRuns_Call { | |
| 466 | + | _c.Call.Return(err) | |
| 467 | + | return _c | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (_c *CILogStore_RemoveRuns_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CILogStore_RemoveRuns_Call { | |
| 471 | + | _c.Call.Return(run) | |
| 472 | + | return _c | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // Reset provides a mock function for the type CILogStore | |
| 476 | + | func (_mock *CILogStore) Reset(ctx context.Context, ref ports.CILogRef) error { | |
| 477 | + | ret := _mock.Called(ctx, ref) | |
| 478 | + | ||
| 479 | + | if len(ret) == 0 { | |
| 480 | + | panic("no return value specified for Reset") | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | var r0 error | |
| 484 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) error); ok { | |
| 485 | + | r0 = returnFunc(ctx, ref) | |
| 486 | + | } else { | |
| 487 | + | r0 = ret.Error(0) | |
| 488 | + | } | |
| 489 | + | return r0 | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // CILogStore_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' | |
| 493 | + | type CILogStore_Reset_Call struct { | |
| 494 | + | *mock.Call | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // Reset is a helper method to define mock.On call | |
| 498 | + | // - ctx context.Context | |
| 499 | + | // - ref ports.CILogRef | |
| 500 | + | func (_e *CILogStore_Expecter) Reset(ctx any, ref any) *CILogStore_Reset_Call { | |
| 501 | + | return &CILogStore_Reset_Call{Call: _e.mock.On("Reset", ctx, ref)} | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func (_c *CILogStore_Reset_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CILogStore_Reset_Call { | |
| 505 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 506 | + | var arg0 context.Context | |
| 507 | + | if args[0] != nil { | |
| 508 | + | arg0 = args[0].(context.Context) | |
| 509 | + | } | |
| 510 | + | var arg1 ports.CILogRef | |
| 511 | + | if args[1] != nil { | |
| 512 | + | arg1 = args[1].(ports.CILogRef) | |
| 513 | + | } | |
| 514 | + | run( | |
| 515 | + | arg0, | |
| 516 | + | arg1, | |
| 517 | + | ) | |
| 518 | + | }) | |
| 519 | + | return _c | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func (_c *CILogStore_Reset_Call) Return(err error) *CILogStore_Reset_Call { | |
| 523 | + | _c.Call.Return(err) | |
| 524 | + | return _c | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (_c *CILogStore_Reset_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) error) *CILogStore_Reset_Call { | |
| 528 | + | _c.Call.Return(run) | |
| 529 | + | return _c | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // RunIDs provides a mock function for the type CILogStore | |
| 533 | + | func (_mock *CILogStore) RunIDs(ctx context.Context) ([]int64, error) { | |
| 534 | + | ret := _mock.Called(ctx) | |
| 535 | + | ||
| 536 | + | if len(ret) == 0 { | |
| 537 | + | panic("no return value specified for RunIDs") | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | var r0 []int64 | |
| 541 | + | var r1 error | |
| 542 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]int64, error)); ok { | |
| 543 | + | return returnFunc(ctx) | |
| 544 | + | } | |
| 545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []int64); ok { | |
| 546 | + | r0 = returnFunc(ctx) | |
| 547 | + | } else { | |
| 548 | + | if ret.Get(0) != nil { | |
| 549 | + | r0 = ret.Get(0).([]int64) | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 553 | + | r1 = returnFunc(ctx) | |
| 554 | + | } else { | |
| 555 | + | r1 = ret.Error(1) | |
| 556 | + | } | |
| 557 | + | return r0, r1 | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | // CILogStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs' | |
| 561 | + | type CILogStore_RunIDs_Call struct { | |
| 562 | + | *mock.Call | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // RunIDs is a helper method to define mock.On call | |
| 566 | + | // - ctx context.Context | |
| 567 | + | func (_e *CILogStore_Expecter) RunIDs(ctx any) *CILogStore_RunIDs_Call { | |
| 568 | + | return &CILogStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx)} | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | func (_c *CILogStore_RunIDs_Call) Run(run func(ctx context.Context)) *CILogStore_RunIDs_Call { | |
| 572 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 573 | + | var arg0 context.Context | |
| 574 | + | if args[0] != nil { | |
| 575 | + | arg0 = args[0].(context.Context) | |
| 576 | + | } | |
| 577 | + | run( | |
| 578 | + | arg0, | |
| 579 | + | ) | |
| 580 | + | }) | |
| 581 | + | return _c | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func (_c *CILogStore_RunIDs_Call) Return(int64s []int64, err error) *CILogStore_RunIDs_Call { | |
| 585 | + | _c.Call.Return(int64s, err) | |
| 586 | + | return _c | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func (_c *CILogStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context) ([]int64, error)) *CILogStore_RunIDs_Call { | |
| 590 | + | _c.Call.Return(run) | |
| 591 | + | return _c | |
| 592 | + | } | |
A internal/ports/mocks/CIRunCleaner.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewCIRunCleaner creates a new instance of CIRunCleaner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewCIRunCleaner(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CIRunCleaner { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CIRunCleaner{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CIRunCleaner is an autogenerated mock type for the CIRunCleaner type | |
| 38 | + | type CIRunCleaner struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CIRunCleaner_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CIRunCleaner) EXPECT() *CIRunCleaner_Expecter { | |
| 47 | + | return &CIRunCleaner_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // RemoveLogs provides a mock function for the type CIRunCleaner | |
| 51 | + | func (_mock *CIRunCleaner) RemoveLogs(ctx context.Context, runIDs []int64) error { | |
| 52 | + | ret := _mock.Called(ctx, runIDs) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for RemoveLogs") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, runIDs) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // CIRunCleaner_RemoveLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveLogs' | |
| 68 | + | type CIRunCleaner_RemoveLogs_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // RemoveLogs is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - runIDs []int64 | |
| 75 | + | func (_e *CIRunCleaner_Expecter) RemoveLogs(ctx any, runIDs any) *CIRunCleaner_RemoveLogs_Call { | |
| 76 | + | return &CIRunCleaner_RemoveLogs_Call{Call: _e.mock.On("RemoveLogs", ctx, runIDs)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *CIRunCleaner_RemoveLogs_Call) Run(run func(ctx context.Context, runIDs []int64)) *CIRunCleaner_RemoveLogs_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 []int64 | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].([]int64) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *CIRunCleaner_RemoveLogs_Call) Return(err error) *CIRunCleaner_RemoveLogs_Call { | |
| 98 | + | _c.Call.Return(err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *CIRunCleaner_RemoveLogs_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CIRunCleaner_RemoveLogs_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // RunIDs provides a mock function for the type CIRunCleaner | |
| 108 | + | func (_mock *CIRunCleaner) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) { | |
| 109 | + | ret := _mock.Called(ctx, repo) | |
| 110 | + | ||
| 111 | + | if len(ret) == 0 { | |
| 112 | + | panic("no return value specified for RunIDs") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | var r0 []int64 | |
| 116 | + | var r1 error | |
| 117 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]int64, error)); ok { | |
| 118 | + | return returnFunc(ctx, repo) | |
| 119 | + | } | |
| 120 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []int64); ok { | |
| 121 | + | r0 = returnFunc(ctx, repo) | |
| 122 | + | } else { | |
| 123 | + | if ret.Get(0) != nil { | |
| 124 | + | r0 = ret.Get(0).([]int64) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 128 | + | r1 = returnFunc(ctx, repo) | |
| 129 | + | } else { | |
| 130 | + | r1 = ret.Error(1) | |
| 131 | + | } | |
| 132 | + | return r0, r1 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // CIRunCleaner_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs' | |
| 136 | + | type CIRunCleaner_RunIDs_Call struct { | |
| 137 | + | *mock.Call | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // RunIDs is a helper method to define mock.On call | |
| 141 | + | // - ctx context.Context | |
| 142 | + | // - repo domain.RepoName | |
| 143 | + | func (_e *CIRunCleaner_Expecter) RunIDs(ctx any, repo any) *CIRunCleaner_RunIDs_Call { | |
| 144 | + | return &CIRunCleaner_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx, repo)} | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (_c *CIRunCleaner_RunIDs_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIRunCleaner_RunIDs_Call { | |
| 148 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 149 | + | var arg0 context.Context | |
| 150 | + | if args[0] != nil { | |
| 151 | + | arg0 = args[0].(context.Context) | |
| 152 | + | } | |
| 153 | + | var arg1 domain.RepoName | |
| 154 | + | if args[1] != nil { | |
| 155 | + | arg1 = args[1].(domain.RepoName) | |
| 156 | + | } | |
| 157 | + | run( | |
| 158 | + | arg0, | |
| 159 | + | arg1, | |
| 160 | + | ) | |
| 161 | + | }) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (_c *CIRunCleaner_RunIDs_Call) Return(int64s []int64, err error) *CIRunCleaner_RunIDs_Call { | |
| 166 | + | _c.Call.Return(int64s, err) | |
| 167 | + | return _c | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (_c *CIRunCleaner_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]int64, error)) *CIRunCleaner_RunIDs_Call { | |
| 171 | + | _c.Call.Return(run) | |
| 172 | + | return _c | |
| 173 | + | } | |
A internal/ports/mocks/CIStore.go +1384 -0
| @@ -0,0 +1,1384 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewCIStore creates a new instance of CIStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewCIStore(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *CIStore { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | mock := &CIStore{} | |
| 27 | + | mock.Mock.Test(t) | |
| 28 | + | ||
| 29 | + | t.Cleanup(func() { | |
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 31 | + | helper.Helper() | |
| 32 | + | } | |
| 33 | + | mock.AssertExpectations(t) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | return mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // CIStore is an autogenerated mock type for the CIStore type | |
| 40 | + | type CIStore struct { | |
| 41 | + | mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type CIStore_Expecter struct { | |
| 45 | + | mock *mock.Mock | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (_m *CIStore) EXPECT() *CIStore_Expecter { | |
| 49 | + | return &CIStore_Expecter{mock: &_m.Mock} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Apply provides a mock function for the type CIStore | |
| 53 | + | func (_mock *CIStore) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) { | |
| 54 | + | ret := _mock.Called(ctx, w) | |
| 55 | + | ||
| 56 | + | if len(ret) == 0 { | |
| 57 | + | panic("no return value specified for Apply") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var r0 uint64 | |
| 61 | + | var r1 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIWrite) (uint64, error)); ok { | |
| 63 | + | return returnFunc(ctx, w) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIWrite) uint64); ok { | |
| 66 | + | r0 = returnFunc(ctx, w) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(uint64) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIWrite) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, w) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // CIStore_Apply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Apply' | |
| 79 | + | type CIStore_Apply_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Apply is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - w ports.CIWrite | |
| 86 | + | func (_e *CIStore_Expecter) Apply(ctx any, w any) *CIStore_Apply_Call { | |
| 87 | + | return &CIStore_Apply_Call{Call: _e.mock.On("Apply", ctx, w)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *CIStore_Apply_Call) Run(run func(ctx context.Context, w ports.CIWrite)) *CIStore_Apply_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 ports.CIWrite | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(ports.CIWrite) | |
| 99 | + | } | |
| 100 | + | run( | |
| 101 | + | arg0, | |
| 102 | + | arg1, | |
| 103 | + | ) | |
| 104 | + | }) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *CIStore_Apply_Call) Return(version uint64, err error) *CIStore_Apply_Call { | |
| 109 | + | _c.Call.Return(version, err) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *CIStore_Apply_Call) RunAndReturn(run func(ctx context.Context, w ports.CIWrite) (uint64, error)) *CIStore_Apply_Call { | |
| 114 | + | _c.Call.Return(run) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // CreateRun provides a mock function for the type CIStore | |
| 119 | + | func (_mock *CIStore) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 120 | + | ret := _mock.Called(ctx, repo, workflowPath, build) | |
| 121 | + | ||
| 122 | + | if len(ret) == 0 { | |
| 123 | + | panic("no return value specified for CreateRun") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | var r0 ports.CIRunDraft | |
| 127 | + | var r1 error | |
| 128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error)); ok { | |
| 129 | + | return returnFunc(ctx, repo, workflowPath, build) | |
| 130 | + | } | |
| 131 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) ports.CIRunDraft); ok { | |
| 132 | + | r0 = returnFunc(ctx, repo, workflowPath, build) | |
| 133 | + | } else { | |
| 134 | + | r0 = ret.Get(0).(ports.CIRunDraft) | |
| 135 | + | } | |
| 136 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) error); ok { | |
| 137 | + | r1 = returnFunc(ctx, repo, workflowPath, build) | |
| 138 | + | } else { | |
| 139 | + | r1 = ret.Error(1) | |
| 140 | + | } | |
| 141 | + | return r0, r1 | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // CIStore_CreateRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRun' | |
| 145 | + | type CIStore_CreateRun_Call struct { | |
| 146 | + | *mock.Call | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // CreateRun is a helper method to define mock.On call | |
| 150 | + | // - ctx context.Context | |
| 151 | + | // - repo domain.RepoName | |
| 152 | + | // - workflowPath string | |
| 153 | + | // - build func(id int64, number int64) ports.CIRunDraft | |
| 154 | + | func (_e *CIStore_Expecter) CreateRun(ctx any, repo any, workflowPath any, build any) *CIStore_CreateRun_Call { | |
| 155 | + | return &CIStore_CreateRun_Call{Call: _e.mock.On("CreateRun", ctx, repo, workflowPath, build)} | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (_c *CIStore_CreateRun_Call) Run(run func(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft)) *CIStore_CreateRun_Call { | |
| 159 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 160 | + | var arg0 context.Context | |
| 161 | + | if args[0] != nil { | |
| 162 | + | arg0 = args[0].(context.Context) | |
| 163 | + | } | |
| 164 | + | var arg1 domain.RepoName | |
| 165 | + | if args[1] != nil { | |
| 166 | + | arg1 = args[1].(domain.RepoName) | |
| 167 | + | } | |
| 168 | + | var arg2 string | |
| 169 | + | if args[2] != nil { | |
| 170 | + | arg2 = args[2].(string) | |
| 171 | + | } | |
| 172 | + | var arg3 func(id int64, number int64) ports.CIRunDraft | |
| 173 | + | if args[3] != nil { | |
| 174 | + | arg3 = args[3].(func(id int64, number int64) ports.CIRunDraft) | |
| 175 | + | } | |
| 176 | + | run( | |
| 177 | + | arg0, | |
| 178 | + | arg1, | |
| 179 | + | arg2, | |
| 180 | + | arg3, | |
| 181 | + | ) | |
| 182 | + | }) | |
| 183 | + | return _c | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (_c *CIStore_CreateRun_Call) Return(cIRunDraft ports.CIRunDraft, err error) *CIStore_CreateRun_Call { | |
| 187 | + | _c.Call.Return(cIRunDraft, err) | |
| 188 | + | return _c | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (_c *CIStore_CreateRun_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error)) *CIStore_CreateRun_Call { | |
| 192 | + | _c.Call.Return(run) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // CreateRunner provides a mock function for the type CIStore | |
| 197 | + | func (_mock *CIStore) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) { | |
| 198 | + | ret := _mock.Called(ctx, r, replace) | |
| 199 | + | ||
| 200 | + | if len(ret) == 0 { | |
| 201 | + | panic("no return value specified for CreateRunner") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | var r0 []domain.CIRunner | |
| 205 | + | var r1 error | |
| 206 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.CIRunner, bool) ([]domain.CIRunner, error)); ok { | |
| 207 | + | return returnFunc(ctx, r, replace) | |
| 208 | + | } | |
| 209 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.CIRunner, bool) []domain.CIRunner); ok { | |
| 210 | + | r0 = returnFunc(ctx, r, replace) | |
| 211 | + | } else { | |
| 212 | + | if ret.Get(0) != nil { | |
| 213 | + | r0 = ret.Get(0).([]domain.CIRunner) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.CIRunner, bool) error); ok { | |
| 217 | + | r1 = returnFunc(ctx, r, replace) | |
| 218 | + | } else { | |
| 219 | + | r1 = ret.Error(1) | |
| 220 | + | } | |
| 221 | + | return r0, r1 | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // CIStore_CreateRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRunner' | |
| 225 | + | type CIStore_CreateRunner_Call struct { | |
| 226 | + | *mock.Call | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // CreateRunner is a helper method to define mock.On call | |
| 230 | + | // - ctx context.Context | |
| 231 | + | // - r domain.CIRunner | |
| 232 | + | // - replace bool | |
| 233 | + | func (_e *CIStore_Expecter) CreateRunner(ctx any, r any, replace any) *CIStore_CreateRunner_Call { | |
| 234 | + | return &CIStore_CreateRunner_Call{Call: _e.mock.On("CreateRunner", ctx, r, replace)} | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (_c *CIStore_CreateRunner_Call) Run(run func(ctx context.Context, r domain.CIRunner, replace bool)) *CIStore_CreateRunner_Call { | |
| 238 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 239 | + | var arg0 context.Context | |
| 240 | + | if args[0] != nil { | |
| 241 | + | arg0 = args[0].(context.Context) | |
| 242 | + | } | |
| 243 | + | var arg1 domain.CIRunner | |
| 244 | + | if args[1] != nil { | |
| 245 | + | arg1 = args[1].(domain.CIRunner) | |
| 246 | + | } | |
| 247 | + | var arg2 bool | |
| 248 | + | if args[2] != nil { | |
| 249 | + | arg2 = args[2].(bool) | |
| 250 | + | } | |
| 251 | + | run( | |
| 252 | + | arg0, | |
| 253 | + | arg1, | |
| 254 | + | arg2, | |
| 255 | + | ) | |
| 256 | + | }) | |
| 257 | + | return _c | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (_c *CIStore_CreateRunner_Call) Return(removed []domain.CIRunner, err error) *CIStore_CreateRunner_Call { | |
| 261 | + | _c.Call.Return(removed, err) | |
| 262 | + | return _c | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (_c *CIStore_CreateRunner_Call) RunAndReturn(run func(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error)) *CIStore_CreateRunner_Call { | |
| 266 | + | _c.Call.Return(run) | |
| 267 | + | return _c | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // FindJobByToken provides a mock function for the type CIStore | |
| 271 | + | func (_mock *CIStore) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) { | |
| 272 | + | ret := _mock.Called(ctx, hash, now) | |
| 273 | + | ||
| 274 | + | if len(ret) == 0 { | |
| 275 | + | panic("no return value specified for FindJobByToken") | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | var r0 domain.CIJob | |
| 279 | + | var r1 error | |
| 280 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash, time.Time) (domain.CIJob, error)); ok { | |
| 281 | + | return returnFunc(ctx, hash, now) | |
| 282 | + | } | |
| 283 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash, time.Time) domain.CIJob); ok { | |
| 284 | + | r0 = returnFunc(ctx, hash, now) | |
| 285 | + | } else { | |
| 286 | + | r0 = ret.Get(0).(domain.CIJob) | |
| 287 | + | } | |
| 288 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash, time.Time) error); ok { | |
| 289 | + | r1 = returnFunc(ctx, hash, now) | |
| 290 | + | } else { | |
| 291 | + | r1 = ret.Error(1) | |
| 292 | + | } | |
| 293 | + | return r0, r1 | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // CIStore_FindJobByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindJobByToken' | |
| 297 | + | type CIStore_FindJobByToken_Call struct { | |
| 298 | + | *mock.Call | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // FindJobByToken is a helper method to define mock.On call | |
| 302 | + | // - ctx context.Context | |
| 303 | + | // - hash domain.TokenHash | |
| 304 | + | // - now time.Time | |
| 305 | + | func (_e *CIStore_Expecter) FindJobByToken(ctx any, hash any, now any) *CIStore_FindJobByToken_Call { | |
| 306 | + | return &CIStore_FindJobByToken_Call{Call: _e.mock.On("FindJobByToken", ctx, hash, now)} | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (_c *CIStore_FindJobByToken_Call) Run(run func(ctx context.Context, hash domain.TokenHash, now time.Time)) *CIStore_FindJobByToken_Call { | |
| 310 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 311 | + | var arg0 context.Context | |
| 312 | + | if args[0] != nil { | |
| 313 | + | arg0 = args[0].(context.Context) | |
| 314 | + | } | |
| 315 | + | var arg1 domain.TokenHash | |
| 316 | + | if args[1] != nil { | |
| 317 | + | arg1 = args[1].(domain.TokenHash) | |
| 318 | + | } | |
| 319 | + | var arg2 time.Time | |
| 320 | + | if args[2] != nil { | |
| 321 | + | arg2 = args[2].(time.Time) | |
| 322 | + | } | |
| 323 | + | run( | |
| 324 | + | arg0, | |
| 325 | + | arg1, | |
| 326 | + | arg2, | |
| 327 | + | ) | |
| 328 | + | }) | |
| 329 | + | return _c | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (_c *CIStore_FindJobByToken_Call) Return(cIJob domain.CIJob, err error) *CIStore_FindJobByToken_Call { | |
| 333 | + | _c.Call.Return(cIJob, err) | |
| 334 | + | return _c | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func (_c *CIStore_FindJobByToken_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error)) *CIStore_FindJobByToken_Call { | |
| 338 | + | _c.Call.Return(run) | |
| 339 | + | return _c | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // FindRunnerByToken provides a mock function for the type CIStore | |
| 343 | + | func (_mock *CIStore) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) { | |
| 344 | + | ret := _mock.Called(ctx, hash) | |
| 345 | + | ||
| 346 | + | if len(ret) == 0 { | |
| 347 | + | panic("no return value specified for FindRunnerByToken") | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | var r0 domain.CIRunner | |
| 351 | + | var r1 error | |
| 352 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.CIRunner, error)); ok { | |
| 353 | + | return returnFunc(ctx, hash) | |
| 354 | + | } | |
| 355 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.CIRunner); ok { | |
| 356 | + | r0 = returnFunc(ctx, hash) | |
| 357 | + | } else { | |
| 358 | + | r0 = ret.Get(0).(domain.CIRunner) | |
| 359 | + | } | |
| 360 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok { | |
| 361 | + | r1 = returnFunc(ctx, hash) | |
| 362 | + | } else { | |
| 363 | + | r1 = ret.Error(1) | |
| 364 | + | } | |
| 365 | + | return r0, r1 | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // CIStore_FindRunnerByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRunnerByToken' | |
| 369 | + | type CIStore_FindRunnerByToken_Call struct { | |
| 370 | + | *mock.Call | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // FindRunnerByToken is a helper method to define mock.On call | |
| 374 | + | // - ctx context.Context | |
| 375 | + | // - hash domain.TokenHash | |
| 376 | + | func (_e *CIStore_Expecter) FindRunnerByToken(ctx any, hash any) *CIStore_FindRunnerByToken_Call { | |
| 377 | + | return &CIStore_FindRunnerByToken_Call{Call: _e.mock.On("FindRunnerByToken", ctx, hash)} | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func (_c *CIStore_FindRunnerByToken_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *CIStore_FindRunnerByToken_Call { | |
| 381 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 382 | + | var arg0 context.Context | |
| 383 | + | if args[0] != nil { | |
| 384 | + | arg0 = args[0].(context.Context) | |
| 385 | + | } | |
| 386 | + | var arg1 domain.TokenHash | |
| 387 | + | if args[1] != nil { | |
| 388 | + | arg1 = args[1].(domain.TokenHash) | |
| 389 | + | } | |
| 390 | + | run( | |
| 391 | + | arg0, | |
| 392 | + | arg1, | |
| 393 | + | ) | |
| 394 | + | }) | |
| 395 | + | return _c | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (_c *CIStore_FindRunnerByToken_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_FindRunnerByToken_Call { | |
| 399 | + | _c.Call.Return(cIRunner, err) | |
| 400 | + | return _c | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error)) *CIStore_FindRunnerByToken_Call { | |
| 404 | + | _c.Call.Return(run) | |
| 405 | + | return _c | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // GetJob provides a mock function for the type CIStore | |
| 409 | + | func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | |
| 410 | + | ret := _mock.Called(ctx, runID, runAttempt, jobID) | |
| 411 | + | ||
| 412 | + | if len(ret) == 0 { | |
| 413 | + | panic("no return value specified for GetJob") | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | var r0 domain.CIJob | |
| 417 | + | var r1 error | |
| 418 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) (domain.CIJob, error)); ok { | |
| 419 | + | return returnFunc(ctx, runID, runAttempt, jobID) | |
| 420 | + | } | |
| 421 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) domain.CIJob); ok { | |
| 422 | + | r0 = returnFunc(ctx, runID, runAttempt, jobID) | |
| 423 | + | } else { | |
| 424 | + | r0 = ret.Get(0).(domain.CIJob) | |
| 425 | + | } | |
| 426 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok { | |
| 427 | + | r1 = returnFunc(ctx, runID, runAttempt, jobID) | |
| 428 | + | } else { | |
| 429 | + | r1 = ret.Error(1) | |
| 430 | + | } | |
| 431 | + | return r0, r1 | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // CIStore_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob' | |
| 435 | + | type CIStore_GetJob_Call struct { | |
| 436 | + | *mock.Call | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // GetJob is a helper method to define mock.On call | |
| 440 | + | // - ctx context.Context | |
| 441 | + | // - runID int64 | |
| 442 | + | // - runAttempt int32 | |
| 443 | + | // - jobID string | |
| 444 | + | func (_e *CIStore_Expecter) GetJob(ctx any, runID any, runAttempt any, jobID any) *CIStore_GetJob_Call { | |
| 445 | + | return &CIStore_GetJob_Call{Call: _e.mock.On("GetJob", ctx, runID, runAttempt, jobID)} | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *CIStore_GetJob_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32, jobID string)) *CIStore_GetJob_Call { | |
| 449 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 450 | + | var arg0 context.Context | |
| 451 | + | if args[0] != nil { | |
| 452 | + | arg0 = args[0].(context.Context) | |
| 453 | + | } | |
| 454 | + | var arg1 int64 | |
| 455 | + | if args[1] != nil { | |
| 456 | + | arg1 = args[1].(int64) | |
| 457 | + | } | |
| 458 | + | var arg2 int32 | |
| 459 | + | if args[2] != nil { | |
| 460 | + | arg2 = args[2].(int32) | |
| 461 | + | } | |
| 462 | + | var arg3 string | |
| 463 | + | if args[3] != nil { | |
| 464 | + | arg3 = args[3].(string) | |
| 465 | + | } | |
| 466 | + | run( | |
| 467 | + | arg0, | |
| 468 | + | arg1, | |
| 469 | + | arg2, | |
| 470 | + | arg3, | |
| 471 | + | ) | |
| 472 | + | }) | |
| 473 | + | return _c | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | func (_c *CIStore_GetJob_Call) Return(cIJob domain.CIJob, err error) *CIStore_GetJob_Call { | |
| 477 | + | _c.Call.Return(cIJob, err) | |
| 478 | + | return _c | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func (_c *CIStore_GetJob_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error)) *CIStore_GetJob_Call { | |
| 482 | + | _c.Call.Return(run) | |
| 483 | + | return _c | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // GetRun provides a mock function for the type CIStore | |
| 487 | + | func (_mock *CIStore) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) { | |
| 488 | + | ret := _mock.Called(ctx, repo, id) | |
| 489 | + | ||
| 490 | + | if len(ret) == 0 { | |
| 491 | + | panic("no return value specified for GetRun") | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | var r0 domain.CIRun | |
| 495 | + | var r1 error | |
| 496 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.CIRun, error)); ok { | |
| 497 | + | return returnFunc(ctx, repo, id) | |
| 498 | + | } | |
| 499 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.CIRun); ok { | |
| 500 | + | r0 = returnFunc(ctx, repo, id) | |
| 501 | + | } else { | |
| 502 | + | r0 = ret.Get(0).(domain.CIRun) | |
| 503 | + | } | |
| 504 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 505 | + | r1 = returnFunc(ctx, repo, id) | |
| 506 | + | } else { | |
| 507 | + | r1 = ret.Error(1) | |
| 508 | + | } | |
| 509 | + | return r0, r1 | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // CIStore_GetRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRun' | |
| 513 | + | type CIStore_GetRun_Call struct { | |
| 514 | + | *mock.Call | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | // GetRun is a helper method to define mock.On call | |
| 518 | + | // - ctx context.Context | |
| 519 | + | // - repo domain.RepoName | |
| 520 | + | // - id int64 | |
| 521 | + | func (_e *CIStore_Expecter) GetRun(ctx any, repo any, id any) *CIStore_GetRun_Call { | |
| 522 | + | return &CIStore_GetRun_Call{Call: _e.mock.On("GetRun", ctx, repo, id)} | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func (_c *CIStore_GetRun_Call) Run(run func(ctx context.Context, repo domain.RepoName, id int64)) *CIStore_GetRun_Call { | |
| 526 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 527 | + | var arg0 context.Context | |
| 528 | + | if args[0] != nil { | |
| 529 | + | arg0 = args[0].(context.Context) | |
| 530 | + | } | |
| 531 | + | var arg1 domain.RepoName | |
| 532 | + | if args[1] != nil { | |
| 533 | + | arg1 = args[1].(domain.RepoName) | |
| 534 | + | } | |
| 535 | + | var arg2 int64 | |
| 536 | + | if args[2] != nil { | |
| 537 | + | arg2 = args[2].(int64) | |
| 538 | + | } | |
| 539 | + | run( | |
| 540 | + | arg0, | |
| 541 | + | arg1, | |
| 542 | + | arg2, | |
| 543 | + | ) | |
| 544 | + | }) | |
| 545 | + | return _c | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func (_c *CIStore_GetRun_Call) Return(cIRun domain.CIRun, err error) *CIStore_GetRun_Call { | |
| 549 | + | _c.Call.Return(cIRun, err) | |
| 550 | + | return _c | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func (_c *CIStore_GetRun_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)) *CIStore_GetRun_Call { | |
| 554 | + | _c.Call.Return(run) | |
| 555 | + | return _c | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // GetRunSource provides a mock function for the type CIStore | |
| 559 | + | func (_mock *CIStore) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) { | |
| 560 | + | ret := _mock.Called(ctx, runID) | |
| 561 | + | ||
| 562 | + | if len(ret) == 0 { | |
| 563 | + | panic("no return value specified for GetRunSource") | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | var r0 domain.CIRunSource | |
| 567 | + | var r1 error | |
| 568 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.CIRunSource, error)); ok { | |
| 569 | + | return returnFunc(ctx, runID) | |
| 570 | + | } | |
| 571 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.CIRunSource); ok { | |
| 572 | + | r0 = returnFunc(ctx, runID) | |
| 573 | + | } else { | |
| 574 | + | r0 = ret.Get(0).(domain.CIRunSource) | |
| 575 | + | } | |
| 576 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok { | |
| 577 | + | r1 = returnFunc(ctx, runID) | |
| 578 | + | } else { | |
| 579 | + | r1 = ret.Error(1) | |
| 580 | + | } | |
| 581 | + | return r0, r1 | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // CIStore_GetRunSource_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRunSource' | |
| 585 | + | type CIStore_GetRunSource_Call struct { | |
| 586 | + | *mock.Call | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | // GetRunSource is a helper method to define mock.On call | |
| 590 | + | // - ctx context.Context | |
| 591 | + | // - runID int64 | |
| 592 | + | func (_e *CIStore_Expecter) GetRunSource(ctx any, runID any) *CIStore_GetRunSource_Call { | |
| 593 | + | return &CIStore_GetRunSource_Call{Call: _e.mock.On("GetRunSource", ctx, runID)} | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | func (_c *CIStore_GetRunSource_Call) Run(run func(ctx context.Context, runID int64)) *CIStore_GetRunSource_Call { | |
| 597 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 598 | + | var arg0 context.Context | |
| 599 | + | if args[0] != nil { | |
| 600 | + | arg0 = args[0].(context.Context) | |
| 601 | + | } | |
| 602 | + | var arg1 int64 | |
| 603 | + | if args[1] != nil { | |
| 604 | + | arg1 = args[1].(int64) | |
| 605 | + | } | |
| 606 | + | run( | |
| 607 | + | arg0, | |
| 608 | + | arg1, | |
| 609 | + | ) | |
| 610 | + | }) | |
| 611 | + | return _c | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | func (_c *CIStore_GetRunSource_Call) Return(cIRunSource domain.CIRunSource, err error) *CIStore_GetRunSource_Call { | |
| 615 | + | _c.Call.Return(cIRunSource, err) | |
| 616 | + | return _c | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | func (_c *CIStore_GetRunSource_Call) RunAndReturn(run func(ctx context.Context, runID int64) (domain.CIRunSource, error)) *CIStore_GetRunSource_Call { | |
| 620 | + | _c.Call.Return(run) | |
| 621 | + | return _c | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | // GetRunner provides a mock function for the type CIStore | |
| 625 | + | func (_mock *CIStore) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) { | |
| 626 | + | ret := _mock.Called(ctx, id) | |
| 627 | + | ||
| 628 | + | if len(ret) == 0 { | |
| 629 | + | panic("no return value specified for GetRunner") | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | var r0 domain.CIRunner | |
| 633 | + | var r1 error | |
| 634 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRunner, error)); ok { | |
| 635 | + | return returnFunc(ctx, id) | |
| 636 | + | } | |
| 637 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRunner); ok { | |
| 638 | + | r0 = returnFunc(ctx, id) | |
| 639 | + | } else { | |
| 640 | + | r0 = ret.Get(0).(domain.CIRunner) | |
| 641 | + | } | |
| 642 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 643 | + | r1 = returnFunc(ctx, id) | |
| 644 | + | } else { | |
| 645 | + | r1 = ret.Error(1) | |
| 646 | + | } | |
| 647 | + | return r0, r1 | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | // CIStore_GetRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRunner' | |
| 651 | + | type CIStore_GetRunner_Call struct { | |
| 652 | + | *mock.Call | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | // GetRunner is a helper method to define mock.On call | |
| 656 | + | // - ctx context.Context | |
| 657 | + | // - id string | |
| 658 | + | func (_e *CIStore_Expecter) GetRunner(ctx any, id any) *CIStore_GetRunner_Call { | |
| 659 | + | return &CIStore_GetRunner_Call{Call: _e.mock.On("GetRunner", ctx, id)} | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | func (_c *CIStore_GetRunner_Call) Run(run func(ctx context.Context, id string)) *CIStore_GetRunner_Call { | |
| 663 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 664 | + | var arg0 context.Context | |
| 665 | + | if args[0] != nil { | |
| 666 | + | arg0 = args[0].(context.Context) | |
| 667 | + | } | |
| 668 | + | var arg1 string | |
| 669 | + | if args[1] != nil { | |
| 670 | + | arg1 = args[1].(string) | |
| 671 | + | } | |
| 672 | + | run( | |
| 673 | + | arg0, | |
| 674 | + | arg1, | |
| 675 | + | ) | |
| 676 | + | }) | |
| 677 | + | return _c | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func (_c *CIStore_GetRunner_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_GetRunner_Call { | |
| 681 | + | _c.Call.Return(cIRunner, err) | |
| 682 | + | return _c | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRunner, error)) *CIStore_GetRunner_Call { | |
| 686 | + | _c.Call.Return(run) | |
| 687 | + | return _c | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | // ListActiveJobs provides a mock function for the type CIStore | |
| 691 | + | func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) { | |
| 692 | + | ret := _mock.Called(ctx) | |
| 693 | + | ||
| 694 | + | if len(ret) == 0 { | |
| 695 | + | panic("no return value specified for ListActiveJobs") | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | var r0 []domain.CIJob | |
| 699 | + | var r1 error | |
| 700 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIJob, error)); ok { | |
| 701 | + | return returnFunc(ctx) | |
| 702 | + | } | |
| 703 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIJob); ok { | |
| 704 | + | r0 = returnFunc(ctx) | |
| 705 | + | } else { | |
| 706 | + | if ret.Get(0) != nil { | |
| 707 | + | r0 = ret.Get(0).([]domain.CIJob) | |
| 708 | + | } | |
| 709 | + | } | |
| 710 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 711 | + | r1 = returnFunc(ctx) | |
| 712 | + | } else { | |
| 713 | + | r1 = ret.Error(1) | |
| 714 | + | } | |
| 715 | + | return r0, r1 | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | // CIStore_ListActiveJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListActiveJobs' | |
| 719 | + | type CIStore_ListActiveJobs_Call struct { | |
| 720 | + | *mock.Call | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | // ListActiveJobs is a helper method to define mock.On call | |
| 724 | + | // - ctx context.Context | |
| 725 | + | func (_e *CIStore_Expecter) ListActiveJobs(ctx any) *CIStore_ListActiveJobs_Call { | |
| 726 | + | return &CIStore_ListActiveJobs_Call{Call: _e.mock.On("ListActiveJobs", ctx)} | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | func (_c *CIStore_ListActiveJobs_Call) Run(run func(ctx context.Context)) *CIStore_ListActiveJobs_Call { | |
| 730 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 731 | + | var arg0 context.Context | |
| 732 | + | if args[0] != nil { | |
| 733 | + | arg0 = args[0].(context.Context) | |
| 734 | + | } | |
| 735 | + | run( | |
| 736 | + | arg0, | |
| 737 | + | ) | |
| 738 | + | }) | |
| 739 | + | return _c | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | func (_c *CIStore_ListActiveJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListActiveJobs_Call { | |
| 743 | + | _c.Call.Return(cIJobs, err) | |
| 744 | + | return _c | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | func (_c *CIStore_ListActiveJobs_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIJob, error)) *CIStore_ListActiveJobs_Call { | |
| 748 | + | _c.Call.Return(run) | |
| 749 | + | return _c | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // ListChecks provides a mock function for the type CIStore | |
| 753 | + | func (_mock *CIStore) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) { | |
| 754 | + | ret := _mock.Called(ctx, repo, sha, limit) | |
| 755 | + | ||
| 756 | + | if len(ret) == 0 { | |
| 757 | + | panic("no return value specified for ListChecks") | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | var r0 []domain.CICheck | |
| 761 | + | var r1 bool | |
| 762 | + | var r2 error | |
| 763 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, int) ([]domain.CICheck, bool, error)); ok { | |
| 764 | + | return returnFunc(ctx, repo, sha, limit) | |
| 765 | + | } | |
| 766 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, int) []domain.CICheck); ok { | |
| 767 | + | r0 = returnFunc(ctx, repo, sha, limit) | |
| 768 | + | } else { | |
| 769 | + | if ret.Get(0) != nil { | |
| 770 | + | r0 = ret.Get(0).([]domain.CICheck) | |
| 771 | + | } | |
| 772 | + | } | |
| 773 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string, int) bool); ok { | |
| 774 | + | r1 = returnFunc(ctx, repo, sha, limit) | |
| 775 | + | } else { | |
| 776 | + | r1 = ret.Get(1).(bool) | |
| 777 | + | } | |
| 778 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, string, int) error); ok { | |
| 779 | + | r2 = returnFunc(ctx, repo, sha, limit) | |
| 780 | + | } else { | |
| 781 | + | r2 = ret.Error(2) | |
| 782 | + | } | |
| 783 | + | return r0, r1, r2 | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | // CIStore_ListChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListChecks' | |
| 787 | + | type CIStore_ListChecks_Call struct { | |
| 788 | + | *mock.Call | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | // ListChecks is a helper method to define mock.On call | |
| 792 | + | // - ctx context.Context | |
| 793 | + | // - repo domain.RepoName | |
| 794 | + | // - sha string | |
| 795 | + | // - limit int | |
| 796 | + | func (_e *CIStore_Expecter) ListChecks(ctx any, repo any, sha any, limit any) *CIStore_ListChecks_Call { | |
| 797 | + | return &CIStore_ListChecks_Call{Call: _e.mock.On("ListChecks", ctx, repo, sha, limit)} | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | func (_c *CIStore_ListChecks_Call) Run(run func(ctx context.Context, repo domain.RepoName, sha string, limit int)) *CIStore_ListChecks_Call { | |
| 801 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 802 | + | var arg0 context.Context | |
| 803 | + | if args[0] != nil { | |
| 804 | + | arg0 = args[0].(context.Context) | |
| 805 | + | } | |
| 806 | + | var arg1 domain.RepoName | |
| 807 | + | if args[1] != nil { | |
| 808 | + | arg1 = args[1].(domain.RepoName) | |
| 809 | + | } | |
| 810 | + | var arg2 string | |
| 811 | + | if args[2] != nil { | |
| 812 | + | arg2 = args[2].(string) | |
| 813 | + | } | |
| 814 | + | var arg3 int | |
| 815 | + | if args[3] != nil { | |
| 816 | + | arg3 = args[3].(int) | |
| 817 | + | } | |
| 818 | + | run( | |
| 819 | + | arg0, | |
| 820 | + | arg1, | |
| 821 | + | arg2, | |
| 822 | + | arg3, | |
| 823 | + | ) | |
| 824 | + | }) | |
| 825 | + | return _c | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | func (_c *CIStore_ListChecks_Call) Return(checks []domain.CICheck, more bool, err error) *CIStore_ListChecks_Call { | |
| 829 | + | _c.Call.Return(checks, more, err) | |
| 830 | + | return _c | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func (_c *CIStore_ListChecks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error)) *CIStore_ListChecks_Call { | |
| 834 | + | _c.Call.Return(run) | |
| 835 | + | return _c | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | // ListJobs provides a mock function for the type CIStore | |
| 839 | + | func (_mock *CIStore) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) { | |
| 840 | + | ret := _mock.Called(ctx, runID, runAttempt) | |
| 841 | + | ||
| 842 | + | if len(ret) == 0 { | |
| 843 | + | panic("no return value specified for ListJobs") | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | var r0 []domain.CIJob | |
| 847 | + | var r1 error | |
| 848 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32) ([]domain.CIJob, error)); ok { | |
| 849 | + | return returnFunc(ctx, runID, runAttempt) | |
| 850 | + | } | |
| 851 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32) []domain.CIJob); ok { | |
| 852 | + | r0 = returnFunc(ctx, runID, runAttempt) | |
| 853 | + | } else { | |
| 854 | + | if ret.Get(0) != nil { | |
| 855 | + | r0 = ret.Get(0).([]domain.CIJob) | |
| 856 | + | } | |
| 857 | + | } | |
| 858 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32) error); ok { | |
| 859 | + | r1 = returnFunc(ctx, runID, runAttempt) | |
| 860 | + | } else { | |
| 861 | + | r1 = ret.Error(1) | |
| 862 | + | } | |
| 863 | + | return r0, r1 | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | // CIStore_ListJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListJobs' | |
| 867 | + | type CIStore_ListJobs_Call struct { | |
| 868 | + | *mock.Call | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | // ListJobs is a helper method to define mock.On call | |
| 872 | + | // - ctx context.Context | |
| 873 | + | // - runID int64 | |
| 874 | + | // - runAttempt int32 | |
| 875 | + | func (_e *CIStore_Expecter) ListJobs(ctx any, runID any, runAttempt any) *CIStore_ListJobs_Call { | |
| 876 | + | return &CIStore_ListJobs_Call{Call: _e.mock.On("ListJobs", ctx, runID, runAttempt)} | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | func (_c *CIStore_ListJobs_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32)) *CIStore_ListJobs_Call { | |
| 880 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 881 | + | var arg0 context.Context | |
| 882 | + | if args[0] != nil { | |
| 883 | + | arg0 = args[0].(context.Context) | |
| 884 | + | } | |
| 885 | + | var arg1 int64 | |
| 886 | + | if args[1] != nil { | |
| 887 | + | arg1 = args[1].(int64) | |
| 888 | + | } | |
| 889 | + | var arg2 int32 | |
| 890 | + | if args[2] != nil { | |
| 891 | + | arg2 = args[2].(int32) | |
| 892 | + | } | |
| 893 | + | run( | |
| 894 | + | arg0, | |
| 895 | + | arg1, | |
| 896 | + | arg2, | |
| 897 | + | ) | |
| 898 | + | }) | |
| 899 | + | return _c | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | func (_c *CIStore_ListJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListJobs_Call { | |
| 903 | + | _c.Call.Return(cIJobs, err) | |
| 904 | + | return _c | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | func (_c *CIStore_ListJobs_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error)) *CIStore_ListJobs_Call { | |
| 908 | + | _c.Call.Return(run) | |
| 909 | + | return _c | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | // ListOpenRuns provides a mock function for the type CIStore | |
| 913 | + | func (_mock *CIStore) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) { | |
| 914 | + | ret := _mock.Called(ctx) | |
| 915 | + | ||
| 916 | + | if len(ret) == 0 { | |
| 917 | + | panic("no return value specified for ListOpenRuns") | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | var r0 []domain.CIRun | |
| 921 | + | var r1 error | |
| 922 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIRun, error)); ok { | |
| 923 | + | return returnFunc(ctx) | |
| 924 | + | } | |
| 925 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIRun); ok { | |
| 926 | + | r0 = returnFunc(ctx) | |
| 927 | + | } else { | |
| 928 | + | if ret.Get(0) != nil { | |
| 929 | + | r0 = ret.Get(0).([]domain.CIRun) | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 933 | + | r1 = returnFunc(ctx) | |
| 934 | + | } else { | |
| 935 | + | r1 = ret.Error(1) | |
| 936 | + | } | |
| 937 | + | return r0, r1 | |
| 938 | + | } | |
| 939 | + | ||
| 940 | + | // CIStore_ListOpenRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenRuns' | |
| 941 | + | type CIStore_ListOpenRuns_Call struct { | |
| 942 | + | *mock.Call | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | // ListOpenRuns is a helper method to define mock.On call | |
| 946 | + | // - ctx context.Context | |
| 947 | + | func (_e *CIStore_Expecter) ListOpenRuns(ctx any) *CIStore_ListOpenRuns_Call { | |
| 948 | + | return &CIStore_ListOpenRuns_Call{Call: _e.mock.On("ListOpenRuns", ctx)} | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | func (_c *CIStore_ListOpenRuns_Call) Run(run func(ctx context.Context)) *CIStore_ListOpenRuns_Call { | |
| 952 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 953 | + | var arg0 context.Context | |
| 954 | + | if args[0] != nil { | |
| 955 | + | arg0 = args[0].(context.Context) | |
| 956 | + | } | |
| 957 | + | run( | |
| 958 | + | arg0, | |
| 959 | + | ) | |
| 960 | + | }) | |
| 961 | + | return _c | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | func (_c *CIStore_ListOpenRuns_Call) Return(cIRuns []domain.CIRun, err error) *CIStore_ListOpenRuns_Call { | |
| 965 | + | _c.Call.Return(cIRuns, err) | |
| 966 | + | return _c | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | func (_c *CIStore_ListOpenRuns_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIRun, error)) *CIStore_ListOpenRuns_Call { | |
| 970 | + | _c.Call.Return(run) | |
| 971 | + | return _c | |
| 972 | + | } | |
| 973 | + | ||
| 974 | + | // ListReadyJobs provides a mock function for the type CIStore | |
| 975 | + | func (_mock *CIStore) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) { | |
| 976 | + | ret := _mock.Called(ctx, limit) | |
| 977 | + | ||
| 978 | + | if len(ret) == 0 { | |
| 979 | + | panic("no return value specified for ListReadyJobs") | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | var r0 []domain.CIJob | |
| 983 | + | var r1 error | |
| 984 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]domain.CIJob, error)); ok { | |
| 985 | + | return returnFunc(ctx, limit) | |
| 986 | + | } | |
| 987 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []domain.CIJob); ok { | |
| 988 | + | r0 = returnFunc(ctx, limit) | |
| 989 | + | } else { | |
| 990 | + | if ret.Get(0) != nil { | |
| 991 | + | r0 = ret.Get(0).([]domain.CIJob) | |
| 992 | + | } | |
| 993 | + | } | |
| 994 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 995 | + | r1 = returnFunc(ctx, limit) | |
| 996 | + | } else { | |
| 997 | + | r1 = ret.Error(1) | |
| 998 | + | } | |
| 999 | + | return r0, r1 | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | // CIStore_ListReadyJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReadyJobs' | |
| 1003 | + | type CIStore_ListReadyJobs_Call struct { | |
| 1004 | + | *mock.Call | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | // ListReadyJobs is a helper method to define mock.On call | |
| 1008 | + | // - ctx context.Context | |
| 1009 | + | // - limit int | |
| 1010 | + | func (_e *CIStore_Expecter) ListReadyJobs(ctx any, limit any) *CIStore_ListReadyJobs_Call { | |
| 1011 | + | return &CIStore_ListReadyJobs_Call{Call: _e.mock.On("ListReadyJobs", ctx, limit)} | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func (_c *CIStore_ListReadyJobs_Call) Run(run func(ctx context.Context, limit int)) *CIStore_ListReadyJobs_Call { | |
| 1015 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1016 | + | var arg0 context.Context | |
| 1017 | + | if args[0] != nil { | |
| 1018 | + | arg0 = args[0].(context.Context) | |
| 1019 | + | } | |
| 1020 | + | var arg1 int | |
| 1021 | + | if args[1] != nil { | |
| 1022 | + | arg1 = args[1].(int) | |
| 1023 | + | } | |
| 1024 | + | run( | |
| 1025 | + | arg0, | |
| 1026 | + | arg1, | |
| 1027 | + | ) | |
| 1028 | + | }) | |
| 1029 | + | return _c | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | func (_c *CIStore_ListReadyJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListReadyJobs_Call { | |
| 1033 | + | _c.Call.Return(cIJobs, err) | |
| 1034 | + | return _c | |
| 1035 | + | } | |
| 1036 | + | ||
| 1037 | + | func (_c *CIStore_ListReadyJobs_Call) RunAndReturn(run func(ctx context.Context, limit int) ([]domain.CIJob, error)) *CIStore_ListReadyJobs_Call { | |
| 1038 | + | _c.Call.Return(run) | |
| 1039 | + | return _c | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | // ListRunners provides a mock function for the type CIStore | |
| 1043 | + | func (_mock *CIStore) ListRunners(ctx context.Context) ([]domain.CIRunner, error) { | |
| 1044 | + | ret := _mock.Called(ctx) | |
| 1045 | + | ||
| 1046 | + | if len(ret) == 0 { | |
| 1047 | + | panic("no return value specified for ListRunners") | |
| 1048 | + | } | |
| 1049 | + | ||
| 1050 | + | var r0 []domain.CIRunner | |
| 1051 | + | var r1 error | |
| 1052 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIRunner, error)); ok { | |
| 1053 | + | return returnFunc(ctx) | |
| 1054 | + | } | |
| 1055 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIRunner); ok { | |
| 1056 | + | r0 = returnFunc(ctx) | |
| 1057 | + | } else { | |
| 1058 | + | if ret.Get(0) != nil { | |
| 1059 | + | r0 = ret.Get(0).([]domain.CIRunner) | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1063 | + | r1 = returnFunc(ctx) | |
| 1064 | + | } else { | |
| 1065 | + | r1 = ret.Error(1) | |
| 1066 | + | } | |
| 1067 | + | return r0, r1 | |
| 1068 | + | } | |
| 1069 | + | ||
| 1070 | + | // CIStore_ListRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRunners' | |
| 1071 | + | type CIStore_ListRunners_Call struct { | |
| 1072 | + | *mock.Call | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | // ListRunners is a helper method to define mock.On call | |
| 1076 | + | // - ctx context.Context | |
| 1077 | + | func (_e *CIStore_Expecter) ListRunners(ctx any) *CIStore_ListRunners_Call { | |
| 1078 | + | return &CIStore_ListRunners_Call{Call: _e.mock.On("ListRunners", ctx)} | |
| 1079 | + | } | |
| 1080 | + | ||
| 1081 | + | func (_c *CIStore_ListRunners_Call) Run(run func(ctx context.Context)) *CIStore_ListRunners_Call { | |
| 1082 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1083 | + | var arg0 context.Context | |
| 1084 | + | if args[0] != nil { | |
| 1085 | + | arg0 = args[0].(context.Context) | |
| 1086 | + | } | |
| 1087 | + | run( | |
| 1088 | + | arg0, | |
| 1089 | + | ) | |
| 1090 | + | }) | |
| 1091 | + | return _c | |
| 1092 | + | } | |
| 1093 | + | ||
| 1094 | + | func (_c *CIStore_ListRunners_Call) Return(cIRunners []domain.CIRunner, err error) *CIStore_ListRunners_Call { | |
| 1095 | + | _c.Call.Return(cIRunners, err) | |
| 1096 | + | return _c | |
| 1097 | + | } | |
| 1098 | + | ||
| 1099 | + | func (_c *CIStore_ListRunners_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIRunner, error)) *CIStore_ListRunners_Call { | |
| 1100 | + | _c.Call.Return(run) | |
| 1101 | + | return _c | |
| 1102 | + | } | |
| 1103 | + | ||
| 1104 | + | // ListRuns provides a mock function for the type CIStore | |
| 1105 | + | func (_mock *CIStore) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) { | |
| 1106 | + | ret := _mock.Called(ctx, repo, before, limit) | |
| 1107 | + | ||
| 1108 | + | if len(ret) == 0 { | |
| 1109 | + | panic("no return value specified for ListRuns") | |
| 1110 | + | } | |
| 1111 | + | ||
| 1112 | + | var r0 []domain.CIRun | |
| 1113 | + | var r1 error | |
| 1114 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.CIRun, error)); ok { | |
| 1115 | + | return returnFunc(ctx, repo, before, limit) | |
| 1116 | + | } | |
| 1117 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.CIRun); ok { | |
| 1118 | + | r0 = returnFunc(ctx, repo, before, limit) | |
| 1119 | + | } else { | |
| 1120 | + | if ret.Get(0) != nil { | |
| 1121 | + | r0 = ret.Get(0).([]domain.CIRun) | |
| 1122 | + | } | |
| 1123 | + | } | |
| 1124 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok { | |
| 1125 | + | r1 = returnFunc(ctx, repo, before, limit) | |
| 1126 | + | } else { | |
| 1127 | + | r1 = ret.Error(1) | |
| 1128 | + | } | |
| 1129 | + | return r0, r1 | |
| 1130 | + | } | |
| 1131 | + | ||
| 1132 | + | // CIStore_ListRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRuns' | |
| 1133 | + | type CIStore_ListRuns_Call struct { | |
| 1134 | + | *mock.Call | |
| 1135 | + | } | |
| 1136 | + | ||
| 1137 | + | // ListRuns is a helper method to define mock.On call | |
| 1138 | + | // - ctx context.Context | |
| 1139 | + | // - repo domain.RepoName | |
| 1140 | + | // - before int64 | |
| 1141 | + | // - limit int | |
| 1142 | + | func (_e *CIStore_Expecter) ListRuns(ctx any, repo any, before any, limit any) *CIStore_ListRuns_Call { | |
| 1143 | + | return &CIStore_ListRuns_Call{Call: _e.mock.On("ListRuns", ctx, repo, before, limit)} | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | func (_c *CIStore_ListRuns_Call) Run(run func(ctx context.Context, repo domain.RepoName, before int64, limit int)) *CIStore_ListRuns_Call { | |
| 1147 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1148 | + | var arg0 context.Context | |
| 1149 | + | if args[0] != nil { | |
| 1150 | + | arg0 = args[0].(context.Context) | |
| 1151 | + | } | |
| 1152 | + | var arg1 domain.RepoName | |
| 1153 | + | if args[1] != nil { | |
| 1154 | + | arg1 = args[1].(domain.RepoName) | |
| 1155 | + | } | |
| 1156 | + | var arg2 int64 | |
| 1157 | + | if args[2] != nil { | |
| 1158 | + | arg2 = args[2].(int64) | |
| 1159 | + | } | |
| 1160 | + | var arg3 int | |
| 1161 | + | if args[3] != nil { | |
| 1162 | + | arg3 = args[3].(int) | |
| 1163 | + | } | |
| 1164 | + | run( | |
| 1165 | + | arg0, | |
| 1166 | + | arg1, | |
| 1167 | + | arg2, | |
| 1168 | + | arg3, | |
| 1169 | + | ) | |
| 1170 | + | }) | |
| 1171 | + | return _c | |
| 1172 | + | } | |
| 1173 | + | ||
| 1174 | + | func (_c *CIStore_ListRuns_Call) Return(cIRuns []domain.CIRun, err error) *CIStore_ListRuns_Call { | |
| 1175 | + | _c.Call.Return(cIRuns, err) | |
| 1176 | + | return _c | |
| 1177 | + | } | |
| 1178 | + | ||
| 1179 | + | func (_c *CIStore_ListRuns_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error)) *CIStore_ListRuns_Call { | |
| 1180 | + | _c.Call.Return(run) | |
| 1181 | + | return _c | |
| 1182 | + | } | |
| 1183 | + | ||
| 1184 | + | // RemoveRunner provides a mock function for the type CIStore | |
| 1185 | + | func (_mock *CIStore) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) { | |
| 1186 | + | ret := _mock.Called(ctx, id) | |
| 1187 | + | ||
| 1188 | + | if len(ret) == 0 { | |
| 1189 | + | panic("no return value specified for RemoveRunner") | |
| 1190 | + | } | |
| 1191 | + | ||
| 1192 | + | var r0 domain.CIRunner | |
| 1193 | + | var r1 error | |
| 1194 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRunner, error)); ok { | |
| 1195 | + | return returnFunc(ctx, id) | |
| 1196 | + | } | |
| 1197 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRunner); ok { | |
| 1198 | + | r0 = returnFunc(ctx, id) | |
| 1199 | + | } else { | |
| 1200 | + | r0 = ret.Get(0).(domain.CIRunner) | |
| 1201 | + | } | |
| 1202 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 1203 | + | r1 = returnFunc(ctx, id) | |
| 1204 | + | } else { | |
| 1205 | + | r1 = ret.Error(1) | |
| 1206 | + | } | |
| 1207 | + | return r0, r1 | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | // CIStore_RemoveRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRunner' | |
| 1211 | + | type CIStore_RemoveRunner_Call struct { | |
| 1212 | + | *mock.Call | |
| 1213 | + | } | |
| 1214 | + | ||
| 1215 | + | // RemoveRunner is a helper method to define mock.On call | |
| 1216 | + | // - ctx context.Context | |
| 1217 | + | // - id string | |
| 1218 | + | func (_e *CIStore_Expecter) RemoveRunner(ctx any, id any) *CIStore_RemoveRunner_Call { | |
| 1219 | + | return &CIStore_RemoveRunner_Call{Call: _e.mock.On("RemoveRunner", ctx, id)} | |
| 1220 | + | } | |
| 1221 | + | ||
| 1222 | + | func (_c *CIStore_RemoveRunner_Call) Run(run func(ctx context.Context, id string)) *CIStore_RemoveRunner_Call { | |
| 1223 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1224 | + | var arg0 context.Context | |
| 1225 | + | if args[0] != nil { | |
| 1226 | + | arg0 = args[0].(context.Context) | |
| 1227 | + | } | |
| 1228 | + | var arg1 string | |
| 1229 | + | if args[1] != nil { | |
| 1230 | + | arg1 = args[1].(string) | |
| 1231 | + | } | |
| 1232 | + | run( | |
| 1233 | + | arg0, | |
| 1234 | + | arg1, | |
| 1235 | + | ) | |
| 1236 | + | }) | |
| 1237 | + | return _c | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | func (_c *CIStore_RemoveRunner_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_RemoveRunner_Call { | |
| 1241 | + | _c.Call.Return(cIRunner, err) | |
| 1242 | + | return _c | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | func (_c *CIStore_RemoveRunner_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRunner, error)) *CIStore_RemoveRunner_Call { | |
| 1246 | + | _c.Call.Return(run) | |
| 1247 | + | return _c | |
| 1248 | + | } | |
| 1249 | + | ||
| 1250 | + | // RunIDs provides a mock function for the type CIStore | |
| 1251 | + | func (_mock *CIStore) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) { | |
| 1252 | + | ret := _mock.Called(ctx, repo) | |
| 1253 | + | ||
| 1254 | + | if len(ret) == 0 { | |
| 1255 | + | panic("no return value specified for RunIDs") | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | var r0 []int64 | |
| 1259 | + | var r1 error | |
| 1260 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]int64, error)); ok { | |
| 1261 | + | return returnFunc(ctx, repo) | |
| 1262 | + | } | |
| 1263 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []int64); ok { | |
| 1264 | + | r0 = returnFunc(ctx, repo) | |
| 1265 | + | } else { | |
| 1266 | + | if ret.Get(0) != nil { | |
| 1267 | + | r0 = ret.Get(0).([]int64) | |
| 1268 | + | } | |
| 1269 | + | } | |
| 1270 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 1271 | + | r1 = returnFunc(ctx, repo) | |
| 1272 | + | } else { | |
| 1273 | + | r1 = ret.Error(1) | |
| 1274 | + | } | |
| 1275 | + | return r0, r1 | |
| 1276 | + | } | |
| 1277 | + | ||
| 1278 | + | // CIStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs' | |
| 1279 | + | type CIStore_RunIDs_Call struct { | |
| 1280 | + | *mock.Call | |
| 1281 | + | } | |
| 1282 | + | ||
| 1283 | + | // RunIDs is a helper method to define mock.On call | |
| 1284 | + | // - ctx context.Context | |
| 1285 | + | // - repo domain.RepoName | |
| 1286 | + | func (_e *CIStore_Expecter) RunIDs(ctx any, repo any) *CIStore_RunIDs_Call { | |
| 1287 | + | return &CIStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx, repo)} | |
| 1288 | + | } | |
| 1289 | + | ||
| 1290 | + | func (_c *CIStore_RunIDs_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIStore_RunIDs_Call { | |
| 1291 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1292 | + | var arg0 context.Context | |
| 1293 | + | if args[0] != nil { | |
| 1294 | + | arg0 = args[0].(context.Context) | |
| 1295 | + | } | |
| 1296 | + | var arg1 domain.RepoName | |
| 1297 | + | if args[1] != nil { | |
| 1298 | + | arg1 = args[1].(domain.RepoName) | |
| 1299 | + | } | |
| 1300 | + | run( | |
| 1301 | + | arg0, | |
| 1302 | + | arg1, | |
| 1303 | + | ) | |
| 1304 | + | }) | |
| 1305 | + | return _c | |
| 1306 | + | } | |
| 1307 | + | ||
| 1308 | + | func (_c *CIStore_RunIDs_Call) Return(int64s []int64, err error) *CIStore_RunIDs_Call { | |
| 1309 | + | _c.Call.Return(int64s, err) | |
| 1310 | + | return _c | |
| 1311 | + | } | |
| 1312 | + | ||
| 1313 | + | func (_c *CIStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]int64, error)) *CIStore_RunIDs_Call { | |
| 1314 | + | _c.Call.Return(run) | |
| 1315 | + | return _c | |
| 1316 | + | } | |
| 1317 | + | ||
| 1318 | + | // Sweep provides a mock function for the type CIStore | |
| 1319 | + | func (_mock *CIStore) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | |
| 1320 | + | ret := _mock.Called(ctx, before) | |
| 1321 | + | ||
| 1322 | + | if len(ret) == 0 { | |
| 1323 | + | panic("no return value specified for Sweep") | |
| 1324 | + | } | |
| 1325 | + | ||
| 1326 | + | var r0 []int64 | |
| 1327 | + | var r1 error | |
| 1328 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) ([]int64, error)); ok { | |
| 1329 | + | return returnFunc(ctx, before) | |
| 1330 | + | } | |
| 1331 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) []int64); ok { | |
| 1332 | + | r0 = returnFunc(ctx, before) | |
| 1333 | + | } else { | |
| 1334 | + | if ret.Get(0) != nil { | |
| 1335 | + | r0 = ret.Get(0).([]int64) | |
| 1336 | + | } | |
| 1337 | + | } | |
| 1338 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |
| 1339 | + | r1 = returnFunc(ctx, before) | |
| 1340 | + | } else { | |
| 1341 | + | r1 = ret.Error(1) | |
| 1342 | + | } | |
| 1343 | + | return r0, r1 | |
| 1344 | + | } | |
| 1345 | + | ||
| 1346 | + | // CIStore_Sweep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sweep' | |
| 1347 | + | type CIStore_Sweep_Call struct { | |
| 1348 | + | *mock.Call | |
| 1349 | + | } | |
| 1350 | + | ||
| 1351 | + | // Sweep is a helper method to define mock.On call | |
| 1352 | + | // - ctx context.Context | |
| 1353 | + | // - before time.Time | |
| 1354 | + | func (_e *CIStore_Expecter) Sweep(ctx any, before any) *CIStore_Sweep_Call { | |
| 1355 | + | return &CIStore_Sweep_Call{Call: _e.mock.On("Sweep", ctx, before)} | |
| 1356 | + | } | |
| 1357 | + | ||
| 1358 | + | func (_c *CIStore_Sweep_Call) Run(run func(ctx context.Context, before time.Time)) *CIStore_Sweep_Call { | |
| 1359 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1360 | + | var arg0 context.Context | |
| 1361 | + | if args[0] != nil { | |
| 1362 | + | arg0 = args[0].(context.Context) | |
| 1363 | + | } | |
| 1364 | + | var arg1 time.Time | |
| 1365 | + | if args[1] != nil { | |
| 1366 | + | arg1 = args[1].(time.Time) | |
| 1367 | + | } | |
| 1368 | + | run( | |
| 1369 | + | arg0, | |
| 1370 | + | arg1, | |
| 1371 | + | ) | |
| 1372 | + | }) | |
| 1373 | + | return _c | |
| 1374 | + | } | |
| 1375 | + | ||
| 1376 | + | func (_c *CIStore_Sweep_Call) Return(int64s []int64, err error) *CIStore_Sweep_Call { | |
| 1377 | + | _c.Call.Return(int64s, err) | |
| 1378 | + | return _c | |
| 1379 | + | } | |
| 1380 | + | ||
| 1381 | + | func (_c *CIStore_Sweep_Call) RunAndReturn(run func(ctx context.Context, before time.Time) ([]int64, error)) *CIStore_Sweep_Call { | |
| 1382 | + | _c.Call.Return(run) | |
| 1383 | + | return _c | |
| 1384 | + | } | |
A internal/ports/mocks/RunService.go +751 -0
| @@ -0,0 +1,751 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewRunService creates a new instance of RunService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewRunService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *RunService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &RunService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // RunService is an autogenerated mock type for the RunService type | |
| 39 | + | type RunService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type RunService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *RunService) EXPECT() *RunService_Expecter { | |
| 48 | + | return &RunService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // CancelRun provides a mock function for the type RunService | |
| 52 | + | func (_mock *RunService) CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, repo, runID) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for CancelRun") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.CIRun | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.CIRun, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, repo, runID) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.CIRun); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, repo, runID) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.CIRun) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, repo, runID) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // RunService_CancelRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelRun' | |
| 78 | + | type RunService_CancelRun_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // CancelRun is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - runID int64 | |
| 87 | + | func (_e *RunService_Expecter) CancelRun(ctx any, p any, repo any, runID any) *RunService_CancelRun_Call { | |
| 88 | + | return &RunService_CancelRun_Call{Call: _e.mock.On("CancelRun", ctx, p, repo, runID)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *RunService_CancelRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64)) *RunService_CancelRun_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 context.Context | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(context.Context) | |
| 96 | + | } | |
| 97 | + | var arg1 domain.Principal | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(domain.Principal) | |
| 100 | + | } | |
| 101 | + | var arg2 domain.RepoName | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].(domain.RepoName) | |
| 104 | + | } | |
| 105 | + | var arg3 int64 | |
| 106 | + | if args[3] != nil { | |
| 107 | + | arg3 = args[3].(int64) | |
| 108 | + | } | |
| 109 | + | run( | |
| 110 | + | arg0, | |
| 111 | + | arg1, | |
| 112 | + | arg2, | |
| 113 | + | arg3, | |
| 114 | + | ) | |
| 115 | + | }) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *RunService_CancelRun_Call) Return(cIRun domain.CIRun, err error) *RunService_CancelRun_Call { | |
| 120 | + | _c.Call.Return(cIRun, err) | |
| 121 | + | return _c | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)) *RunService_CancelRun_Call { | |
| 125 | + | _c.Call.Return(run) | |
| 126 | + | return _c | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // GetJob provides a mock function for the type RunService | |
| 130 | + | func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) { | |
| 131 | + | ret := _mock.Called(ctx, p, repo, jobID, attempt) | |
| 132 | + | ||
| 133 | + | if len(ret) == 0 { | |
| 134 | + | panic("no return value specified for GetJob") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | var r0 ports.JobView | |
| 138 | + | var r1 error | |
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) (ports.JobView, error)); ok { | |
| 140 | + | return returnFunc(ctx, p, repo, jobID, attempt) | |
| 141 | + | } | |
| 142 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) ports.JobView); ok { | |
| 143 | + | r0 = returnFunc(ctx, p, repo, jobID, attempt) | |
| 144 | + | } else { | |
| 145 | + | r0 = ret.Get(0).(ports.JobView) | |
| 146 | + | } | |
| 147 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32) error); ok { | |
| 148 | + | r1 = returnFunc(ctx, p, repo, jobID, attempt) | |
| 149 | + | } else { | |
| 150 | + | r1 = ret.Error(1) | |
| 151 | + | } | |
| 152 | + | return r0, r1 | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // RunService_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob' | |
| 156 | + | type RunService_GetJob_Call struct { | |
| 157 | + | *mock.Call | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // GetJob is a helper method to define mock.On call | |
| 161 | + | // - ctx context.Context | |
| 162 | + | // - p domain.Principal | |
| 163 | + | // - repo domain.RepoName | |
| 164 | + | // - jobID string | |
| 165 | + | // - attempt int32 | |
| 166 | + | func (_e *RunService_Expecter) GetJob(ctx any, p any, repo any, jobID any, attempt any) *RunService_GetJob_Call { | |
| 167 | + | return &RunService_GetJob_Call{Call: _e.mock.On("GetJob", ctx, p, repo, jobID, attempt)} | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (_c *RunService_GetJob_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32)) *RunService_GetJob_Call { | |
| 171 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 172 | + | var arg0 context.Context | |
| 173 | + | if args[0] != nil { | |
| 174 | + | arg0 = args[0].(context.Context) | |
| 175 | + | } | |
| 176 | + | var arg1 domain.Principal | |
| 177 | + | if args[1] != nil { | |
| 178 | + | arg1 = args[1].(domain.Principal) | |
| 179 | + | } | |
| 180 | + | var arg2 domain.RepoName | |
| 181 | + | if args[2] != nil { | |
| 182 | + | arg2 = args[2].(domain.RepoName) | |
| 183 | + | } | |
| 184 | + | var arg3 string | |
| 185 | + | if args[3] != nil { | |
| 186 | + | arg3 = args[3].(string) | |
| 187 | + | } | |
| 188 | + | var arg4 int32 | |
| 189 | + | if args[4] != nil { | |
| 190 | + | arg4 = args[4].(int32) | |
| 191 | + | } | |
| 192 | + | run( | |
| 193 | + | arg0, | |
| 194 | + | arg1, | |
| 195 | + | arg2, | |
| 196 | + | arg3, | |
| 197 | + | arg4, | |
| 198 | + | ) | |
| 199 | + | }) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *RunService_GetJob_Call) Return(jobView ports.JobView, err error) *RunService_GetJob_Call { | |
| 204 | + | _c.Call.Return(jobView, err) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *RunService_GetJob_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error)) *RunService_GetJob_Call { | |
| 209 | + | _c.Call.Return(run) | |
| 210 | + | return _c | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // GetJobLog provides a mock function for the type RunService | |
| 214 | + | func (_mock *RunService) GetJobLog(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error { | |
| 215 | + | ret := _mock.Called(ctx, p, repo, params, send) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for GetJobLog") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 error | |
| 222 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.GetJobLogParams, func(ports.LogBatch) error) error); ok { | |
| 223 | + | r0 = returnFunc(ctx, p, repo, params, send) | |
| 224 | + | } else { | |
| 225 | + | r0 = ret.Error(0) | |
| 226 | + | } | |
| 227 | + | return r0 | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // RunService_GetJobLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobLog' | |
| 231 | + | type RunService_GetJobLog_Call struct { | |
| 232 | + | *mock.Call | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // GetJobLog is a helper method to define mock.On call | |
| 236 | + | // - ctx context.Context | |
| 237 | + | // - p domain.Principal | |
| 238 | + | // - repo domain.RepoName | |
| 239 | + | // - params ports.GetJobLogParams | |
| 240 | + | // - send func(ports.LogBatch) error | |
| 241 | + | func (_e *RunService_Expecter) GetJobLog(ctx any, p any, repo any, params any, send any) *RunService_GetJobLog_Call { | |
| 242 | + | return &RunService_GetJobLog_Call{Call: _e.mock.On("GetJobLog", ctx, p, repo, params, send)} | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (_c *RunService_GetJobLog_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error)) *RunService_GetJobLog_Call { | |
| 246 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 247 | + | var arg0 context.Context | |
| 248 | + | if args[0] != nil { | |
| 249 | + | arg0 = args[0].(context.Context) | |
| 250 | + | } | |
| 251 | + | var arg1 domain.Principal | |
| 252 | + | if args[1] != nil { | |
| 253 | + | arg1 = args[1].(domain.Principal) | |
| 254 | + | } | |
| 255 | + | var arg2 domain.RepoName | |
| 256 | + | if args[2] != nil { | |
| 257 | + | arg2 = args[2].(domain.RepoName) | |
| 258 | + | } | |
| 259 | + | var arg3 ports.GetJobLogParams | |
| 260 | + | if args[3] != nil { | |
| 261 | + | arg3 = args[3].(ports.GetJobLogParams) | |
| 262 | + | } | |
| 263 | + | var arg4 func(ports.LogBatch) error | |
| 264 | + | if args[4] != nil { | |
| 265 | + | arg4 = args[4].(func(ports.LogBatch) error) | |
| 266 | + | } | |
| 267 | + | run( | |
| 268 | + | arg0, | |
| 269 | + | arg1, | |
| 270 | + | arg2, | |
| 271 | + | arg3, | |
| 272 | + | arg4, | |
| 273 | + | ) | |
| 274 | + | }) | |
| 275 | + | return _c | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (_c *RunService_GetJobLog_Call) Return(err error) *RunService_GetJobLog_Call { | |
| 279 | + | _c.Call.Return(err) | |
| 280 | + | return _c | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (_c *RunService_GetJobLog_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error) *RunService_GetJobLog_Call { | |
| 284 | + | _c.Call.Return(run) | |
| 285 | + | return _c | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // GetRun provides a mock function for the type RunService | |
| 289 | + | func (_mock *RunService) GetRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error) { | |
| 290 | + | ret := _mock.Called(ctx, p, repo, runID, attempt) | |
| 291 | + | ||
| 292 | + | if len(ret) == 0 { | |
| 293 | + | panic("no return value specified for GetRun") | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | var r0 ports.RunDetail | |
| 297 | + | var r1 error | |
| 298 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) (ports.RunDetail, error)); ok { | |
| 299 | + | return returnFunc(ctx, p, repo, runID, attempt) | |
| 300 | + | } | |
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) ports.RunDetail); ok { | |
| 302 | + | r0 = returnFunc(ctx, p, repo, runID, attempt) | |
| 303 | + | } else { | |
| 304 | + | r0 = ret.Get(0).(ports.RunDetail) | |
| 305 | + | } | |
| 306 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) error); ok { | |
| 307 | + | r1 = returnFunc(ctx, p, repo, runID, attempt) | |
| 308 | + | } else { | |
| 309 | + | r1 = ret.Error(1) | |
| 310 | + | } | |
| 311 | + | return r0, r1 | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // RunService_GetRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRun' | |
| 315 | + | type RunService_GetRun_Call struct { | |
| 316 | + | *mock.Call | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // GetRun is a helper method to define mock.On call | |
| 320 | + | // - ctx context.Context | |
| 321 | + | // - p domain.Principal | |
| 322 | + | // - repo domain.RepoName | |
| 323 | + | // - runID int64 | |
| 324 | + | // - attempt int32 | |
| 325 | + | func (_e *RunService_Expecter) GetRun(ctx any, p any, repo any, runID any, attempt any) *RunService_GetRun_Call { | |
| 326 | + | return &RunService_GetRun_Call{Call: _e.mock.On("GetRun", ctx, p, repo, runID, attempt)} | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (_c *RunService_GetRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32)) *RunService_GetRun_Call { | |
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 331 | + | var arg0 context.Context | |
| 332 | + | if args[0] != nil { | |
| 333 | + | arg0 = args[0].(context.Context) | |
| 334 | + | } | |
| 335 | + | var arg1 domain.Principal | |
| 336 | + | if args[1] != nil { | |
| 337 | + | arg1 = args[1].(domain.Principal) | |
| 338 | + | } | |
| 339 | + | var arg2 domain.RepoName | |
| 340 | + | if args[2] != nil { | |
| 341 | + | arg2 = args[2].(domain.RepoName) | |
| 342 | + | } | |
| 343 | + | var arg3 int64 | |
| 344 | + | if args[3] != nil { | |
| 345 | + | arg3 = args[3].(int64) | |
| 346 | + | } | |
| 347 | + | var arg4 int32 | |
| 348 | + | if args[4] != nil { | |
| 349 | + | arg4 = args[4].(int32) | |
| 350 | + | } | |
| 351 | + | run( | |
| 352 | + | arg0, | |
| 353 | + | arg1, | |
| 354 | + | arg2, | |
| 355 | + | arg3, | |
| 356 | + | arg4, | |
| 357 | + | ) | |
| 358 | + | }) | |
| 359 | + | return _c | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (_c *RunService_GetRun_Call) Return(runDetail ports.RunDetail, err error) *RunService_GetRun_Call { | |
| 363 | + | _c.Call.Return(runDetail, err) | |
| 364 | + | return _c | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error)) *RunService_GetRun_Call { | |
| 368 | + | _c.Call.Return(run) | |
| 369 | + | return _c | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // ListChecks provides a mock function for the type RunService | |
| 373 | + | func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) { | |
| 374 | + | ret := _mock.Called(ctx, p, repo, sha) | |
| 375 | + | ||
| 376 | + | if len(ret) == 0 { | |
| 377 | + | panic("no return value specified for ListChecks") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | var r0 ports.ChecksResult | |
| 381 | + | var r1 error | |
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.ChecksResult, error)); ok { | |
| 383 | + | return returnFunc(ctx, p, repo, sha) | |
| 384 | + | } | |
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.ChecksResult); ok { | |
| 386 | + | r0 = returnFunc(ctx, p, repo, sha) | |
| 387 | + | } else { | |
| 388 | + | r0 = ret.Get(0).(ports.ChecksResult) | |
| 389 | + | } | |
| 390 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok { | |
| 391 | + | r1 = returnFunc(ctx, p, repo, sha) | |
| 392 | + | } else { | |
| 393 | + | r1 = ret.Error(1) | |
| 394 | + | } | |
| 395 | + | return r0, r1 | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // RunService_ListChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListChecks' | |
| 399 | + | type RunService_ListChecks_Call struct { | |
| 400 | + | *mock.Call | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // ListChecks is a helper method to define mock.On call | |
| 404 | + | // - ctx context.Context | |
| 405 | + | // - p domain.Principal | |
| 406 | + | // - repo domain.RepoName | |
| 407 | + | // - sha string | |
| 408 | + | func (_e *RunService_Expecter) ListChecks(ctx any, p any, repo any, sha any) *RunService_ListChecks_Call { | |
| 409 | + | return &RunService_ListChecks_Call{Call: _e.mock.On("ListChecks", ctx, p, repo, sha)} | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (_c *RunService_ListChecks_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string)) *RunService_ListChecks_Call { | |
| 413 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 414 | + | var arg0 context.Context | |
| 415 | + | if args[0] != nil { | |
| 416 | + | arg0 = args[0].(context.Context) | |
| 417 | + | } | |
| 418 | + | var arg1 domain.Principal | |
| 419 | + | if args[1] != nil { | |
| 420 | + | arg1 = args[1].(domain.Principal) | |
| 421 | + | } | |
| 422 | + | var arg2 domain.RepoName | |
| 423 | + | if args[2] != nil { | |
| 424 | + | arg2 = args[2].(domain.RepoName) | |
| 425 | + | } | |
| 426 | + | var arg3 string | |
| 427 | + | if args[3] != nil { | |
| 428 | + | arg3 = args[3].(string) | |
| 429 | + | } | |
| 430 | + | run( | |
| 431 | + | arg0, | |
| 432 | + | arg1, | |
| 433 | + | arg2, | |
| 434 | + | arg3, | |
| 435 | + | ) | |
| 436 | + | }) | |
| 437 | + | return _c | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func (_c *RunService_ListChecks_Call) Return(checksResult ports.ChecksResult, err error) *RunService_ListChecks_Call { | |
| 441 | + | _c.Call.Return(checksResult, err) | |
| 442 | + | return _c | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (_c *RunService_ListChecks_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error)) *RunService_ListChecks_Call { | |
| 446 | + | _c.Call.Return(run) | |
| 447 | + | return _c | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // ListRunners provides a mock function for the type RunService | |
| 451 | + | func (_mock *RunService) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) { | |
| 452 | + | ret := _mock.Called(ctx, p, size, token) | |
| 453 | + | ||
| 454 | + | if len(ret) == 0 { | |
| 455 | + | panic("no return value specified for ListRunners") | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | var r0 ports.RunnerPage | |
| 459 | + | var r1 error | |
| 460 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.RunnerPage, error)); ok { | |
| 461 | + | return returnFunc(ctx, p, size, token) | |
| 462 | + | } | |
| 463 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.RunnerPage); ok { | |
| 464 | + | r0 = returnFunc(ctx, p, size, token) | |
| 465 | + | } else { | |
| 466 | + | r0 = ret.Get(0).(ports.RunnerPage) | |
| 467 | + | } | |
| 468 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok { | |
| 469 | + | r1 = returnFunc(ctx, p, size, token) | |
| 470 | + | } else { | |
| 471 | + | r1 = ret.Error(1) | |
| 472 | + | } | |
| 473 | + | return r0, r1 | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // RunService_ListRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRunners' | |
| 477 | + | type RunService_ListRunners_Call struct { | |
| 478 | + | *mock.Call | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | // ListRunners is a helper method to define mock.On call | |
| 482 | + | // - ctx context.Context | |
| 483 | + | // - p domain.Principal | |
| 484 | + | // - size int | |
| 485 | + | // - token string | |
| 486 | + | func (_e *RunService_Expecter) ListRunners(ctx any, p any, size any, token any) *RunService_ListRunners_Call { | |
| 487 | + | return &RunService_ListRunners_Call{Call: _e.mock.On("ListRunners", ctx, p, size, token)} | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (_c *RunService_ListRunners_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *RunService_ListRunners_Call { | |
| 491 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 492 | + | var arg0 context.Context | |
| 493 | + | if args[0] != nil { | |
| 494 | + | arg0 = args[0].(context.Context) | |
| 495 | + | } | |
| 496 | + | var arg1 domain.Principal | |
| 497 | + | if args[1] != nil { | |
| 498 | + | arg1 = args[1].(domain.Principal) | |
| 499 | + | } | |
| 500 | + | var arg2 int | |
| 501 | + | if args[2] != nil { | |
| 502 | + | arg2 = args[2].(int) | |
| 503 | + | } | |
| 504 | + | var arg3 string | |
| 505 | + | if args[3] != nil { | |
| 506 | + | arg3 = args[3].(string) | |
| 507 | + | } | |
| 508 | + | run( | |
| 509 | + | arg0, | |
| 510 | + | arg1, | |
| 511 | + | arg2, | |
| 512 | + | arg3, | |
| 513 | + | ) | |
| 514 | + | }) | |
| 515 | + | return _c | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func (_c *RunService_ListRunners_Call) Return(runnerPage ports.RunnerPage, err error) *RunService_ListRunners_Call { | |
| 519 | + | _c.Call.Return(runnerPage, err) | |
| 520 | + | return _c | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func (_c *RunService_ListRunners_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error)) *RunService_ListRunners_Call { | |
| 524 | + | _c.Call.Return(run) | |
| 525 | + | return _c | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // ListRuns provides a mock function for the type RunService | |
| 529 | + | func (_mock *RunService) ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error) { | |
| 530 | + | ret := _mock.Called(ctx, p, repo, params) | |
| 531 | + | ||
| 532 | + | if len(ret) == 0 { | |
| 533 | + | panic("no return value specified for ListRuns") | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | var r0 ports.RunPage | |
| 537 | + | var r1 error | |
| 538 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) (ports.RunPage, error)); ok { | |
| 539 | + | return returnFunc(ctx, p, repo, params) | |
| 540 | + | } | |
| 541 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) ports.RunPage); ok { | |
| 542 | + | r0 = returnFunc(ctx, p, repo, params) | |
| 543 | + | } else { | |
| 544 | + | r0 = ret.Get(0).(ports.RunPage) | |
| 545 | + | } | |
| 546 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) error); ok { | |
| 547 | + | r1 = returnFunc(ctx, p, repo, params) | |
| 548 | + | } else { | |
| 549 | + | r1 = ret.Error(1) | |
| 550 | + | } | |
| 551 | + | return r0, r1 | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // RunService_ListRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRuns' | |
| 555 | + | type RunService_ListRuns_Call struct { | |
| 556 | + | *mock.Call | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // ListRuns is a helper method to define mock.On call | |
| 560 | + | // - ctx context.Context | |
| 561 | + | // - p domain.Principal | |
| 562 | + | // - repo domain.RepoName | |
| 563 | + | // - params ports.ListRunsParams | |
| 564 | + | func (_e *RunService_Expecter) ListRuns(ctx any, p any, repo any, params any) *RunService_ListRuns_Call { | |
| 565 | + | return &RunService_ListRuns_Call{Call: _e.mock.On("ListRuns", ctx, p, repo, params)} | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func (_c *RunService_ListRuns_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams)) *RunService_ListRuns_Call { | |
| 569 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 570 | + | var arg0 context.Context | |
| 571 | + | if args[0] != nil { | |
| 572 | + | arg0 = args[0].(context.Context) | |
| 573 | + | } | |
| 574 | + | var arg1 domain.Principal | |
| 575 | + | if args[1] != nil { | |
| 576 | + | arg1 = args[1].(domain.Principal) | |
| 577 | + | } | |
| 578 | + | var arg2 domain.RepoName | |
| 579 | + | if args[2] != nil { | |
| 580 | + | arg2 = args[2].(domain.RepoName) | |
| 581 | + | } | |
| 582 | + | var arg3 ports.ListRunsParams | |
| 583 | + | if args[3] != nil { | |
| 584 | + | arg3 = args[3].(ports.ListRunsParams) | |
| 585 | + | } | |
| 586 | + | run( | |
| 587 | + | arg0, | |
| 588 | + | arg1, | |
| 589 | + | arg2, | |
| 590 | + | arg3, | |
| 591 | + | ) | |
| 592 | + | }) | |
| 593 | + | return _c | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | func (_c *RunService_ListRuns_Call) Return(runPage ports.RunPage, err error) *RunService_ListRuns_Call { | |
| 597 | + | _c.Call.Return(runPage, err) | |
| 598 | + | return _c | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error)) *RunService_ListRuns_Call { | |
| 602 | + | _c.Call.Return(run) | |
| 603 | + | return _c | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // RemoveRunner provides a mock function for the type RunService | |
| 607 | + | func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | |
| 608 | + | ret := _mock.Called(ctx, p, id) | |
| 609 | + | ||
| 610 | + | if len(ret) == 0 { | |
| 611 | + | panic("no return value specified for RemoveRunner") | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | var r0 error | |
| 615 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok { | |
| 616 | + | r0 = returnFunc(ctx, p, id) | |
| 617 | + | } else { | |
| 618 | + | r0 = ret.Error(0) | |
| 619 | + | } | |
| 620 | + | return r0 | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // RunService_RemoveRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRunner' | |
| 624 | + | type RunService_RemoveRunner_Call struct { | |
| 625 | + | *mock.Call | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | // RemoveRunner is a helper method to define mock.On call | |
| 629 | + | // - ctx context.Context | |
| 630 | + | // - p domain.Principal | |
| 631 | + | // - id string | |
| 632 | + | func (_e *RunService_Expecter) RemoveRunner(ctx any, p any, id any) *RunService_RemoveRunner_Call { | |
| 633 | + | return &RunService_RemoveRunner_Call{Call: _e.mock.On("RemoveRunner", ctx, p, id)} | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func (_c *RunService_RemoveRunner_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *RunService_RemoveRunner_Call { | |
| 637 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 638 | + | var arg0 context.Context | |
| 639 | + | if args[0] != nil { | |
| 640 | + | arg0 = args[0].(context.Context) | |
| 641 | + | } | |
| 642 | + | var arg1 domain.Principal | |
| 643 | + | if args[1] != nil { | |
| 644 | + | arg1 = args[1].(domain.Principal) | |
| 645 | + | } | |
| 646 | + | var arg2 string | |
| 647 | + | if args[2] != nil { | |
| 648 | + | arg2 = args[2].(string) | |
| 649 | + | } | |
| 650 | + | run( | |
| 651 | + | arg0, | |
| 652 | + | arg1, | |
| 653 | + | arg2, | |
| 654 | + | ) | |
| 655 | + | }) | |
| 656 | + | return _c | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | func (_c *RunService_RemoveRunner_Call) Return(err error) *RunService_RemoveRunner_Call { | |
| 660 | + | _c.Call.Return(err) | |
| 661 | + | return _c | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | func (_c *RunService_RemoveRunner_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *RunService_RemoveRunner_Call { | |
| 665 | + | _c.Call.Return(run) | |
| 666 | + | return _c | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | // RerunRun provides a mock function for the type RunService | |
| 670 | + | func (_mock *RunService) RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) { | |
| 671 | + | ret := _mock.Called(ctx, p, repo, runID, failedOnly) | |
| 672 | + | ||
| 673 | + | if len(ret) == 0 { | |
| 674 | + | panic("no return value specified for RerunRun") | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | var r0 domain.CIRun | |
| 678 | + | var r1 error | |
| 679 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (domain.CIRun, error)); ok { | |
| 680 | + | return returnFunc(ctx, p, repo, runID, failedOnly) | |
| 681 | + | } | |
| 682 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) domain.CIRun); ok { | |
| 683 | + | r0 = returnFunc(ctx, p, repo, runID, failedOnly) | |
| 684 | + | } else { | |
| 685 | + | r0 = ret.Get(0).(domain.CIRun) | |
| 686 | + | } | |
| 687 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok { | |
| 688 | + | r1 = returnFunc(ctx, p, repo, runID, failedOnly) | |
| 689 | + | } else { | |
| 690 | + | r1 = ret.Error(1) | |
| 691 | + | } | |
| 692 | + | return r0, r1 | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // RunService_RerunRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RerunRun' | |
| 696 | + | type RunService_RerunRun_Call struct { | |
| 697 | + | *mock.Call | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // RerunRun is a helper method to define mock.On call | |
| 701 | + | // - ctx context.Context | |
| 702 | + | // - p domain.Principal | |
| 703 | + | // - repo domain.RepoName | |
| 704 | + | // - runID int64 | |
| 705 | + | // - failedOnly bool | |
| 706 | + | func (_e *RunService_Expecter) RerunRun(ctx any, p any, repo any, runID any, failedOnly any) *RunService_RerunRun_Call { | |
| 707 | + | return &RunService_RerunRun_Call{Call: _e.mock.On("RerunRun", ctx, p, repo, runID, failedOnly)} | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | func (_c *RunService_RerunRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool)) *RunService_RerunRun_Call { | |
| 711 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 712 | + | var arg0 context.Context | |
| 713 | + | if args[0] != nil { | |
| 714 | + | arg0 = args[0].(context.Context) | |
| 715 | + | } | |
| 716 | + | var arg1 domain.Principal | |
| 717 | + | if args[1] != nil { | |
| 718 | + | arg1 = args[1].(domain.Principal) | |
| 719 | + | } | |
| 720 | + | var arg2 domain.RepoName | |
| 721 | + | if args[2] != nil { | |
| 722 | + | arg2 = args[2].(domain.RepoName) | |
| 723 | + | } | |
| 724 | + | var arg3 int64 | |
| 725 | + | if args[3] != nil { | |
| 726 | + | arg3 = args[3].(int64) | |
| 727 | + | } | |
| 728 | + | var arg4 bool | |
| 729 | + | if args[4] != nil { | |
| 730 | + | arg4 = args[4].(bool) | |
| 731 | + | } | |
| 732 | + | run( | |
| 733 | + | arg0, | |
| 734 | + | arg1, | |
| 735 | + | arg2, | |
| 736 | + | arg3, | |
| 737 | + | arg4, | |
| 738 | + | ) | |
| 739 | + | }) | |
| 740 | + | return _c | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | func (_c *RunService_RerunRun_Call) Return(cIRun domain.CIRun, err error) *RunService_RerunRun_Call { | |
| 744 | + | _c.Call.Return(cIRun, err) | |
| 745 | + | return _c | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | func (_c *RunService_RerunRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)) *RunService_RerunRun_Call { | |
| 749 | + | _c.Call.Return(run) | |
| 750 | + | return _c | |
| 751 | + | } | |
A internal/ports/mocks/RunnerService.go +562 -0
| @@ -0,0 +1,562 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewRunnerService creates a new instance of RunnerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewRunnerService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *RunnerService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &RunnerService{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // RunnerService is an autogenerated mock type for the RunnerService type | |
| 39 | + | type RunnerService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type RunnerService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *RunnerService) EXPECT() *RunnerService_Expecter { | |
| 48 | + | return &RunnerService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AppendLog provides a mock function for the type RunnerService | |
| 52 | + | func (_mock *RunnerService) AppendLog(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error) { | |
| 53 | + | ret := _mock.Called(ctx, secret, jobID, step, offset, lines) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AppendLog") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 int64 | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, int64, []domain.CILogLine) (int64, error)); ok { | |
| 62 | + | return returnFunc(ctx, secret, jobID, step, offset, lines) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, int64, []domain.CILogLine) int64); ok { | |
| 65 | + | r0 = returnFunc(ctx, secret, jobID, step, offset, lines) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(int64) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, int32, int64, []domain.CILogLine) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, secret, jobID, step, offset, lines) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // RunnerService_AppendLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendLog' | |
| 78 | + | type RunnerService_AppendLog_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // AppendLog is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - secret string | |
| 85 | + | // - jobID string | |
| 86 | + | // - step int32 | |
| 87 | + | // - offset int64 | |
| 88 | + | // - lines []domain.CILogLine | |
| 89 | + | func (_e *RunnerService_Expecter) AppendLog(ctx any, secret any, jobID any, step any, offset any, lines any) *RunnerService_AppendLog_Call { | |
| 90 | + | return &RunnerService_AppendLog_Call{Call: _e.mock.On("AppendLog", ctx, secret, jobID, step, offset, lines)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *RunnerService_AppendLog_Call) Run(run func(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine)) *RunnerService_AppendLog_Call { | |
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | + | var arg0 context.Context | |
| 96 | + | if args[0] != nil { | |
| 97 | + | arg0 = args[0].(context.Context) | |
| 98 | + | } | |
| 99 | + | var arg1 string | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(string) | |
| 102 | + | } | |
| 103 | + | var arg2 string | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(string) | |
| 106 | + | } | |
| 107 | + | var arg3 int32 | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(int32) | |
| 110 | + | } | |
| 111 | + | var arg4 int64 | |
| 112 | + | if args[4] != nil { | |
| 113 | + | arg4 = args[4].(int64) | |
| 114 | + | } | |
| 115 | + | var arg5 []domain.CILogLine | |
| 116 | + | if args[5] != nil { | |
| 117 | + | arg5 = args[5].([]domain.CILogLine) | |
| 118 | + | } | |
| 119 | + | run( | |
| 120 | + | arg0, | |
| 121 | + | arg1, | |
| 122 | + | arg2, | |
| 123 | + | arg3, | |
| 124 | + | arg4, | |
| 125 | + | arg5, | |
| 126 | + | ) | |
| 127 | + | }) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *RunnerService_AppendLog_Call) Return(next int64, err error) *RunnerService_AppendLog_Call { | |
| 132 | + | _c.Call.Return(next, err) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *RunnerService_AppendLog_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error)) *RunnerService_AppendLog_Call { | |
| 137 | + | _c.Call.Return(run) | |
| 138 | + | return _c | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // CompleteJob provides a mock function for the type RunnerService | |
| 142 | + | func (_mock *RunnerService) CompleteJob(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error) { | |
| 143 | + | ret := _mock.Called(ctx, secret, params) | |
| 144 | + | ||
| 145 | + | if len(ret) == 0 { | |
| 146 | + | panic("no return value specified for CompleteJob") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | var r0 ports.CompleteJobResult | |
| 150 | + | var r1 error | |
| 151 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.CompleteJobParams) (ports.CompleteJobResult, error)); ok { | |
| 152 | + | return returnFunc(ctx, secret, params) | |
| 153 | + | } | |
| 154 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.CompleteJobParams) ports.CompleteJobResult); ok { | |
| 155 | + | r0 = returnFunc(ctx, secret, params) | |
| 156 | + | } else { | |
| 157 | + | r0 = ret.Get(0).(ports.CompleteJobResult) | |
| 158 | + | } | |
| 159 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.CompleteJobParams) error); ok { | |
| 160 | + | r1 = returnFunc(ctx, secret, params) | |
| 161 | + | } else { | |
| 162 | + | r1 = ret.Error(1) | |
| 163 | + | } | |
| 164 | + | return r0, r1 | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // RunnerService_CompleteJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompleteJob' | |
| 168 | + | type RunnerService_CompleteJob_Call struct { | |
| 169 | + | *mock.Call | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // CompleteJob is a helper method to define mock.On call | |
| 173 | + | // - ctx context.Context | |
| 174 | + | // - secret string | |
| 175 | + | // - params ports.CompleteJobParams | |
| 176 | + | func (_e *RunnerService_Expecter) CompleteJob(ctx any, secret any, params any) *RunnerService_CompleteJob_Call { | |
| 177 | + | return &RunnerService_CompleteJob_Call{Call: _e.mock.On("CompleteJob", ctx, secret, params)} | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (_c *RunnerService_CompleteJob_Call) Run(run func(ctx context.Context, secret string, params ports.CompleteJobParams)) *RunnerService_CompleteJob_Call { | |
| 181 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 182 | + | var arg0 context.Context | |
| 183 | + | if args[0] != nil { | |
| 184 | + | arg0 = args[0].(context.Context) | |
| 185 | + | } | |
| 186 | + | var arg1 string | |
| 187 | + | if args[1] != nil { | |
| 188 | + | arg1 = args[1].(string) | |
| 189 | + | } | |
| 190 | + | var arg2 ports.CompleteJobParams | |
| 191 | + | if args[2] != nil { | |
| 192 | + | arg2 = args[2].(ports.CompleteJobParams) | |
| 193 | + | } | |
| 194 | + | run( | |
| 195 | + | arg0, | |
| 196 | + | arg1, | |
| 197 | + | arg2, | |
| 198 | + | ) | |
| 199 | + | }) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *RunnerService_CompleteJob_Call) Return(completeJobResult ports.CompleteJobResult, err error) *RunnerService_CompleteJob_Call { | |
| 204 | + | _c.Call.Return(completeJobResult, err) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *RunnerService_CompleteJob_Call) RunAndReturn(run func(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error)) *RunnerService_CompleteJob_Call { | |
| 209 | + | _c.Call.Return(run) | |
| 210 | + | return _c | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // FetchJob provides a mock function for the type RunnerService | |
| 214 | + | func (_mock *RunnerService) FetchJob(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error) { | |
| 215 | + | ret := _mock.Called(ctx, secret, params) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for FetchJob") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 ports.FetchJobResult | |
| 222 | + | var r1 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.FetchJobParams) (ports.FetchJobResult, error)); ok { | |
| 224 | + | return returnFunc(ctx, secret, params) | |
| 225 | + | } | |
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.FetchJobParams) ports.FetchJobResult); ok { | |
| 227 | + | r0 = returnFunc(ctx, secret, params) | |
| 228 | + | } else { | |
| 229 | + | r0 = ret.Get(0).(ports.FetchJobResult) | |
| 230 | + | } | |
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.FetchJobParams) error); ok { | |
| 232 | + | r1 = returnFunc(ctx, secret, params) | |
| 233 | + | } else { | |
| 234 | + | r1 = ret.Error(1) | |
| 235 | + | } | |
| 236 | + | return r0, r1 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // RunnerService_FetchJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchJob' | |
| 240 | + | type RunnerService_FetchJob_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // FetchJob is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - secret string | |
| 247 | + | // - params ports.FetchJobParams | |
| 248 | + | func (_e *RunnerService_Expecter) FetchJob(ctx any, secret any, params any) *RunnerService_FetchJob_Call { | |
| 249 | + | return &RunnerService_FetchJob_Call{Call: _e.mock.On("FetchJob", ctx, secret, params)} | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (_c *RunnerService_FetchJob_Call) Run(run func(ctx context.Context, secret string, params ports.FetchJobParams)) *RunnerService_FetchJob_Call { | |
| 253 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 254 | + | var arg0 context.Context | |
| 255 | + | if args[0] != nil { | |
| 256 | + | arg0 = args[0].(context.Context) | |
| 257 | + | } | |
| 258 | + | var arg1 string | |
| 259 | + | if args[1] != nil { | |
| 260 | + | arg1 = args[1].(string) | |
| 261 | + | } | |
| 262 | + | var arg2 ports.FetchJobParams | |
| 263 | + | if args[2] != nil { | |
| 264 | + | arg2 = args[2].(ports.FetchJobParams) | |
| 265 | + | } | |
| 266 | + | run( | |
| 267 | + | arg0, | |
| 268 | + | arg1, | |
| 269 | + | arg2, | |
| 270 | + | ) | |
| 271 | + | }) | |
| 272 | + | return _c | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (_c *RunnerService_FetchJob_Call) Return(fetchJobResult ports.FetchJobResult, err error) *RunnerService_FetchJob_Call { | |
| 276 | + | _c.Call.Return(fetchJobResult, err) | |
| 277 | + | return _c | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (_c *RunnerService_FetchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error)) *RunnerService_FetchJob_Call { | |
| 281 | + | _c.Call.Return(run) | |
| 282 | + | return _c | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Heartbeat provides a mock function for the type RunnerService | |
| 286 | + | func (_mock *RunnerService) Heartbeat(ctx context.Context, secret string, jobID string) error { | |
| 287 | + | ret := _mock.Called(ctx, secret, jobID) | |
| 288 | + | ||
| 289 | + | if len(ret) == 0 { | |
| 290 | + | panic("no return value specified for Heartbeat") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | var r0 error | |
| 294 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 295 | + | r0 = returnFunc(ctx, secret, jobID) | |
| 296 | + | } else { | |
| 297 | + | r0 = ret.Error(0) | |
| 298 | + | } | |
| 299 | + | return r0 | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // RunnerService_Heartbeat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Heartbeat' | |
| 303 | + | type RunnerService_Heartbeat_Call struct { | |
| 304 | + | *mock.Call | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Heartbeat is a helper method to define mock.On call | |
| 308 | + | // - ctx context.Context | |
| 309 | + | // - secret string | |
| 310 | + | // - jobID string | |
| 311 | + | func (_e *RunnerService_Expecter) Heartbeat(ctx any, secret any, jobID any) *RunnerService_Heartbeat_Call { | |
| 312 | + | return &RunnerService_Heartbeat_Call{Call: _e.mock.On("Heartbeat", ctx, secret, jobID)} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (_c *RunnerService_Heartbeat_Call) Run(run func(ctx context.Context, secret string, jobID string)) *RunnerService_Heartbeat_Call { | |
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 317 | + | var arg0 context.Context | |
| 318 | + | if args[0] != nil { | |
| 319 | + | arg0 = args[0].(context.Context) | |
| 320 | + | } | |
| 321 | + | var arg1 string | |
| 322 | + | if args[1] != nil { | |
| 323 | + | arg1 = args[1].(string) | |
| 324 | + | } | |
| 325 | + | var arg2 string | |
| 326 | + | if args[2] != nil { | |
| 327 | + | arg2 = args[2].(string) | |
| 328 | + | } | |
| 329 | + | run( | |
| 330 | + | arg0, | |
| 331 | + | arg1, | |
| 332 | + | arg2, | |
| 333 | + | ) | |
| 334 | + | }) | |
| 335 | + | return _c | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func (_c *RunnerService_Heartbeat_Call) Return(err error) *RunnerService_Heartbeat_Call { | |
| 339 | + | _c.Call.Return(err) | |
| 340 | + | return _c | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func (_c *RunnerService_Heartbeat_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string) error) *RunnerService_Heartbeat_Call { | |
| 344 | + | _c.Call.Return(run) | |
| 345 | + | return _c | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // Register provides a mock function for the type RunnerService | |
| 349 | + | func (_mock *RunnerService) Register(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error) { | |
| 350 | + | ret := _mock.Called(ctx, p, params) | |
| 351 | + | ||
| 352 | + | if len(ret) == 0 { | |
| 353 | + | panic("no return value specified for Register") | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | var r0 ports.RegisteredRunner | |
| 357 | + | var r1 error | |
| 358 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) (ports.RegisteredRunner, error)); ok { | |
| 359 | + | return returnFunc(ctx, p, params) | |
| 360 | + | } | |
| 361 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) ports.RegisteredRunner); ok { | |
| 362 | + | r0 = returnFunc(ctx, p, params) | |
| 363 | + | } else { | |
| 364 | + | r0 = ret.Get(0).(ports.RegisteredRunner) | |
| 365 | + | } | |
| 366 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) error); ok { | |
| 367 | + | r1 = returnFunc(ctx, p, params) | |
| 368 | + | } else { | |
| 369 | + | r1 = ret.Error(1) | |
| 370 | + | } | |
| 371 | + | return r0, r1 | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // RunnerService_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' | |
| 375 | + | type RunnerService_Register_Call struct { | |
| 376 | + | *mock.Call | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // Register is a helper method to define mock.On call | |
| 380 | + | // - ctx context.Context | |
| 381 | + | // - p domain.Principal | |
| 382 | + | // - params ports.RegisterRunnerParams | |
| 383 | + | func (_e *RunnerService_Expecter) Register(ctx any, p any, params any) *RunnerService_Register_Call { | |
| 384 | + | return &RunnerService_Register_Call{Call: _e.mock.On("Register", ctx, p, params)} | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func (_c *RunnerService_Register_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams)) *RunnerService_Register_Call { | |
| 388 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 389 | + | var arg0 context.Context | |
| 390 | + | if args[0] != nil { | |
| 391 | + | arg0 = args[0].(context.Context) | |
| 392 | + | } | |
| 393 | + | var arg1 domain.Principal | |
| 394 | + | if args[1] != nil { | |
| 395 | + | arg1 = args[1].(domain.Principal) | |
| 396 | + | } | |
| 397 | + | var arg2 ports.RegisterRunnerParams | |
| 398 | + | if args[2] != nil { | |
| 399 | + | arg2 = args[2].(ports.RegisterRunnerParams) | |
| 400 | + | } | |
| 401 | + | run( | |
| 402 | + | arg0, | |
| 403 | + | arg1, | |
| 404 | + | arg2, | |
| 405 | + | ) | |
| 406 | + | }) | |
| 407 | + | return _c | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func (_c *RunnerService_Register_Call) Return(registeredRunner ports.RegisteredRunner, err error) *RunnerService_Register_Call { | |
| 411 | + | _c.Call.Return(registeredRunner, err) | |
| 412 | + | return _c | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error)) *RunnerService_Register_Call { | |
| 416 | + | _c.Call.Return(run) | |
| 417 | + | return _c | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // UpdateJob provides a mock function for the type RunnerService | |
| 421 | + | func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error { | |
| 422 | + | ret := _mock.Called(ctx, secret, jobID, steps) | |
| 423 | + | ||
| 424 | + | if len(ret) == 0 { | |
| 425 | + | panic("no return value specified for UpdateJob") | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | var r0 error | |
| 429 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate) error); ok { | |
| 430 | + | r0 = returnFunc(ctx, secret, jobID, steps) | |
| 431 | + | } else { | |
| 432 | + | r0 = ret.Error(0) | |
| 433 | + | } | |
| 434 | + | return r0 | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // RunnerService_UpdateJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateJob' | |
| 438 | + | type RunnerService_UpdateJob_Call struct { | |
| 439 | + | *mock.Call | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // UpdateJob is a helper method to define mock.On call | |
| 443 | + | // - ctx context.Context | |
| 444 | + | // - secret string | |
| 445 | + | // - jobID string | |
| 446 | + | // - steps []ports.StepUpdate | |
| 447 | + | func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any) *RunnerService_UpdateJob_Call { | |
| 448 | + | return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps)} | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate)) *RunnerService_UpdateJob_Call { | |
| 452 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 453 | + | var arg0 context.Context | |
| 454 | + | if args[0] != nil { | |
| 455 | + | arg0 = args[0].(context.Context) | |
| 456 | + | } | |
| 457 | + | var arg1 string | |
| 458 | + | if args[1] != nil { | |
| 459 | + | arg1 = args[1].(string) | |
| 460 | + | } | |
| 461 | + | var arg2 string | |
| 462 | + | if args[2] != nil { | |
| 463 | + | arg2 = args[2].(string) | |
| 464 | + | } | |
| 465 | + | var arg3 []ports.StepUpdate | |
| 466 | + | if args[3] != nil { | |
| 467 | + | arg3 = args[3].([]ports.StepUpdate) | |
| 468 | + | } | |
| 469 | + | run( | |
| 470 | + | arg0, | |
| 471 | + | arg1, | |
| 472 | + | arg2, | |
| 473 | + | arg3, | |
| 474 | + | ) | |
| 475 | + | }) | |
| 476 | + | return _c | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJob_Call { | |
| 480 | + | _c.Call.Return(err) | |
| 481 | + | return _c | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error) *RunnerService_UpdateJob_Call { | |
| 485 | + | _c.Call.Return(run) | |
| 486 | + | return _c | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // WatchJob provides a mock function for the type RunnerService | |
| 490 | + | func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error { | |
| 491 | + | ret := _mock.Called(ctx, secret, jobID, ready, send) | |
| 492 | + | ||
| 493 | + | if len(ret) == 0 { | |
| 494 | + | panic("no return value specified for WatchJob") | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | var r0 error | |
| 498 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(), func(ports.JobSignal) error) error); ok { | |
| 499 | + | r0 = returnFunc(ctx, secret, jobID, ready, send) | |
| 500 | + | } else { | |
| 501 | + | r0 = ret.Error(0) | |
| 502 | + | } | |
| 503 | + | return r0 | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | // RunnerService_WatchJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WatchJob' | |
| 507 | + | type RunnerService_WatchJob_Call struct { | |
| 508 | + | *mock.Call | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // WatchJob is a helper method to define mock.On call | |
| 512 | + | // - ctx context.Context | |
| 513 | + | // - secret string | |
| 514 | + | // - jobID string | |
| 515 | + | // - ready func() | |
| 516 | + | // - send func(ports.JobSignal) error | |
| 517 | + | func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, ready any, send any) *RunnerService_WatchJob_Call { | |
| 518 | + | return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, ready, send)} | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call { | |
| 522 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 523 | + | var arg0 context.Context | |
| 524 | + | if args[0] != nil { | |
| 525 | + | arg0 = args[0].(context.Context) | |
| 526 | + | } | |
| 527 | + | var arg1 string | |
| 528 | + | if args[1] != nil { | |
| 529 | + | arg1 = args[1].(string) | |
| 530 | + | } | |
| 531 | + | var arg2 string | |
| 532 | + | if args[2] != nil { | |
| 533 | + | arg2 = args[2].(string) | |
| 534 | + | } | |
| 535 | + | var arg3 func() | |
| 536 | + | if args[3] != nil { | |
| 537 | + | arg3 = args[3].(func()) | |
| 538 | + | } | |
| 539 | + | var arg4 func(ports.JobSignal) error | |
| 540 | + | if args[4] != nil { | |
| 541 | + | arg4 = args[4].(func(ports.JobSignal) error) | |
| 542 | + | } | |
| 543 | + | run( | |
| 544 | + | arg0, | |
| 545 | + | arg1, | |
| 546 | + | arg2, | |
| 547 | + | arg3, | |
| 548 | + | arg4, | |
| 549 | + | ) | |
| 550 | + | }) | |
| 551 | + | return _c | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func (_c *RunnerService_WatchJob_Call) Return(err error) *RunnerService_WatchJob_Call { | |
| 555 | + | _c.Call.Return(err) | |
| 556 | + | return _c | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call { | |
| 560 | + | _c.Call.Return(run) | |
| 561 | + | return _c | |
| 562 | + | } | |
A internal/ports/mocks/WorkflowTrigger.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewWorkflowTrigger creates a new instance of WorkflowTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewWorkflowTrigger(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *WorkflowTrigger { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &WorkflowTrigger{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // WorkflowTrigger is an autogenerated mock type for the WorkflowTrigger type | |
| 38 | + | type WorkflowTrigger struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type WorkflowTrigger_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *WorkflowTrigger) EXPECT() *WorkflowTrigger_Expecter { | |
| 47 | + | return &WorkflowTrigger_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // OnPush provides a mock function for the type WorkflowTrigger | |
| 51 | + | func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) { | |
| 52 | + | _mock.Called(ctx, repo, updates, pusher) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // WorkflowTrigger_OnPush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnPush' | |
| 57 | + | type WorkflowTrigger_OnPush_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // OnPush is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - repo domain.RepoName | |
| 64 | + | // - updates []domain.RefUpdate | |
| 65 | + | // - pusher domain.Principal | |
| 66 | + | func (_e *WorkflowTrigger_Expecter) OnPush(ctx any, repo any, updates any, pusher any) *WorkflowTrigger_OnPush_Call { | |
| 67 | + | return &WorkflowTrigger_OnPush_Call{Call: _e.mock.On("OnPush", ctx, repo, updates, pusher)} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *WorkflowTrigger_OnPush_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)) *WorkflowTrigger_OnPush_Call { | |
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 72 | + | var arg0 context.Context | |
| 73 | + | if args[0] != nil { | |
| 74 | + | arg0 = args[0].(context.Context) | |
| 75 | + | } | |
| 76 | + | var arg1 domain.RepoName | |
| 77 | + | if args[1] != nil { | |
| 78 | + | arg1 = args[1].(domain.RepoName) | |
| 79 | + | } | |
| 80 | + | var arg2 []domain.RefUpdate | |
| 81 | + | if args[2] != nil { | |
| 82 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 83 | + | } | |
| 84 | + | var arg3 domain.Principal | |
| 85 | + | if args[3] != nil { | |
| 86 | + | arg3 = args[3].(domain.Principal) | |
| 87 | + | } | |
| 88 | + | run( | |
| 89 | + | arg0, | |
| 90 | + | arg1, | |
| 91 | + | arg2, | |
| 92 | + | arg3, | |
| 93 | + | ) | |
| 94 | + | }) | |
| 95 | + | return _c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *WorkflowTrigger_OnPush_Call) Return() *WorkflowTrigger_OnPush_Call { | |
| 99 | + | _c.Call.Return() | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *WorkflowTrigger_OnPush_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)) *WorkflowTrigger_OnPush_Call { | |
| 104 | + | _c.Run(run) | |
| 105 | + | return _c | |
| 106 | + | } | |
A internal/usecase/ci.go +530 -0
| @@ -0,0 +1,530 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "encoding/base64" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "fmt" | |
| 11 | + | "slices" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Defaults of the CI engine. | |
| 23 | + | const ( | |
| 24 | + | // DefaultCIPollTimeout is how long FetchJob holds a call: the contract's 50 s. | |
| 25 | + | DefaultCIPollTimeout = 50 * time.Second | |
| 26 | + | // DefaultCITick is how often the watchdog looks at the jobs in flight. | |
| 27 | + | DefaultCITick = time.Second | |
| 28 | + | // DefaultCIKeepalive is how often WatchJob sends a keepalive. | |
| 29 | + | DefaultCIKeepalive = 30 * time.Second | |
| 30 | + | // DefaultCITriggerQueue is the size of the queue of pushes to plan. | |
| 31 | + | DefaultCITriggerQueue = 256 | |
| 32 | + | // MaxCIChecks is the most checks ListChecks returns. | |
| 33 | + | MaxCIChecks = 1000 | |
| 34 | + | // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log | |
| 35 | + | // streams (GetJobLog). | |
| 36 | + | MaxCILogStreams = 64 | |
| 37 | + | MaxCILogStreamsPerClient = 8 | |
| 38 | + | // maxCIWorkflowBytes is the largest workflow file (the parser's limit). | |
| 39 | + | maxCIWorkflowBytes = 1 << 20 | |
| 40 | + | // maxCIWorkflowFiles bounds the workflow files read per push. | |
| 41 | + | maxCIWorkflowFiles = 256 | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // CIConfig holds what the CI use cases need. Store, Logs, Meta, Repos, Reader, | |
| 45 | + | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | |
| 46 | + | // the defaults of Owner and Retention). | |
| 47 | + | type CIConfig struct { | |
| 48 | + | Store ports.CIStore | |
| 49 | + | Logs ports.CILogStore | |
| 50 | + | Meta ports.RepoMetaStore | |
| 51 | + | Repos ports.GitRepos | |
| 52 | + | Reader ports.RepoReader | |
| 53 | + | Git ports.MergeGit | |
| 54 | + | Diff ports.CIGit | |
| 55 | + | Bus ports.EventBus | |
| 56 | + | Clock ports.Clock | |
| 57 | + | // Auth re-validates the token of a log stream; nil skips it. | |
| 58 | + | Auth ports.Revalidator | |
| 59 | + | // Owner is the owner login of the github context. | |
| 60 | + | Owner string | |
| 61 | + | // PublicURL is the base URL of the instance (github.server_url). | |
| 62 | + | PublicURL string | |
| 63 | + | // Retention is how long runs, jobs, checks and logs are kept. | |
| 64 | + | Retention time.Duration | |
| 65 | + | ||
| 66 | + | // The durations below default to the contract's; tests shorten them. | |
| 67 | + | PollTimeout time.Duration | |
| 68 | + | Tick time.Duration | |
| 69 | + | Keepalive time.Duration | |
| 70 | + | AckTimeout time.Duration | |
| 71 | + | HeartbeatDeadline time.Duration | |
| 72 | + | CancelGrace time.Duration | |
| 73 | + | TimeoutSlack time.Duration | |
| 74 | + | QueueTimeout time.Duration | |
| 75 | + | TriggerQueue int | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (c CIConfig) withDefaults() CIConfig { | |
| 79 | + | def := func(d *time.Duration, v time.Duration) { | |
| 80 | + | if *d <= 0 { | |
| 81 | + | *d = v | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | def(&c.PollTimeout, DefaultCIPollTimeout) | |
| 85 | + | def(&c.Tick, DefaultCITick) | |
| 86 | + | def(&c.Keepalive, DefaultCIKeepalive) | |
| 87 | + | def(&c.AckTimeout, domain.CIAckTimeout) | |
| 88 | + | def(&c.HeartbeatDeadline, domain.CIHeartbeatDeadline) | |
| 89 | + | def(&c.CancelGrace, domain.CICancelGrace) | |
| 90 | + | def(&c.TimeoutSlack, domain.CITimeoutSlack) | |
| 91 | + | def(&c.QueueTimeout, domain.CIQueueTimeout) | |
| 92 | + | def(&c.Retention, domain.CIRetentionDays*24*time.Hour) | |
| 93 | + | if c.TriggerQueue <= 0 { | |
| 94 | + | c.TriggerQueue = DefaultCITriggerQueue | |
| 95 | + | } | |
| 96 | + | return c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // CI is the CI use cases: the workflow trigger, the planner, the assignment of | |
| 100 | + | // jobs to runners, the runner protocol and the owner side (ADR 0025). | |
| 101 | + | // | |
| 102 | + | // Every change of a run goes through its lock (withRun): the records of a run | |
| 103 | + | // are read, changed and written again by one goroutine at a time, so that two | |
| 104 | + | // calls (a runner's update and an owner's cancel) never lose one another's | |
| 105 | + | // write. Assignment takes the assignment lock before the lock of the run. | |
| 106 | + | type CI struct { | |
| 107 | + | cfg CIConfig | |
| 108 | + | boot time.Time | |
| 109 | + | queue chan ciTrigger | |
| 110 | + | ||
| 111 | + | stop context.CancelFunc | |
| 112 | + | ctx context.Context | |
| 113 | + | wg sync.WaitGroup | |
| 114 | + | ||
| 115 | + | locks sync.Map // run id -> *sync.Mutex | |
| 116 | + | assign sync.Mutex | |
| 117 | + | jobs notifier // woken when a job changes: key is the job reference | |
| 118 | + | pending notifier // woken when a job may be ready or a runner was removed | |
| 119 | + | flows *wfCache | |
| 120 | + | ||
| 121 | + | mu sync.Mutex | |
| 122 | + | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 123 | + | seen map[string]time.Time // runner id -> last seen | |
| 124 | + | polls map[string]pollRecord | |
| 125 | + | streams int | |
| 126 | + | perClient map[string]int | |
| 127 | + | closed bool | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | var ( | |
| 131 | + | _ ports.RunnerService = (*CI)(nil) | |
| 132 | + | _ ports.RunService = (*CI)(nil) | |
| 133 | + | _ ports.WorkflowTrigger = (*CI)(nil) | |
| 134 | + | _ ports.CIChecksReader = (*CI)(nil) | |
| 135 | + | ) | |
| 136 | + | ||
| 137 | + | // pollRecord remembers the assignment a poll made, for the retry of a lost | |
| 138 | + | // response (FetchJobRequest.poll_id). The token is kept in memory only. | |
| 139 | + | type pollRecord struct { | |
| 140 | + | pollID string | |
| 141 | + | ref string | |
| 142 | + | token string | |
| 143 | + | hash domain.TokenHash | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // NewCI builds the CI use cases. ctx carries the logger; its cancellation is | |
| 147 | + | // not followed: call Start to run the workers and Close to stop them. | |
| 148 | + | func NewCI(ctx context.Context, cfg CIConfig) *CI { | |
| 149 | + | if cfg.Owner == "" { | |
| 150 | + | panic("usecase: CIConfig.Owner is required") | |
| 151 | + | } | |
| 152 | + | cfg = cfg.withDefaults() | |
| 153 | + | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 154 | + | return &CI{ | |
| 155 | + | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | |
| 156 | + | ctx: run, stop: stop, flows: newWfCache(64), | |
| 157 | + | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| 158 | + | perClient: map[string]int{}, | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // Start runs the trigger worker and the watchdog, and plans again the runs | |
| 163 | + | // that were open when the server stopped. | |
| 164 | + | func (c *CI) Start() { | |
| 165 | + | c.wg.Add(2) | |
| 166 | + | go c.triggerLoop() | |
| 167 | + | go c.watchdog() | |
| 168 | + | c.wg.Add(1) | |
| 169 | + | go func() { | |
| 170 | + | defer c.wg.Done() | |
| 171 | + | c.resume() | |
| 172 | + | }() | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Close stops the workers and waits for them. | |
| 176 | + | func (c *CI) Close(_ context.Context) { | |
| 177 | + | c.mu.Lock() | |
| 178 | + | c.closed = true | |
| 179 | + | c.mu.Unlock() | |
| 180 | + | c.stop() | |
| 181 | + | c.wg.Wait() | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() } | |
| 185 | + | ||
| 186 | + | // --- identifiers and secrets --- | |
| 187 | + | ||
| 188 | + | func newToken(prefix string) string { | |
| 189 | + | var raw [32]byte | |
| 190 | + | _, _ = rand.Read(raw[:]) | |
| 191 | + | return prefix + base64.RawURLEncoding.EncodeToString(raw[:]) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func newRunnerID() string { | |
| 195 | + | var raw [8]byte | |
| 196 | + | _, _ = rand.Read(raw[:]) | |
| 197 | + | return hex.EncodeToString(raw[:]) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func jobRef(j domain.CIJob) string { return fmt.Sprintf("%d/%d/%s", j.RunID, j.RunAttempt, j.ID) } | |
| 201 | + | ||
| 202 | + | // --- locks and notifications --- | |
| 203 | + | ||
| 204 | + | // notifier wakes the goroutines waiting on a key. A channel exists only while | |
| 205 | + | // somebody waits on it. | |
| 206 | + | type notifier struct { | |
| 207 | + | mu sync.Mutex | |
| 208 | + | ch map[string]chan struct{} | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // wait returns a channel closed by the next notify of key. | |
| 212 | + | func (n *notifier) wait(key string) <-chan struct{} { | |
| 213 | + | n.mu.Lock() | |
| 214 | + | defer n.mu.Unlock() | |
| 215 | + | if n.ch == nil { | |
| 216 | + | n.ch = map[string]chan struct{}{} | |
| 217 | + | } | |
| 218 | + | c, ok := n.ch[key] | |
| 219 | + | if !ok { | |
| 220 | + | c = make(chan struct{}) | |
| 221 | + | n.ch[key] = c | |
| 222 | + | } | |
| 223 | + | return c | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (n *notifier) notify(key string) { | |
| 227 | + | n.mu.Lock() | |
| 228 | + | defer n.mu.Unlock() | |
| 229 | + | if c, ok := n.ch[key]; ok { | |
| 230 | + | close(c) | |
| 231 | + | delete(n.ch, key) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | const queueKey = "q" | |
| 236 | + | ||
| 237 | + | func (c *CI) wakeQueue() { c.pending.notify(queueKey) } | |
| 238 | + | ||
| 239 | + | func (c *CI) runLock(runID int64) *sync.Mutex { | |
| 240 | + | m, _ := c.locks.LoadOrStore(runID, &sync.Mutex{}) | |
| 241 | + | return m.(*sync.Mutex) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // --- run state --- | |
| 245 | + | ||
| 246 | + | // runState is a run being changed under its lock: the run, the jobs of one of | |
| 247 | + | // its attempts, and what must be written. flush writes everything in one batch | |
| 248 | + | // and publishes the events. | |
| 249 | + | type runState struct { | |
| 250 | + | c *CI | |
| 251 | + | run domain.CIRun | |
| 252 | + | attempt int32 | |
| 253 | + | jobs []domain.CIJob | |
| 254 | + | src *domain.CIRunSource | |
| 255 | + | ||
| 256 | + | runDirty bool | |
| 257 | + | dirty map[string]bool | |
| 258 | + | // chk marks jobs whose check must be written even when the job itself did | |
| 259 | + | // not change (a rerun moves every check to the new attempt). | |
| 260 | + | checks map[string]bool | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // withRun locks the run, loads it with the jobs of attempt (0 for the latest) | |
| 264 | + | // and calls fn. The run is found by id alone through its source. | |
| 265 | + | func (c *CI) withRun(ctx context.Context, runID int64, attempt int32, fn func(rs *runState) error) error { | |
| 266 | + | lock := c.runLock(runID) | |
| 267 | + | lock.Lock() | |
| 268 | + | defer lock.Unlock() | |
| 269 | + | rs, err := c.loadRun(ctx, runID, attempt) | |
| 270 | + | if err != nil { | |
| 271 | + | return err | |
| 272 | + | } | |
| 273 | + | return fn(rs) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (c *CI) loadRun(ctx context.Context, runID int64, attempt int32) (*runState, error) { | |
| 277 | + | src, err := c.cfg.Store.GetRunSource(ctx, runID) | |
| 278 | + | if err != nil { | |
| 279 | + | return nil, err | |
| 280 | + | } | |
| 281 | + | run, err := c.cfg.Store.GetRun(ctx, src.Repo, runID) | |
| 282 | + | if err != nil { | |
| 283 | + | return nil, err | |
| 284 | + | } | |
| 285 | + | if attempt <= 0 { | |
| 286 | + | attempt = run.Attempt | |
| 287 | + | } | |
| 288 | + | jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt) | |
| 289 | + | if err != nil { | |
| 290 | + | return nil, err | |
| 291 | + | } | |
| 292 | + | return &runState{c: c, run: run, attempt: attempt, jobs: jobs, src: &src, dirty: map[string]bool{}, checks: map[string]bool{}}, nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // latest reports whether the state holds the latest attempt of the run. | |
| 296 | + | func (rs *runState) latest() bool { return rs.attempt == rs.run.Attempt } | |
| 297 | + | ||
| 298 | + | func (rs *runState) find(id string) *domain.CIJob { | |
| 299 | + | for i := range rs.jobs { | |
| 300 | + | if rs.jobs[i].ID == id { | |
| 301 | + | return &rs.jobs[i] | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | return nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (rs *runState) touch(j *domain.CIJob) { rs.dirty[j.ID] = true } | |
| 308 | + | ||
| 309 | + | // checkOf builds the check of a job. | |
| 310 | + | func (rs *runState) checkOf(j domain.CIJob) domain.CICheck { | |
| 311 | + | return domain.CICheck{ | |
| 312 | + | ID: j.ID, Repo: rs.run.Repo, Sha: rs.run.Sha, Name: j.Name, Status: j.Status(), Conclusion: j.Conclusion, | |
| 313 | + | RunID: rs.run.ID, JobID: j.ID, Attempt: rs.run.Attempt, WorkflowName: rs.run.WorkflowName, | |
| 314 | + | CreatedAt: j.CreatedAt, StartedAt: j.StartedAt, CompletedAt: j.CompletedAt, | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // flush recomputes the status of the run, writes the changed jobs, their | |
| 319 | + | // checks and the run in one batch, and publishes RunUpdated and JobUpdated. | |
| 320 | + | func (rs *runState) flush(ctx context.Context) error { | |
| 321 | + | c := rs.c | |
| 322 | + | now := c.now() | |
| 323 | + | var w ports.CIWrite | |
| 324 | + | var changedJobs []int | |
| 325 | + | for i := range rs.jobs { | |
| 326 | + | if rs.dirty[rs.jobs[i].ID] { | |
| 327 | + | w.Jobs = append(w.Jobs, rs.jobs[i]) | |
| 328 | + | changedJobs = append(changedJobs, i) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | if rs.latest() { | |
| 332 | + | for i := range rs.jobs { | |
| 333 | + | if rs.dirty[rs.jobs[i].ID] || rs.checks[rs.jobs[i].ID] { | |
| 334 | + | w.Checks = append(w.Checks, rs.checkOf(rs.jobs[i])) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if next := rs.recompute(now); next != rs.run { | |
| 338 | + | rs.run, rs.runDirty = next, true | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | if rs.runDirty { | |
| 342 | + | w.Runs = []domain.CIRun{rs.run} | |
| 343 | + | } | |
| 344 | + | if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 { | |
| 345 | + | return nil | |
| 346 | + | } | |
| 347 | + | v, err := c.cfg.Store.Apply(ctx, w) | |
| 348 | + | if err != nil { | |
| 349 | + | return fmt.Errorf("write run %d: %w", rs.run.ID, err) | |
| 350 | + | } | |
| 351 | + | for _, i := range changedJobs { | |
| 352 | + | rs.jobs[i].Version = v | |
| 353 | + | } | |
| 354 | + | if rs.runDirty { | |
| 355 | + | rs.run.Version = v | |
| 356 | + | } | |
| 357 | + | changedRun := rs.runDirty | |
| 358 | + | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | |
| 359 | + | ||
| 360 | + | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | |
| 361 | + | if err != nil { | |
| 362 | + | for _, i := range changedJobs { | |
| 363 | + | c.jobs.notify(jobRef(rs.jobs[i])) | |
| 364 | + | } | |
| 365 | + | return nil // the repository is gone: nobody to tell | |
| 366 | + | } | |
| 367 | + | for _, i := range changedJobs { | |
| 368 | + | j := rs.jobs[i] | |
| 369 | + | lines, _ := c.cfg.Logs.Count(ctx, ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}) | |
| 370 | + | c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, Lines: lines, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 371 | + | c.jobs.notify(jobRef(j)) | |
| 372 | + | if j.Phase == domain.CIPhaseReady { | |
| 373 | + | c.wakeQueue() | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | if changedRun { | |
| 377 | + | c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: rs.run, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 378 | + | } | |
| 379 | + | return nil | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // recompute returns the run with the status, the conclusion and the | |
| 383 | + | // timestamps its jobs imply. | |
| 384 | + | func (rs *runState) recompute(now time.Time) domain.CIRun { | |
| 385 | + | run := rs.run | |
| 386 | + | if run.Status == domain.CICompleted && len(rs.jobs) == 0 { | |
| 387 | + | return run // an invalid workflow file: failed from the start | |
| 388 | + | } | |
| 389 | + | allEnded := len(rs.jobs) > 0 | |
| 390 | + | started := time.Time{} | |
| 391 | + | var failed, cancelled bool | |
| 392 | + | for _, j := range rs.jobs { | |
| 393 | + | if !j.Ended() { | |
| 394 | + | allEnded = false | |
| 395 | + | } | |
| 396 | + | // A job kept from an earlier attempt did not start in this one. | |
| 397 | + | if j.Attempt == rs.attempt && !j.StartedAt.IsZero() && (started.IsZero() || j.StartedAt.Before(started)) { | |
| 398 | + | started = j.StartedAt | |
| 399 | + | } | |
| 400 | + | switch { | |
| 401 | + | case j.Conclusion.IsFailure(): | |
| 402 | + | failed = true | |
| 403 | + | case j.Conclusion == domain.CICancelled: | |
| 404 | + | cancelled = true | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | if run.StartedAt.IsZero() && !started.IsZero() { | |
| 408 | + | run.StartedAt = started | |
| 409 | + | } | |
| 410 | + | switch { | |
| 411 | + | case allEnded: | |
| 412 | + | run.Status, run.CompletedAt = domain.CICompleted, now | |
| 413 | + | switch { | |
| 414 | + | case run.CancelRequested: | |
| 415 | + | run.Conclusion = domain.CICancelled | |
| 416 | + | case failed: | |
| 417 | + | run.Conclusion = domain.CIFailure | |
| 418 | + | case cancelled: | |
| 419 | + | run.Conclusion = domain.CICancelled | |
| 420 | + | default: | |
| 421 | + | run.Conclusion = domain.CISuccess | |
| 422 | + | } | |
| 423 | + | if rs.run.Status == domain.CICompleted { | |
| 424 | + | run.CompletedAt = rs.run.CompletedAt | |
| 425 | + | } | |
| 426 | + | case !run.StartedAt.IsZero(): | |
| 427 | + | run.Status, run.Conclusion, run.CompletedAt = domain.CIInProgress, domain.CINoConclusion, time.Time{} | |
| 428 | + | default: | |
| 429 | + | run.Status, run.Conclusion, run.CompletedAt = domain.CIQueued, domain.CINoConclusion, time.Time{} | |
| 430 | + | } | |
| 431 | + | return run | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // --- access --- | |
| 435 | + | ||
| 436 | + | // readableRepo looks the repository up and applies the read rule. | |
| 437 | + | func (c *CI) readableRepo(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 438 | + | repo, err := lookupRepo(ctx, c.cfg.Meta, name) | |
| 439 | + | if err != nil { | |
| 440 | + | return domain.Repo{}, err | |
| 441 | + | } | |
| 442 | + | if !canRead(p, repo) { | |
| 443 | + | return domain.Repo{}, domain.ErrNotFound | |
| 444 | + | } | |
| 445 | + | return repo, nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // writableRepo is the access rule of cancelling and re-running: a principal | |
| 449 | + | // whose token holds ci:write on the repository sees the repository for this | |
| 450 | + | // action even without repo:read, as for merge requests. | |
| 451 | + | func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 452 | + | repo, err := lookupRepo(ctx, c.cfg.Meta, name) | |
| 453 | + | if err != nil { | |
| 454 | + | return domain.Repo{}, err | |
| 455 | + | } | |
| 456 | + | if !canRead(p, repo) && !p.Allows(domain.ScopeCIWrite, name) { | |
| 457 | + | return domain.Repo{}, domain.ErrNotFound | |
| 458 | + | } | |
| 459 | + | if err := scopeAccess(p, repo, domain.ScopeCIWrite); err != nil { | |
| 460 | + | return domain.Repo{}, err | |
| 461 | + | } | |
| 462 | + | return repo, nil | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | // canManageRunners is the access rule of runners: runner:register on a token | |
| 466 | + | // that is not restricted to repositories (a runner serves every repository), | |
| 467 | + | // or root. | |
| 468 | + | func canManageRunners(p domain.Principal) error { | |
| 469 | + | switch { | |
| 470 | + | case p.IsAnonymous(): | |
| 471 | + | return domain.ErrUnauthenticated | |
| 472 | + | case !p.HasScope(domain.ScopeRunnerRegister): | |
| 473 | + | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | |
| 474 | + | case len(p.Repos()) > 0: | |
| 475 | + | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | |
| 476 | + | } | |
| 477 | + | return nil | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // --- small helpers --- | |
| 481 | + | ||
| 482 | + | func truncateRunes(s string, n int) string { | |
| 483 | + | if len(s) <= n { | |
| 484 | + | return s | |
| 485 | + | } | |
| 486 | + | r := []rune(s) | |
| 487 | + | if len(r) <= n { | |
| 488 | + | return s | |
| 489 | + | } | |
| 490 | + | return string(r[:n]) | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // cutText cuts s to n bytes at a character boundary. | |
| 494 | + | func cutText(s string, n int) string { | |
| 495 | + | if len(s) <= n { | |
| 496 | + | return s | |
| 497 | + | } | |
| 498 | + | for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 { | |
| 499 | + | n-- | |
| 500 | + | } | |
| 501 | + | return s[:n] | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func sortedKeys[V any](m map[string]V) []string { | |
| 505 | + | keys := make([]string, 0, len(m)) | |
| 506 | + | for k := range m { | |
| 507 | + | keys = append(keys, k) | |
| 508 | + | } | |
| 509 | + | slices.Sort(keys) | |
| 510 | + | return keys | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // ctxLog returns the logger of the context, addressable so that the level | |
| 514 | + | // methods can be chained on it. | |
| 515 | + | func ctxLog(ctx context.Context) *zerowrap.Logger { | |
| 516 | + | l := zerowrap.FromCtx(ctx) | |
| 517 | + | return &l | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // logRun returns the logger of the context with the fields of the run. | |
| 521 | + | func logRun(ctx context.Context, run domain.CIRun) zerowrap.Logger { | |
| 522 | + | return zerowrap.FromCtx(ctx).WithFields(map[string]any{"repo": run.Repo.String(), "run_id": run.ID}) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func isHex40(s string) bool { | |
| 526 | + | if len(s) != 40 { | |
| 527 | + | return false | |
| 528 | + | } | |
| 529 | + | return strings.Trim(s, "0123456789abcdef") == "" | |
| 530 | + | } | |
A internal/usecase/ci_plan.go +489 -0
| @@ -0,0 +1,489 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "fmt" | |
| 10 | + | "math" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-actions/expr" | |
| 17 | + | "bnema.dev/ermite-actions/template" | |
| 18 | + | "bnema.dev/ermite-actions/workflow" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // maxJobError bounds JobError text (4 KiB, run.proto). | |
| 23 | + | const maxJobError = 4 << 10 | |
| 24 | + | ||
| 25 | + | // wfCache keeps the parsed workflows of the runs in flight, so that planning a | |
| 26 | + | // job does not parse the file again. | |
| 27 | + | type wfCache struct { | |
| 28 | + | mu sync.Mutex | |
| 29 | + | max int | |
| 30 | + | m map[int64]*workflow.Workflow | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newWfCache(max int) *wfCache { return &wfCache{max: max, m: map[int64]*workflow.Workflow{}} } | |
| 34 | + | ||
| 35 | + | func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) { | |
| 36 | + | w.mu.Lock() | |
| 37 | + | if wf, ok := w.m[runID]; ok { | |
| 38 | + | w.mu.Unlock() | |
| 39 | + | return wf, nil | |
| 40 | + | } | |
| 41 | + | w.mu.Unlock() | |
| 42 | + | wf, errs := workflow.Parse(path, []byte(yaml)) | |
| 43 | + | if len(errs) > 0 { | |
| 44 | + | return nil, fmt.Errorf("the workflow file no longer parses: %s", errs[0].Error()) | |
| 45 | + | } | |
| 46 | + | w.mu.Lock() | |
| 47 | + | defer w.mu.Unlock() | |
| 48 | + | if len(w.m) >= w.max { | |
| 49 | + | clear(w.m) | |
| 50 | + | } | |
| 51 | + | w.m[runID] = wf | |
| 52 | + | return wf, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // unsupported names the feature of a job that CI-1 does not run, or "". Such | |
| 56 | + | // a job fails with "not supported yet: <feature>" instead of running wrong. | |
| 57 | + | // Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3). | |
| 58 | + | func unsupported(j *workflow.Job) string { | |
| 59 | + | switch { | |
| 60 | + | case j.IsReusable(): | |
| 61 | + | return "reusable workflows (uses at the job level)" | |
| 62 | + | case j.Container != nil: | |
| 63 | + | return "container" | |
| 64 | + | case j.Services != nil: | |
| 65 | + | return "services" | |
| 66 | + | case j.Environment != nil: | |
| 67 | + | return "environment" | |
| 68 | + | case j.Concurrency != nil: | |
| 69 | + | return "concurrency" | |
| 70 | + | case present(j.ContinueOnError): | |
| 71 | + | return "continue-on-error" | |
| 72 | + | } | |
| 73 | + | for _, s := range j.Steps { | |
| 74 | + | switch { | |
| 75 | + | case s.Uses != nil: | |
| 76 | + | return "uses: " + s.Uses.Value | |
| 77 | + | case present(s.ContinueOnError): | |
| 78 | + | return "continue-on-error on a step" | |
| 79 | + | case present(s.TimeoutMinutes): | |
| 80 | + | return "timeout-minutes on a step" | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return "" | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // present reports whether an optional key is given and is not a literal | |
| 87 | + | // false (continue-on-error: false is the default). | |
| 88 | + | func present(t template.Token) bool { | |
| 89 | + | if t == nil { | |
| 90 | + | return false | |
| 91 | + | } | |
| 92 | + | if b, ok := t.(*template.Boolean); ok && !b.Value { | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | return true | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // instances returns the indexes of the records of the job key, in order. | |
| 99 | + | func (rs *runState) instances(key string) []int { | |
| 100 | + | var out []int | |
| 101 | + | for i, j := range rs.jobs { | |
| 102 | + | if j.Key == key { | |
| 103 | + | out = append(out, i) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | return out | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (rs *runState) sortJobs() { | |
| 110 | + | slices.SortStableFunc(rs.jobs, func(a, b domain.CIJob) int { | |
| 111 | + | if a.Index != b.Index { | |
| 112 | + | return a.Index - b.Index | |
| 113 | + | } | |
| 114 | + | return a.Combo - b.Combo | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // result is the aggregate conclusion of the instances of a job as the needs | |
| 119 | + | // context shows it: success, failure, cancelled or skipped. | |
| 120 | + | func result(insts []domain.CIJob) string { | |
| 121 | + | var failed, cancelled, ok bool | |
| 122 | + | for _, j := range insts { | |
| 123 | + | switch j.Conclusion { | |
| 124 | + | case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired: | |
| 125 | + | failed = true | |
| 126 | + | case domain.CICancelled: | |
| 127 | + | cancelled = true | |
| 128 | + | case domain.CISkipped: | |
| 129 | + | default: | |
| 130 | + | ok = true | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | switch { | |
| 134 | + | case failed: | |
| 135 | + | return "failure" | |
| 136 | + | case cancelled: | |
| 137 | + | return "cancelled" | |
| 138 | + | case ok: | |
| 139 | + | return "success" | |
| 140 | + | } | |
| 141 | + | return "skipped" | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // needsOf computes the needs context and the status functions of a job from | |
| 145 | + | // the jobs it needs, and whether they all have ended. | |
| 146 | + | func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) { | |
| 147 | + | needs = expr.NewDictionary() | |
| 148 | + | st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested} | |
| 149 | + | for _, n := range j.Needs { | |
| 150 | + | var insts []domain.CIJob | |
| 151 | + | for _, i := range rs.instances(n) { | |
| 152 | + | if !rs.jobs[i].Ended() { | |
| 153 | + | return nil, st, false | |
| 154 | + | } | |
| 155 | + | insts = append(insts, rs.jobs[i]) | |
| 156 | + | } | |
| 157 | + | if len(insts) == 0 { | |
| 158 | + | return nil, st, false | |
| 159 | + | } | |
| 160 | + | res := result(insts) | |
| 161 | + | switch res { | |
| 162 | + | case "failure": | |
| 163 | + | st.Failure, st.Success = true, false | |
| 164 | + | case "cancelled": | |
| 165 | + | st.Cancelled, st.Success = true, false | |
| 166 | + | case "skipped": | |
| 167 | + | st.Success = false | |
| 168 | + | } | |
| 169 | + | outs := expr.NewDictionary() | |
| 170 | + | for _, in := range insts { | |
| 171 | + | for _, k := range sortedKeys(in.Outputs) { | |
| 172 | + | // Instances of a matrix share names: the last non-empty value wins. | |
| 173 | + | if v := in.Outputs[k]; v != "" || !hasKey(outs, k) { | |
| 174 | + | setKey(outs, k, expr.String(v)) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | d := expr.NewDictionary() | |
| 179 | + | d.Add("result", expr.String(res)) | |
| 180 | + | d.Add("outputs", outs) | |
| 181 | + | needs.Add(n, d) | |
| 182 | + | } | |
| 183 | + | return needs, st, true | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func hasKey(d *expr.Dictionary, k string) bool { _, ok := d.Get(k); return ok } | |
| 187 | + | ||
| 188 | + | // setKey sets a key of a dictionary, replacing an existing value. | |
| 189 | + | func setKey(d *expr.Dictionary, k string, v expr.Value) { | |
| 190 | + | if !d.Add(k, v) { | |
| 191 | + | pairs := d.Pairs() | |
| 192 | + | *d = *expr.NewDictionary() | |
| 193 | + | for _, p := range pairs { | |
| 194 | + | if strings.EqualFold(p.Key, k) { | |
| 195 | + | d.Add(p.Key, v) | |
| 196 | + | } else { | |
| 197 | + | d.Add(p.Key, p.Value) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // githubValue is the github context of the run with the keys of the job. | |
| 204 | + | func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, error) { | |
| 205 | + | v, err := expr.FromJSON([]byte(rs.src.GithubJSON)) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, fmt.Errorf("the stored github context is not valid: %w", err) | |
| 208 | + | } | |
| 209 | + | d, ok := v.(*expr.Dictionary) | |
| 210 | + | if !ok { | |
| 211 | + | return nil, fmt.Errorf("the stored github context is not an object") | |
| 212 | + | } | |
| 213 | + | d.Add("job", expr.String(key)) | |
| 214 | + | d.Add("run_attempt", expr.String(strconv.Itoa(int(attempt)))) | |
| 215 | + | return d, nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // trim cuts a message for a job error. | |
| 219 | + | func trim(s string) string { return cutText(s, maxJobError) } | |
| 220 | + | ||
| 221 | + | // end ends a job with a conclusion and an error text, completes its steps like | |
| 222 | + | // the server does, and applies fail-fast to its siblings. | |
| 223 | + | func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg string) { | |
| 224 | + | now := rs.c.now() | |
| 225 | + | cancelish := conclusion == domain.CICancelled || conclusion == domain.CITimedOut | |
| 226 | + | for i := range j.Steps { | |
| 227 | + | s := &j.Steps[i] | |
| 228 | + | if s.State == domain.CIStepCompleted { | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | s.Conclusion = domain.CICancelled | |
| 232 | + | if s.State == domain.CIStepPending && !cancelish { | |
| 233 | + | s.Conclusion = domain.CISkipped | |
| 234 | + | } | |
| 235 | + | s.State, s.CompletedAt = domain.CIStepCompleted, now | |
| 236 | + | } | |
| 237 | + | j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now | |
| 238 | + | if msg != "" { | |
| 239 | + | j.Error = trim(msg) | |
| 240 | + | } | |
| 241 | + | if j.HasToken() { | |
| 242 | + | j.TokenValidUntil = now.Add(domain.CITokenGrace) | |
| 243 | + | } | |
| 244 | + | rs.touch(j) | |
| 245 | + | rs.c.mu.Lock() | |
| 246 | + | delete(rs.c.heartbeats, jobRef(*j)) | |
| 247 | + | rs.c.mu.Unlock() | |
| 248 | + | ||
| 249 | + | if j.FailFast && conclusion.IsFailure() { | |
| 250 | + | for i := range rs.jobs { | |
| 251 | + | s := &rs.jobs[i] | |
| 252 | + | if s.Key != j.Key || s.ID == j.ID || s.Ended() { | |
| 253 | + | continue | |
| 254 | + | } | |
| 255 | + | if s.Active() { | |
| 256 | + | rs.requestCancel(s, domain.CICancelFailFast, "Canceled because another job of the matrix failed (fail-fast)") | |
| 257 | + | } else { | |
| 258 | + | rs.end(s, domain.CICancelled, "") | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // requestCancel asks the runner of a job to stop, once: the first reason wins. | |
| 265 | + | func (rs *runState) requestCancel(j *domain.CIJob, reason domain.CICancelReason, msg string) { | |
| 266 | + | if j.CancelReason != domain.CICancelNone || j.Ended() { | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, msg, rs.c.now() | |
| 270 | + | rs.touch(j) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // cancelAll cancels what is left of the run: jobs that no runner has are over | |
| 274 | + | // at once, the ones a runner has get a cancel request. | |
| 275 | + | func (rs *runState) cancelAll(msg string) { | |
| 276 | + | for i := range rs.jobs { | |
| 277 | + | j := &rs.jobs[i] | |
| 278 | + | switch { | |
| 279 | + | case j.Ended(): | |
| 280 | + | case j.Active(): | |
| 281 | + | rs.requestCancel(j, domain.CICancelUser, msg) | |
| 282 | + | default: | |
| 283 | + | rs.end(j, domain.CICancelled, "") | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // advance plans the jobs that became ready and promotes queued matrix | |
| 289 | + | // instances, until nothing changes. | |
| 290 | + | func (rs *runState) advance(ctx context.Context) error { | |
| 291 | + | if !rs.latest() { | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | if rs.run.CancelRequested { | |
| 295 | + | rs.cancelAll("Canceled by the owner") | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML) | |
| 299 | + | if err != nil { | |
| 300 | + | for i := range rs.jobs { | |
| 301 | + | if !rs.jobs[i].Ended() && !rs.jobs[i].Active() { | |
| 302 | + | rs.end(&rs.jobs[i], domain.CIFailure, err.Error()) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | return nil | |
| 306 | + | } | |
| 307 | + | for changed := true; changed; { | |
| 308 | + | changed = false | |
| 309 | + | for _, wj := range wf.Jobs { | |
| 310 | + | insts := rs.instances(wj.ID) | |
| 311 | + | if len(insts) != 1 { | |
| 312 | + | continue | |
| 313 | + | } | |
| 314 | + | rec := rs.jobs[insts[0]] | |
| 315 | + | if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | |
| 316 | + | continue | |
| 317 | + | } | |
| 318 | + | needs, st, ready := rs.needsOf(wj) | |
| 319 | + | if !ready { | |
| 320 | + | continue | |
| 321 | + | } | |
| 322 | + | rs.planJob(ctx, wj, insts[0], needs, st) | |
| 323 | + | changed = true | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | rs.promote() | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // promote makes planned instances ready while max-parallel allows it. | |
| 331 | + | func (rs *runState) promote() { | |
| 332 | + | now := rs.c.now() | |
| 333 | + | counts := map[string]int{} | |
| 334 | + | for _, j := range rs.jobs { | |
| 335 | + | if j.Phase == domain.CIPhaseReady || j.Active() { | |
| 336 | + | counts[j.Key]++ | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | for i := range rs.jobs { | |
| 340 | + | j := &rs.jobs[i] | |
| 341 | + | if !j.Planned || j.Phase != domain.CIPhaseWaiting { | |
| 342 | + | continue | |
| 343 | + | } | |
| 344 | + | if j.MaxParallel > 0 && counts[j.Key] >= j.MaxParallel { | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | j.Phase, j.ReadyAt = domain.CIPhaseReady, now | |
| 348 | + | counts[j.Key]++ | |
| 349 | + | rs.touch(j) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // plan evaluates a job whose needs have ended: its condition, its strategy and | |
| 354 | + | // the parts of every instance the server decides (name, labels, timeout, | |
| 355 | + | // contexts). The record at idx becomes the first instance. | |
| 356 | + | func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) { | |
| 357 | + | rec := rs.jobs[idx] | |
| 358 | + | fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) } | |
| 359 | + | ||
| 360 | + | gh, err := rs.githubValue(wj.ID, rs.run.Attempt) | |
| 361 | + | if err != nil { | |
| 362 | + | fail(err.Error()) | |
| 363 | + | return | |
| 364 | + | } | |
| 365 | + | env := template.Env{Contexts: map[string]expr.Value{ | |
| 366 | + | "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 367 | + | }} | |
| 368 | + | run, err := wj.EvaluateIf(ctx, env, st) | |
| 369 | + | switch { | |
| 370 | + | case err != nil: | |
| 371 | + | fail(err.Error()) | |
| 372 | + | return | |
| 373 | + | case !run: | |
| 374 | + | rs.jobs[idx].Name = rs.skippedName(ctx, wj, env) | |
| 375 | + | rs.end(&rs.jobs[idx], domain.CISkipped, "") | |
| 376 | + | return | |
| 377 | + | } | |
| 378 | + | if feature := unsupported(wj); feature != "" { | |
| 379 | + | fail("not supported yet: " + feature) | |
| 380 | + | return | |
| 381 | + | } | |
| 382 | + | plan, err := wj.EvaluateStrategy(ctx, env) | |
| 383 | + | if err != nil { | |
| 384 | + | fail(err.Error()) | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | insts := make([]domain.CIJob, 0, len(plan.Combos)) | |
| 389 | + | for i := range plan.Combos { | |
| 390 | + | menv, err := plan.MatrixEnv(env, i) | |
| 391 | + | if err != nil { | |
| 392 | + | fail(err.Error()) | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | inst, err := rs.instance(ctx, wj, plan, rec, i, menv, needs) | |
| 396 | + | if err != nil { | |
| 397 | + | fail(err.Error()) | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | insts = append(insts, inst) | |
| 401 | + | } | |
| 402 | + | rs.jobs[idx] = insts[0] | |
| 403 | + | rs.touch(&rs.jobs[idx]) | |
| 404 | + | for _, inst := range insts[1:] { | |
| 405 | + | rs.jobs = append(rs.jobs, inst) | |
| 406 | + | rs.dirty[inst.ID] = true | |
| 407 | + | } | |
| 408 | + | rs.sortJobs() | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // skippedName is the best name for a job that did not run: its evaluated name | |
| 412 | + | // when that does not read the matrix, else its key. | |
| 413 | + | func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env template.Env) string { | |
| 414 | + | if wj.HasName { | |
| 415 | + | if name, err := template.EvaluateString(ctx, wj.Name, env); err == nil && name != "" { | |
| 416 | + | return name | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | return wj.ID | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // instance builds the planned record of combination i of a job. | |
| 423 | + | func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) { | |
| 424 | + | j := base | |
| 425 | + | j.Combo = i | |
| 426 | + | j.ID = domain.NewJobID(rs.run.ID, base.Index, i) | |
| 427 | + | if i > 0 { | |
| 428 | + | j.Version = 0 | |
| 429 | + | } | |
| 430 | + | name, err := wj.DisplayName(ctx, env, plan, i) | |
| 431 | + | if err != nil { | |
| 432 | + | return j, err | |
| 433 | + | } | |
| 434 | + | labels, _, err := wj.EvaluateRunsOn(ctx, env) | |
| 435 | + | if err != nil { | |
| 436 | + | return j, err | |
| 437 | + | } | |
| 438 | + | if len(labels) == 0 { | |
| 439 | + | return j, fmt.Errorf("job '%s' has no runs-on labels", wj.ID) | |
| 440 | + | } | |
| 441 | + | timeout := int32(domain.CIDefaultTimeoutMinutes) | |
| 442 | + | if wj.TimeoutMinutes != nil { | |
| 443 | + | n, err := template.EvaluateNumber(ctx, wj.TimeoutMinutes, env) | |
| 444 | + | if err != nil { | |
| 445 | + | return j, err | |
| 446 | + | } | |
| 447 | + | timeout = int32(min(max(math.Round(n), 1), 525600)) //nolint:gosec // clamped | |
| 448 | + | } | |
| 449 | + | gh, err := rs.githubValue(wj.ID, rs.run.Attempt) | |
| 450 | + | if err != nil { | |
| 451 | + | return j, err | |
| 452 | + | } | |
| 453 | + | matrix := plan.Combos[i].Values | |
| 454 | + | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrix) | |
| 455 | + | if err != nil { | |
| 456 | + | return j, err | |
| 457 | + | } | |
| 458 | + | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | |
| 459 | + | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | |
| 460 | + | j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos) | |
| 461 | + | j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel | |
| 462 | + | j.Planned, j.Phase = true, domain.CIPhaseWaiting | |
| 463 | + | return j, nil | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func compactJSON(s string) string { | |
| 467 | + | var b bytes.Buffer | |
| 468 | + | if err := json.Compact(&b, []byte(s)); err != nil { | |
| 469 | + | return "{}" | |
| 470 | + | } | |
| 471 | + | return b.String() | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // contextsJSON is the JSON object of the contexts the server computes for a | |
| 475 | + | // job (Job.contexts in runner.proto). | |
| 476 | + | func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Dictionary) (string, error) { | |
| 477 | + | out := map[string]json.RawMessage{} | |
| 478 | + | for k, v := range map[string]expr.Value{ | |
| 479 | + | "github": github, "needs": needs, "strategy": strategy, "matrix": matrix, | |
| 480 | + | "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(), | |
| 481 | + | } { | |
| 482 | + | out[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | |
| 483 | + | } | |
| 484 | + | b, err := json.Marshal(out) | |
| 485 | + | if err != nil { | |
| 486 | + | return "", err | |
| 487 | + | } | |
| 488 | + | return string(b), nil | |
| 489 | + | } | |
A internal/usecase/ci_runner.go +771 -0
| @@ -0,0 +1,771 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // maxVersionLen bounds RegisterRunner.version. | |
| 21 | + | const maxVersionLen = 64 | |
| 22 | + | ||
| 23 | + | // --- registration --- | |
| 24 | + | ||
| 25 | + | // Register adds a runner and returns its token, shown once. The principal | |
| 26 | + | // needs runner:register on a token that is not restricted to repositories, or | |
| 27 | + | // to be root. A name in use is domain.ErrAlreadyExists unless Replace is set, | |
| 28 | + | // which removes the old runner (its token stops working at once). | |
| 29 | + | func (c *CI) Register(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error) { | |
| 30 | + | if err := canManageRunners(p); err != nil { | |
| 31 | + | return ports.RegisteredRunner{}, err | |
| 32 | + | } | |
| 33 | + | if err := domain.ParseRunnerName(params.Name); err != nil { | |
| 34 | + | return ports.RegisteredRunner{}, err | |
| 35 | + | } | |
| 36 | + | labels, err := domain.ParseRunnerLabels(params.Labels) | |
| 37 | + | if err != nil { | |
| 38 | + | return ports.RegisteredRunner{}, err | |
| 39 | + | } | |
| 40 | + | var v domain.Violations | |
| 41 | + | if len(params.Version) > maxVersionLen || !utf8.ValidString(params.Version) { | |
| 42 | + | v.Add("version", "is at most 64 characters") | |
| 43 | + | } | |
| 44 | + | switch params.Isolation { | |
| 45 | + | case domain.CIIsolationVM, domain.CIIsolationContainerSysbox, domain.CIIsolationContainerPrivileged: | |
| 46 | + | default: | |
| 47 | + | v.Add("isolation", "is required") | |
| 48 | + | } | |
| 49 | + | if params.Arch != "x64" && params.Arch != "arm64" { | |
| 50 | + | v.Add("arch", `must be "x64" or "arm64"`) | |
| 51 | + | } | |
| 52 | + | if params.OS != "linux" { | |
| 53 | + | v.Add("os", `must be "linux"`) | |
| 54 | + | } | |
| 55 | + | if err := v.Err(); err != nil { | |
| 56 | + | return ports.RegisteredRunner{}, err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | token := newToken(domain.RunnerTokenPrefix) | |
| 60 | + | r := domain.CIRunner{ | |
| 61 | + | ID: newRunnerID(), Name: params.Name, Labels: domain.RunnerLabels(labels, params.OS, params.Arch), | |
| 62 | + | Isolation: params.Isolation, Version: params.Version, Arch: params.Arch, OS: params.OS, | |
| 63 | + | RegisteredAt: c.now(), TokenHash: domain.HashTokenSecret(token), | |
| 64 | + | } | |
| 65 | + | removed, err := c.cfg.Store.CreateRunner(ctx, r, params.Replace) | |
| 66 | + | if err != nil { | |
| 67 | + | return ports.RegisteredRunner{}, err | |
| 68 | + | } | |
| 69 | + | log := zerowrap.FromCtx(ctx) | |
| 70 | + | log.Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("isolation", string(r.Isolation)). | |
| 71 | + | Int("replaced", len(removed)).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner registered") | |
| 72 | + | c.forgetRunners(removed) | |
| 73 | + | return ports.RegisteredRunner{Runner: r, Token: token}, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // forgetRunners drops what is kept in memory for removed runners and ends | |
| 77 | + | // their polls in progress. | |
| 78 | + | func (c *CI) forgetRunners(rs []domain.CIRunner) { | |
| 79 | + | c.mu.Lock() | |
| 80 | + | for _, r := range rs { | |
| 81 | + | delete(c.polls, r.ID) | |
| 82 | + | delete(c.seen, r.ID) | |
| 83 | + | } | |
| 84 | + | c.mu.Unlock() | |
| 85 | + | c.wakeQueue() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // runnerFor authenticates a runner token. | |
| 89 | + | func (c *CI) runnerFor(ctx context.Context, secret string) (domain.CIRunner, error) { | |
| 90 | + | if !domain.IsRunnerToken(secret) { | |
| 91 | + | return domain.CIRunner{}, domain.ErrUnauthenticated | |
| 92 | + | } | |
| 93 | + | r, err := c.cfg.Store.FindRunnerByToken(ctx, domain.HashTokenSecret(secret)) | |
| 94 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 95 | + | return domain.CIRunner{}, domain.ErrUnauthenticated | |
| 96 | + | } | |
| 97 | + | return r, err | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (c *CI) markSeen(runnerID string) { | |
| 101 | + | c.mu.Lock() | |
| 102 | + | c.seen[runnerID] = c.now() | |
| 103 | + | c.mu.Unlock() | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // --- FetchJob --- | |
| 107 | + | ||
| 108 | + | var errNoJob = errors.New("no job") | |
| 109 | + | ||
| 110 | + | // FetchJob holds the call until a queued job matches the runner, or the poll | |
| 111 | + | // time passes. A job is given to exactly one runner: assignments are made one | |
| 112 | + | // at a time under a lock and recorded before the answer is sent. | |
| 113 | + | func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error) { | |
| 114 | + | runner, err := c.runnerFor(ctx, secret) | |
| 115 | + | if err != nil { | |
| 116 | + | return ports.FetchJobResult{}, err | |
| 117 | + | } | |
| 118 | + | offered, err := offeredLabels(runner, params.Labels) | |
| 119 | + | if err != nil { | |
| 120 | + | return ports.FetchJobResult{}, err | |
| 121 | + | } | |
| 122 | + | if params.PollID != "" { | |
| 123 | + | if err := checkPollID(params.PollID); err != nil { | |
| 124 | + | return ports.FetchJobResult{}, err | |
| 125 | + | } | |
| 126 | + | if j, ok := c.repeatPoll(ctx, runner, params.PollID); ok { | |
| 127 | + | return ports.FetchJobResult{Job: j}, nil | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | c.markSeen(runner.ID) | |
| 131 | + | deadline := time.NewTimer(c.cfg.PollTimeout) | |
| 132 | + | defer deadline.Stop() | |
| 133 | + | for { | |
| 134 | + | wake := c.pending.wait(queueKey) // before looking: a job made meanwhile wakes us | |
| 135 | + | job, err := c.assignTo(ctx, runner, offered, params.PollID) | |
| 136 | + | switch { | |
| 137 | + | case err == nil: | |
| 138 | + | c.markSeen(runner.ID) | |
| 139 | + | return ports.FetchJobResult{Job: job}, nil | |
| 140 | + | case !errors.Is(err, errNoJob): | |
| 141 | + | return ports.FetchJobResult{}, err | |
| 142 | + | } | |
| 143 | + | select { | |
| 144 | + | case <-wake: | |
| 145 | + | case <-deadline.C: | |
| 146 | + | c.markSeen(runner.ID) | |
| 147 | + | return ports.FetchJobResult{}, nil | |
| 148 | + | case <-ctx.Done(): | |
| 149 | + | return ports.FetchJobResult{}, ctx.Err() | |
| 150 | + | case <-c.ctx.Done(): | |
| 151 | + | return ports.FetchJobResult{RetryAfter: time.Second}, nil | |
| 152 | + | } | |
| 153 | + | // A removed runner's poll ends with unauthenticated. | |
| 154 | + | if _, err := c.cfg.Store.GetRunner(ctx, runner.ID); errors.Is(err, domain.ErrNotFound) { | |
| 155 | + | return ports.FetchJobResult{}, domain.ErrUnauthenticated | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func checkPollID(id string) error { | |
| 161 | + | if len(id) > 64 { | |
| 162 | + | return fmt.Errorf("%w: poll_id is at most 64 characters", domain.ErrInvalid) | |
| 163 | + | } | |
| 164 | + | for _, r := range id { | |
| 165 | + | if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' { | |
| 166 | + | return fmt.Errorf("%w: poll_id has letters, digits, '_' and '-' only", domain.ErrInvalid) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // offeredLabels are the labels of one poll: all those of the runner, or the | |
| 173 | + | // subset the call names. | |
| 174 | + | func offeredLabels(r domain.CIRunner, asked []string) ([]string, error) { | |
| 175 | + | if len(asked) == 0 { | |
| 176 | + | return r.Labels, nil | |
| 177 | + | } | |
| 178 | + | var out []string | |
| 179 | + | for _, l := range asked { | |
| 180 | + | l = strings.ToLower(l) | |
| 181 | + | if !slices.Contains(r.Labels, l) { | |
| 182 | + | return nil, fmt.Errorf("%w: label %q is not a label of the runner", domain.ErrInvalid, l) | |
| 183 | + | } | |
| 184 | + | out = append(out, l) | |
| 185 | + | } | |
| 186 | + | return out, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // repeatPoll answers a retried poll with the assignment the first call made, | |
| 190 | + | // as long as it has not been acknowledged or cancelled. | |
| 191 | + | func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID string) (*ports.RunnerJob, bool) { | |
| 192 | + | c.mu.Lock() | |
| 193 | + | rec, ok := c.polls[runner.ID] | |
| 194 | + | c.mu.Unlock() | |
| 195 | + | if !ok || rec.pollID != pollID { | |
| 196 | + | return nil, false | |
| 197 | + | } | |
| 198 | + | var rid int64 | |
| 199 | + | var ra int32 | |
| 200 | + | var id string | |
| 201 | + | if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil { | |
| 202 | + | return nil, false | |
| 203 | + | } | |
| 204 | + | var out *ports.RunnerJob | |
| 205 | + | err := c.withRun(ctx, rid, ra, func(rs *runState) error { | |
| 206 | + | j := rs.find(id) | |
| 207 | + | if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID { | |
| 208 | + | return errNoJob | |
| 209 | + | } | |
| 210 | + | out = c.runnerJob(rs, *j, rec.token) | |
| 211 | + | return nil | |
| 212 | + | }) | |
| 213 | + | return out, err == nil && out != nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // runnerJob is the job as the runner gets it. | |
| 217 | + | func (c *CI) runnerJob(rs *runState, j domain.CIJob, token string) *ports.RunnerJob { | |
| 218 | + | return &ports.RunnerJob{ | |
| 219 | + | Job: j, Token: token, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | |
| 220 | + | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // assignTo gives the oldest queued job that matches the labels to the runner, | |
| 225 | + | // or errNoJob. The assignment lock makes the choice and the write one step. | |
| 226 | + | func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []string, pollID string) (*ports.RunnerJob, error) { | |
| 227 | + | c.assign.Lock() | |
| 228 | + | defer c.assign.Unlock() | |
| 229 | + | ready, err := c.cfg.Store.ListReadyJobs(ctx, 0) | |
| 230 | + | if err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | for _, cand := range ready { | |
| 234 | + | if !domain.LabelsMatch(cand.Labels, offered) { | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | var out *ports.RunnerJob | |
| 238 | + | err := c.withRun(ctx, cand.RunID, cand.RunAttempt, func(rs *runState) error { | |
| 239 | + | j := rs.find(cand.ID) | |
| 240 | + | if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested { | |
| 241 | + | return errNoJob | |
| 242 | + | } | |
| 243 | + | token := newToken(domain.JobTokenPrefix) | |
| 244 | + | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | |
| 245 | + | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | |
| 246 | + | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | |
| 247 | + | rs.touch(j) | |
| 248 | + | if err := rs.flush(ctx); err != nil { | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | out = c.runnerJob(rs, *j, token) | |
| 252 | + | if pollID != "" { | |
| 253 | + | c.mu.Lock() | |
| 254 | + | c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, hash: j.TokenHash} | |
| 255 | + | c.mu.Unlock() | |
| 256 | + | } | |
| 257 | + | return nil | |
| 258 | + | }) | |
| 259 | + | switch { | |
| 260 | + | case err == nil: | |
| 261 | + | log := zerowrap.FromCtx(ctx) | |
| 262 | + | log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned") | |
| 263 | + | return out, nil | |
| 264 | + | case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict): | |
| 265 | + | continue // taken or gone meanwhile | |
| 266 | + | default: | |
| 267 | + | return nil, err | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | return nil, errNoJob | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // --- the calls of a job token --- | |
| 274 | + | ||
| 275 | + | // jobFor authenticates a job token and checks that it is the one of jobID. | |
| 276 | + | func (c *CI) jobFor(ctx context.Context, secret, jobID string) (domain.CIJob, error) { | |
| 277 | + | if !domain.IsJobToken(secret) { | |
| 278 | + | return domain.CIJob{}, domain.ErrUnauthenticated | |
| 279 | + | } | |
| 280 | + | j, err := c.cfg.Store.FindJobByToken(ctx, domain.HashTokenSecret(secret), c.now()) | |
| 281 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 282 | + | return domain.CIJob{}, domain.ErrUnauthenticated | |
| 283 | + | } | |
| 284 | + | if err != nil { | |
| 285 | + | return domain.CIJob{}, err | |
| 286 | + | } | |
| 287 | + | if j.ID != jobID { | |
| 288 | + | return domain.CIJob{}, fmt.Errorf("%w: this token is the token of another job", domain.ErrForbidden) | |
| 289 | + | } | |
| 290 | + | return j, nil | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func endedError(j domain.CIJob) error { | |
| 294 | + | return &domain.JobEndedError{Conclusion: j.Conclusion, Message: j.Error} | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // lockedJob runs fn with the lock of the run and the job read again. | |
| 298 | + | func (c *CI) lockedJob(ctx context.Context, j domain.CIJob, fn func(cur *domain.CIJob) error) error { | |
| 299 | + | lock := c.runLock(j.RunID) | |
| 300 | + | lock.Lock() | |
| 301 | + | defer lock.Unlock() | |
| 302 | + | cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID) | |
| 303 | + | if err != nil { | |
| 304 | + | return err | |
| 305 | + | } | |
| 306 | + | if err := sameAssignment(j, cur); err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | return fn(&cur) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // sameAssignment checks, under the lock of the run, that the job a token was | |
| 313 | + | // found for is still given under that token: an assignment cancelled and given | |
| 314 | + | // again since has a new one, and the old runner is unauthenticated. A job that | |
| 315 | + | // has ended answers with its end instead (the callers do). | |
| 316 | + | func sameAssignment(was, cur domain.CIJob) error { | |
| 317 | + | if !cur.Ended() && cur.TokenHash != was.TokenHash { | |
| 318 | + | return domain.ErrUnauthenticated | |
| 319 | + | } | |
| 320 | + | return nil | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // saveJob writes one job (its steps changed) and publishes JobUpdated. The | |
| 324 | + | // status of the run does not depend on steps. | |
| 325 | + | func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error { | |
| 326 | + | v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}}) | |
| 327 | + | if err != nil { | |
| 328 | + | return fmt.Errorf("write job %s: %w", j.ID, err) | |
| 329 | + | } | |
| 330 | + | j.Version = v | |
| 331 | + | if repo, err := c.cfg.Meta.Get(ctx, j.Repo); err == nil { | |
| 332 | + | lines, _ := c.cfg.Logs.Count(ctx, logRef(*j)) | |
| 333 | + | c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: *j, Lines: lines, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 334 | + | } | |
| 335 | + | c.jobs.notify(jobRef(*j)) | |
| 336 | + | return nil | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func logRef(j domain.CIJob) ports.CILogRef { | |
| 340 | + | return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt} | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // UpdateJob applies the steps of the request whole or not at all. | |
| 344 | + | func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate) error { | |
| 345 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 346 | + | if err != nil { | |
| 347 | + | return err | |
| 348 | + | } | |
| 349 | + | if j.Ended() { | |
| 350 | + | return endedError(j) | |
| 351 | + | } | |
| 352 | + | if err := checkStepUpdates(steps); err != nil { | |
| 353 | + | return err | |
| 354 | + | } | |
| 355 | + | return c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 356 | + | if cur.Ended() { | |
| 357 | + | return endedError(*cur) | |
| 358 | + | } | |
| 359 | + | next, changed, err := applySteps(cur.Steps, steps) | |
| 360 | + | if err != nil || !changed { | |
| 361 | + | return err | |
| 362 | + | } | |
| 363 | + | cur.Steps = next | |
| 364 | + | return c.saveJob(ctx, cur) | |
| 365 | + | }) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // checkStepUpdates validates the shape of the steps of a request. | |
| 369 | + | func checkStepUpdates(steps []ports.StepUpdate) error { | |
| 370 | + | if len(steps) > domain.CIMaxSteps { | |
| 371 | + | return fmt.Errorf("%w: at most %d steps", domain.ErrInvalid, domain.CIMaxSteps) | |
| 372 | + | } | |
| 373 | + | for _, s := range steps { | |
| 374 | + | if s.Number < 1 || s.Number > domain.CIMaxSteps { | |
| 375 | + | return fmt.Errorf("%w: step number %d is not between 1 and %d", domain.ErrInvalid, s.Number, domain.CIMaxSteps) | |
| 376 | + | } | |
| 377 | + | switch s.State { | |
| 378 | + | case domain.CIStepPending: | |
| 379 | + | if s.Conclusion != domain.CINoConclusion { | |
| 380 | + | return fmt.Errorf("%w: step %d is not completed: it has no conclusion", domain.ErrInvalid, s.Number) | |
| 381 | + | } | |
| 382 | + | case domain.CIStepInProgress: | |
| 383 | + | if s.Conclusion != domain.CINoConclusion || s.StartedAt.IsZero() { | |
| 384 | + | return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion is not allowed", domain.ErrInvalid, s.Number) | |
| 385 | + | } | |
| 386 | + | case domain.CIStepCompleted: | |
| 387 | + | switch s.Conclusion { | |
| 388 | + | case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped: | |
| 389 | + | default: | |
| 390 | + | return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number) | |
| 391 | + | } | |
| 392 | + | if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped { | |
| 393 | + | return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number) | |
| 394 | + | } | |
| 395 | + | if s.CompletedAt.IsZero() { | |
| 396 | + | return fmt.Errorf("%w: step %d is completed: completed_at is required", domain.ErrInvalid, s.Number) | |
| 397 | + | } | |
| 398 | + | default: | |
| 399 | + | return fmt.Errorf("%w: step %d has no state", domain.ErrInvalid, s.Number) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // applySteps returns the steps after the updates, and whether anything | |
| 406 | + | // changed. The input is not modified. | |
| 407 | + | func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep, bool, error) { | |
| 408 | + | out := slices.Clone(have) | |
| 409 | + | changed := false | |
| 410 | + | for _, u := range ups { | |
| 411 | + | name := truncateRunes(u.Name, domain.CIMaxStepName) | |
| 412 | + | i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number }) | |
| 413 | + | if i < 0 { | |
| 414 | + | out = append(out, domain.CIStep{ | |
| 415 | + | Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, StartedAt: u.StartedAt, CompletedAt: u.CompletedAt, | |
| 416 | + | }) | |
| 417 | + | changed = true | |
| 418 | + | continue | |
| 419 | + | } | |
| 420 | + | cur := out[i] | |
| 421 | + | next := cur | |
| 422 | + | next.Name, next.State, next.Conclusion = name, u.State, u.Conclusion | |
| 423 | + | next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt | |
| 424 | + | if cur.Name != "" && name == "" { | |
| 425 | + | next.Name = cur.Name // an empty name never erases one | |
| 426 | + | } | |
| 427 | + | switch { | |
| 428 | + | case cur.State == domain.CIStepCompleted: | |
| 429 | + | same := next.State == cur.State && next.Conclusion == cur.Conclusion && next.Name == cur.Name && | |
| 430 | + | next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt) | |
| 431 | + | if !same { | |
| 432 | + | return nil, false, fmt.Errorf("%w: step %d is completed: it cannot change", domain.ErrPreconditionFailed, u.Number) | |
| 433 | + | } | |
| 434 | + | continue | |
| 435 | + | case cur.State == domain.CIStepInProgress && next.State == domain.CIStepPending: | |
| 436 | + | return nil, false, fmt.Errorf("%w: step %d cannot go from in progress back to pending", domain.ErrPreconditionFailed, u.Number) | |
| 437 | + | } | |
| 438 | + | if !stepsEqual(cur, next) { | |
| 439 | + | out[i], changed = next, true | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | slices.SortFunc(out, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) }) | |
| 443 | + | return out, changed, nil | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func stepsEqual(a, b domain.CIStep) bool { | |
| 447 | + | return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion && | |
| 448 | + | a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // lastLineStep returns the highest step number that has lines, 0 for none. | |
| 452 | + | func lastLineStep(steps []domain.CIStep) int32 { | |
| 453 | + | var last int32 | |
| 454 | + | for _, s := range steps { | |
| 455 | + | if s.HasLines { | |
| 456 | + | last = max(last, s.Number) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | return last | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // checkLines validates the shape of the lines of an AppendLog request. | |
| 463 | + | func checkLines(step int32, offset int64, lines []domain.CILogLine) error { | |
| 464 | + | if step < 1 || step > domain.CIMaxSteps { | |
| 465 | + | return fmt.Errorf("%w: step %d is not between 1 and %d", domain.ErrInvalid, step, domain.CIMaxSteps) | |
| 466 | + | } | |
| 467 | + | if offset < 0 { | |
| 468 | + | return fmt.Errorf("%w: negative offset", domain.ErrInvalid) | |
| 469 | + | } | |
| 470 | + | if len(lines) > domain.CIMaxLogLines { | |
| 471 | + | return fmt.Errorf("%w: at most %d lines per request", domain.ErrInvalid, domain.CIMaxLogLines) | |
| 472 | + | } | |
| 473 | + | total := 0 | |
| 474 | + | for i, l := range lines { | |
| 475 | + | if strings.ContainsRune(l.Text, '\n') || !utf8.ValidString(l.Text) { | |
| 476 | + | return fmt.Errorf("%w: line %d has a line feed or is not valid UTF-8", domain.ErrInvalid, i) | |
| 477 | + | } | |
| 478 | + | if l.Time.IsZero() { | |
| 479 | + | return fmt.Errorf("%w: line %d has no time", domain.ErrInvalid, i) | |
| 480 | + | } | |
| 481 | + | if l.Continued && offset == 0 && i == 0 { | |
| 482 | + | return fmt.Errorf("%w: the first line of a job cannot be continued", domain.ErrInvalid) | |
| 483 | + | } | |
| 484 | + | total += len(l.Text) | |
| 485 | + | } | |
| 486 | + | if total > domain.CIMaxLogChunkBytes { | |
| 487 | + | return fmt.Errorf("%w: at most %d bytes of text per request", domain.ErrInvalid, domain.CIMaxLogChunkBytes) | |
| 488 | + | } | |
| 489 | + | return nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // AppendLog appends lines of a step to the log of the job; see runner.proto | |
| 493 | + | // for the rules of offsets. The metadata store is written only when a step | |
| 494 | + | // gets its first line. | |
| 495 | + | func (c *CI) AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error) { | |
| 496 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 497 | + | if err != nil { | |
| 498 | + | return 0, err | |
| 499 | + | } | |
| 500 | + | if j.Ended() { | |
| 501 | + | return 0, endedError(j) | |
| 502 | + | } | |
| 503 | + | if err := checkLines(step, offset, lines); err != nil { | |
| 504 | + | return 0, err | |
| 505 | + | } | |
| 506 | + | var next int64 | |
| 507 | + | limitHit := false | |
| 508 | + | err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 509 | + | if cur.Ended() { | |
| 510 | + | return endedError(*cur) | |
| 511 | + | } | |
| 512 | + | ref := logRef(*cur) | |
| 513 | + | count, err := c.cfg.Logs.Count(ctx, ref) | |
| 514 | + | if err != nil { | |
| 515 | + | return err | |
| 516 | + | } | |
| 517 | + | if offset > count { | |
| 518 | + | return &ports.ErrCILogGap{Count: count} | |
| 519 | + | } | |
| 520 | + | skip := count - offset | |
| 521 | + | if skip >= int64(len(lines)) { | |
| 522 | + | next = count // everything is stored already | |
| 523 | + | return nil | |
| 524 | + | } | |
| 525 | + | last := lastLineStep(cur.Steps) | |
| 526 | + | if step < last { | |
| 527 | + | return fmt.Errorf("%w: step %d is below step %d, which already has lines", domain.ErrPreconditionFailed, step, last) | |
| 528 | + | } | |
| 529 | + | if first := lines[skip]; first.Continued && count > 0 && step != last { | |
| 530 | + | return fmt.Errorf("%w: a continued line must be in the step of the line before it", domain.ErrPreconditionFailed) | |
| 531 | + | } | |
| 532 | + | n, added, err := c.cfg.Logs.Append(ctx, ref, offset, lines, domain.CIMaxLogBytes) | |
| 533 | + | if errors.Is(err, ports.ErrCILogLimit) { | |
| 534 | + | limitHit = true | |
| 535 | + | return nil | |
| 536 | + | } | |
| 537 | + | if err != nil { | |
| 538 | + | return err | |
| 539 | + | } | |
| 540 | + | next = n | |
| 541 | + | if added == 0 { | |
| 542 | + | return nil | |
| 543 | + | } | |
| 544 | + | return c.startStepLines(ctx, cur, step, count, lines[skip].Time) | |
| 545 | + | }) | |
| 546 | + | if err != nil { | |
| 547 | + | return 0, err | |
| 548 | + | } | |
| 549 | + | if limitHit { | |
| 550 | + | return 0, c.stopForLogLimit(ctx, j) | |
| 551 | + | } | |
| 552 | + | return next, nil | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // startStepLines records where the lines of a step begin, creating the step | |
| 556 | + | // when UpdateJob did not announce it. | |
| 557 | + | func (c *CI) startStepLines(ctx context.Context, cur *domain.CIJob, step int32, firstLine int64, at time.Time) error { | |
| 558 | + | i := slices.IndexFunc(cur.Steps, func(s domain.CIStep) bool { return s.Number == step }) | |
| 559 | + | switch { | |
| 560 | + | case i < 0: | |
| 561 | + | cur.Steps = append(cur.Steps, domain.CIStep{ | |
| 562 | + | Number: step, State: domain.CIStepInProgress, StartedAt: at, HasLines: true, LogStart: firstLine, | |
| 563 | + | }) | |
| 564 | + | slices.SortFunc(cur.Steps, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) }) | |
| 565 | + | case cur.Steps[i].HasLines: | |
| 566 | + | return nil | |
| 567 | + | default: | |
| 568 | + | cur.Steps[i].HasLines, cur.Steps[i].LogStart = true, firstLine | |
| 569 | + | } | |
| 570 | + | return c.saveJob(ctx, cur) | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // stopForLogLimit ends a job whose log reached its limit: a last line says | |
| 574 | + | // so, the job fails, and the call answers like any call after the end. | |
| 575 | + | func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error { | |
| 576 | + | var ended *domain.JobEndedError | |
| 577 | + | err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 578 | + | cur := rs.find(j.ID) | |
| 579 | + | if cur == nil { | |
| 580 | + | return domain.ErrNotFound | |
| 581 | + | } | |
| 582 | + | if cur.Ended() { | |
| 583 | + | ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error} | |
| 584 | + | return nil | |
| 585 | + | } | |
| 586 | + | if err := sameAssignment(j, *cur); err != nil { | |
| 587 | + | return err | |
| 588 | + | } | |
| 589 | + | _, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}}) | |
| 590 | + | rs.end(cur, domain.CIFailure, domain.CILogLimitMessage) | |
| 591 | + | ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage} | |
| 592 | + | if err := rs.advance(ctx); err != nil { | |
| 593 | + | return err | |
| 594 | + | } | |
| 595 | + | return rs.flush(ctx) | |
| 596 | + | }) | |
| 597 | + | if err != nil { | |
| 598 | + | return err | |
| 599 | + | } | |
| 600 | + | if ended == nil { | |
| 601 | + | return domain.ErrNotFound // a typed nil would be taken for an error | |
| 602 | + | } | |
| 603 | + | return ended | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // Heartbeat tells the server that the runner is alive. The first one | |
| 607 | + | // acknowledges the assignment; the others only move the deadline. | |
| 608 | + | func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error { | |
| 609 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 610 | + | if err != nil { | |
| 611 | + | return err | |
| 612 | + | } | |
| 613 | + | if j.Ended() { | |
| 614 | + | return endedError(j) | |
| 615 | + | } | |
| 616 | + | // Everything is done under the lock of the run: the acknowledgement, and | |
| 617 | + | // the note of the heartbeat, which must not be written once the job ended | |
| 618 | + | // (end() forgets it under the same lock). | |
| 619 | + | return c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 620 | + | cur := rs.find(j.ID) | |
| 621 | + | switch { | |
| 622 | + | case cur == nil: | |
| 623 | + | return domain.ErrUnauthenticated | |
| 624 | + | case cur.Ended(): | |
| 625 | + | return endedError(*cur) | |
| 626 | + | case cur.TokenHash != j.TokenHash: | |
| 627 | + | return domain.ErrUnauthenticated // the assignment was cancelled and given again | |
| 628 | + | } | |
| 629 | + | now := c.now() | |
| 630 | + | if cur.Phase == domain.CIPhaseAssigned { | |
| 631 | + | cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0 | |
| 632 | + | rs.touch(cur) | |
| 633 | + | if err := rs.flush(ctx); err != nil { | |
| 634 | + | return err | |
| 635 | + | } | |
| 636 | + | c.forgetPoll(jobRef(*cur)) // the token it holds is the runner's now | |
| 637 | + | } | |
| 638 | + | c.mu.Lock() | |
| 639 | + | c.heartbeats[jobRef(*cur)] = now | |
| 640 | + | c.seen[cur.RunnerID] = now | |
| 641 | + | c.mu.Unlock() | |
| 642 | + | return nil | |
| 643 | + | }) | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | // forgetPoll drops the remembered assignment (and its plaintext token) of a | |
| 647 | + | // job: after the acknowledgement or the cancel of the assignment, a retried | |
| 648 | + | // poll has nothing to repeat. | |
| 649 | + | func (c *CI) forgetPoll(ref string) { | |
| 650 | + | c.mu.Lock() | |
| 651 | + | defer c.mu.Unlock() | |
| 652 | + | for id, p := range c.polls { | |
| 653 | + | if p.ref == ref { | |
| 654 | + | delete(c.polls, id) | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // WatchJob streams what the runner must react to: the cancel request (the | |
| 660 | + | // first message when one is already pending), keepalives, and the end. | |
| 661 | + | func (c *CI) WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(ports.JobSignal) error) error { | |
| 662 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 663 | + | if err != nil { | |
| 664 | + | return err | |
| 665 | + | } | |
| 666 | + | if j.Ended() { | |
| 667 | + | return endedError(j) | |
| 668 | + | } | |
| 669 | + | ref := jobRef(j) | |
| 670 | + | keepalive := time.NewTicker(c.cfg.Keepalive) | |
| 671 | + | defer keepalive.Stop() | |
| 672 | + | cancelSent := false | |
| 673 | + | if ready != nil { | |
| 674 | + | ready() | |
| 675 | + | } | |
| 676 | + | for { | |
| 677 | + | wake := c.jobs.wait(ref) | |
| 678 | + | cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID) | |
| 679 | + | if err != nil { | |
| 680 | + | return err | |
| 681 | + | } | |
| 682 | + | if cur.TokenHash != j.TokenHash && !cur.Ended() { | |
| 683 | + | return domain.ErrUnauthenticated // given again | |
| 684 | + | } | |
| 685 | + | if cur.Ended() { | |
| 686 | + | return send(ports.JobSignal{Ended: &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}}) | |
| 687 | + | } | |
| 688 | + | if cur.CancelReason != domain.CICancelNone && !cancelSent { | |
| 689 | + | cancelSent = true | |
| 690 | + | if err := send(ports.JobSignal{Cancel: &ports.JobCancel{ | |
| 691 | + | Reason: cur.CancelReason, Message: cur.CancelMessage, RequestedAt: cur.CancelRequestedAt, | |
| 692 | + | }}); err != nil { | |
| 693 | + | return err | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | select { | |
| 697 | + | case <-wake: | |
| 698 | + | case <-keepalive.C: | |
| 699 | + | if err := send(ports.JobSignal{Keepalive: true}); err != nil { | |
| 700 | + | return err | |
| 701 | + | } | |
| 702 | + | case <-ctx.Done(): | |
| 703 | + | return nil | |
| 704 | + | case <-c.ctx.Done(): | |
| 705 | + | return nil | |
| 706 | + | } | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | // CompleteJob ends a job, once. A later call returns the stored result. | |
| 711 | + | func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error) { | |
| 712 | + | j, err := c.jobFor(ctx, secret, params.JobID) | |
| 713 | + | if err != nil { | |
| 714 | + | return ports.CompleteJobResult{}, err | |
| 715 | + | } | |
| 716 | + | if j.Ended() { | |
| 717 | + | return ports.CompleteJobResult{Conclusion: j.Conclusion, Error: j.Error}, nil | |
| 718 | + | } | |
| 719 | + | switch params.Conclusion { | |
| 720 | + | case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CITimedOut: | |
| 721 | + | default: | |
| 722 | + | return ports.CompleteJobResult{}, fmt.Errorf("%w: a runner reports success, failure, cancelled or timed_out", domain.ErrInvalid) | |
| 723 | + | } | |
| 724 | + | if params.RunnerShutdown && params.Conclusion != domain.CICancelled { | |
| 725 | + | return ports.CompleteJobResult{}, fmt.Errorf("%w: runner_shutdown goes with cancelled", domain.ErrInvalid) | |
| 726 | + | } | |
| 727 | + | if err := domain.CheckOutputs(params.Outputs); err != nil { | |
| 728 | + | return ports.CompleteJobResult{}, err | |
| 729 | + | } | |
| 730 | + | if err := checkStepUpdates(params.Steps); err != nil { | |
| 731 | + | return ports.CompleteJobResult{}, err | |
| 732 | + | } | |
| 733 | + | var res ports.CompleteJobResult | |
| 734 | + | err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 735 | + | cur := rs.find(j.ID) | |
| 736 | + | if cur == nil { | |
| 737 | + | return domain.ErrNotFound | |
| 738 | + | } | |
| 739 | + | if cur.Ended() { // ended by the server since | |
| 740 | + | res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error} | |
| 741 | + | return nil | |
| 742 | + | } | |
| 743 | + | if err := sameAssignment(j, *cur); err != nil { | |
| 744 | + | return err | |
| 745 | + | } | |
| 746 | + | steps, _, err := applySteps(cur.Steps, params.Steps) | |
| 747 | + | if err != nil { | |
| 748 | + | return err | |
| 749 | + | } | |
| 750 | + | cur.Steps = steps | |
| 751 | + | cur.Outputs = params.Outputs | |
| 752 | + | msg := "" | |
| 753 | + | if params.RunnerShutdown { | |
| 754 | + | msg = domain.CIShutdownMessage | |
| 755 | + | } | |
| 756 | + | rs.end(cur, params.Conclusion, msg) | |
| 757 | + | if err := rs.advance(ctx); err != nil { | |
| 758 | + | return err | |
| 759 | + | } | |
| 760 | + | res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error, Applied: true} | |
| 761 | + | return rs.flush(ctx) | |
| 762 | + | }) | |
| 763 | + | if err != nil { | |
| 764 | + | return ports.CompleteJobResult{}, err | |
| 765 | + | } | |
| 766 | + | if res.Applied { | |
| 767 | + | log := zerowrap.FromCtx(ctx) | |
| 768 | + | log.Info().Str("job_id", j.ID).Int64("run_id", j.RunID).Str("conclusion", string(res.Conclusion)).Msg("job completed") | |
| 769 | + | } | |
| 770 | + | return res, nil | |
| 771 | + | } | |
A internal/usecase/ci_runner_unit_test.go +414 -0
| @@ -0,0 +1,414 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // ciUnit is the CI use cases over mocks, with a clock the test moves. | |
| 24 | + | type ciUnit struct { | |
| 25 | + | store *mocks.CIStore | |
| 26 | + | logs *mocks.CILogStore | |
| 27 | + | meta *mocks.RepoMetaStore | |
| 28 | + | bus *mocks.EventBus | |
| 29 | + | clock *fakeClock | |
| 30 | + | ci *usecase.CI | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type fakeClock struct{ t time.Time } | |
| 34 | + | ||
| 35 | + | func (c *fakeClock) Now() time.Time { return c.t } | |
| 36 | + | ||
| 37 | + | func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit { | |
| 38 | + | t.Helper() | |
| 39 | + | u := &ciUnit{ | |
| 40 | + | store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t), | |
| 41 | + | bus: mocks.NewEventBus(t), clock: &fakeClock{t: now}, | |
| 42 | + | } | |
| 43 | + | cfg := usecase.CIConfig{ | |
| 44 | + | Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t), | |
| 45 | + | Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test", | |
| 46 | + | } | |
| 47 | + | if tweak != nil { | |
| 48 | + | tweak(&cfg) | |
| 49 | + | } | |
| 50 | + | u.ci = usecase.NewCI(context.Background(), cfg) | |
| 51 | + | t.Cleanup(func() { u.ci.Close(context.Background()) }) | |
| 52 | + | return u | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const ( | |
| 56 | + | jobSecret = "ermitejob_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0" | |
| 57 | + | jobIDOne = "7-0-0" | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | func ciJob(t *testing.T, phase domain.CIJobPhase, secret string) domain.CIJob { | |
| 61 | + | t.Helper() | |
| 62 | + | return domain.CIJob{ | |
| 63 | + | ID: jobIDOne, RunID: 7, Attempt: 1, RunAttempt: 1, Repo: name(t, "proj"), Key: "build", Name: "build", | |
| 64 | + | Phase: phase, TokenHash: domain.HashTokenSecret(secret), RunnerID: "r1", RunnerName: "alpha", Planned: true, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // expectToken makes the token lookup find j. | |
| 69 | + | func (u *ciUnit) expectToken(secret string, j domain.CIJob) { | |
| 70 | + | u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(secret), mock.Anything).Return(j, nil) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // S4: a call whose token was replaced between the lookup and the lock (the | |
| 74 | + | // assignment was cancelled and given again) is unauthenticated, for every | |
| 75 | + | // call that writes the job. | |
| 76 | + | func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | replaced := func(j domain.CIJob) domain.CIJob { | |
| 79 | + | j.TokenHash = domain.HashTokenSecret("ermitejob_replaced") | |
| 80 | + | return j | |
| 81 | + | } | |
| 82 | + | calls := map[string]func(u *ciUnit) error{ | |
| 83 | + | "UpdateJob": func(u *ciUnit) error { | |
| 84 | + | return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{ | |
| 85 | + | Number: 1, State: domain.CIStepInProgress, StartedAt: now, | |
| 86 | + | }}) | |
| 87 | + | }, | |
| 88 | + | "AppendLog": func(u *ciUnit) error { | |
| 89 | + | _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}}) | |
| 90 | + | return err | |
| 91 | + | }, | |
| 92 | + | } | |
| 93 | + | for label, call := range calls { | |
| 94 | + | t.Run(label, func(t *testing.T) { | |
| 95 | + | t.Parallel() | |
| 96 | + | u := newCIUnit(t, nil) | |
| 97 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 98 | + | u.expectToken(jobSecret, j) | |
| 99 | + | u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(replaced(j), nil) | |
| 100 | + | // No write: the stores and the log expect nothing else. | |
| 101 | + | assert.ErrorIs(t, call(u), domain.ErrUnauthenticated) | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | t.Run("CompleteJob", func(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | u := newCIUnit(t, nil) | |
| 108 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 109 | + | u.expectToken(jobSecret, j) | |
| 110 | + | u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil) | |
| 111 | + | u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil) | |
| 112 | + | u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{replaced(j)}, nil) | |
| 113 | + | _, err := u.ci.CompleteJob(context.Background(), jobSecret, ports.CompleteJobParams{JobID: j.ID, Conclusion: domain.CISuccess}) | |
| 114 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 115 | + | }) | |
| 116 | + | ||
| 117 | + | t.Run("an ended job answers with its end, not unauthenticated", func(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | u := newCIUnit(t, nil) | |
| 120 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 121 | + | ended := replaced(j) | |
| 122 | + | ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled | |
| 123 | + | u.expectToken(jobSecret, j) | |
| 124 | + | u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil) | |
| 125 | + | err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}}) | |
| 126 | + | got, ok := domain.IsJobEnded(err) | |
| 127 | + | require.True(t, ok, "%v", err) | |
| 128 | + | assert.Equal(t, domain.CICancelled, got.Conclusion) | |
| 129 | + | }) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // S5: the job vanished while the log limit was handled: a plain not found, never | |
| 133 | + | // a typed nil error. | |
| 134 | + | func TestLogLimitOnAVanishedJobIsNotFound(t *testing.T) { | |
| 135 | + | t.Parallel() | |
| 136 | + | u := newCIUnit(t, nil) | |
| 137 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 138 | + | u.expectToken(jobSecret, j) | |
| 139 | + | // UpdateJob-style lookup under lock, then a log at the limit. | |
| 140 | + | u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil) | |
| 141 | + | u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil) | |
| 142 | + | u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(0), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit) | |
| 143 | + | // stopForLogLimit loads the run, and the job is no longer in it. | |
| 144 | + | u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil) | |
| 145 | + | u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil) | |
| 146 | + | u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return(nil, nil) | |
| 147 | + | ||
| 148 | + | _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}}) | |
| 149 | + | require.Error(t, err) | |
| 150 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 151 | + | _, isEnded := domain.IsJobEnded(err) | |
| 152 | + | assert.False(t, isEnded) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // expectRunJob makes GetJobLog find the job of the repository (a public one). | |
| 156 | + | func (u *ciUnit) expectLogJob(j domain.CIJob) { | |
| 157 | + | u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo, Visibility: domain.VisibilityPublic}, nil) | |
| 158 | + | u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil) | |
| 159 | + | u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // S7: without follow a missing log is not found and an offset past the end is | |
| 163 | + | // out of range; with follow a running job's reader waits for the lines, and a | |
| 164 | + | // job that ended answers as without follow (follow changes nothing then). | |
| 165 | + | func TestGetJobLogFollowAndOutOfRange(t *testing.T) { | |
| 166 | + | t.Parallel() | |
| 167 | + | p := domain.Anonymous() | |
| 168 | + | params := func(follow bool, offset int64) ports.GetJobLogParams { | |
| 169 | + | return ports.GetJobLogParams{JobID: jobIDOne, Offset: offset, Follow: follow} | |
| 170 | + | } | |
| 171 | + | send := func(ports.LogBatch) error { return nil } | |
| 172 | + | ||
| 173 | + | t.Run("no follow: out of range", func(t *testing.T) { | |
| 174 | + | t.Parallel() | |
| 175 | + | u := newCIUnit(t, nil) | |
| 176 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 177 | + | u.expectLogJob(j) | |
| 178 | + | u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{})) | |
| 179 | + | u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(5), mock.Anything, mock.Anything).Return(nil, domain.ErrOutOfRange) | |
| 180 | + | err := u.ci.GetJobLog(context.Background(), p, j.Repo, params(false, 5), send) | |
| 181 | + | assert.ErrorIs(t, err, domain.ErrOutOfRange) | |
| 182 | + | }) | |
| 183 | + | t.Run("no follow: no log", func(t *testing.T) { | |
| 184 | + | t.Parallel() | |
| 185 | + | u := newCIUnit(t, nil) | |
| 186 | + | j := ciJob(t, domain.CIPhaseReady, jobSecret) | |
| 187 | + | u.expectLogJob(j) | |
| 188 | + | u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{})) | |
| 189 | + | u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(0), mock.Anything, mock.Anything).Return(nil, domain.ErrNotFound) | |
| 190 | + | assert.ErrorIs(t, u.ci.GetJobLog(context.Background(), p, j.Repo, params(false, 0), send), domain.ErrNotFound) | |
| 191 | + | }) | |
| 192 | + | t.Run("follow, ended: out of range like without follow", func(t *testing.T) { | |
| 193 | + | t.Parallel() | |
| 194 | + | u := newCIUnit(t, nil) | |
| 195 | + | j := ciJob(t, domain.CIPhaseEnded, jobSecret) | |
| 196 | + | u.expectLogJob(j) | |
| 197 | + | u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{})) | |
| 198 | + | u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(5), mock.Anything, mock.Anything).Return(nil, domain.ErrOutOfRange) | |
| 199 | + | err := u.ci.GetJobLog(context.Background(), p, j.Repo, params(true, 5), send) | |
| 200 | + | assert.ErrorIs(t, err, domain.ErrOutOfRange) | |
| 201 | + | }) | |
| 202 | + | t.Run("follow, running: waits for the line, then sends it", func(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 204 | + | u := newCIUnit(t, nil) | |
| 205 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 206 | + | u.expectLogJob(j) | |
| 207 | + | wake := make(chan struct{}) | |
| 208 | + | u.logs.EXPECT().Changed(mock.Anything).Return((<-chan struct{})(wake)) | |
| 209 | + | reads := 0 | |
| 210 | + | u.logs.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 211 | + | func(_ context.Context, _ ports.CILogRef, from int64, _, _ int) ([]domain.CILogLine, error) { | |
| 212 | + | reads++ | |
| 213 | + | if from == 0 && reads == 1 { | |
| 214 | + | return nil, domain.ErrOutOfRange // the runner has not written yet | |
| 215 | + | } | |
| 216 | + | if from == 0 { | |
| 217 | + | return []domain.CILogLine{{Time: now, Text: "late line"}}, nil | |
| 218 | + | } | |
| 219 | + | return nil, nil | |
| 220 | + | }) | |
| 221 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 222 | + | got := make(chan ports.LogBatch, 1) | |
| 223 | + | errc := make(chan error, 1) | |
| 224 | + | go func() { | |
| 225 | + | errc <- u.ci.GetJobLog(ctx, p, j.Repo, params(true, 0), func(b ports.LogBatch) error { | |
| 226 | + | got <- b | |
| 227 | + | cancel() // enough | |
| 228 | + | return nil | |
| 229 | + | }) | |
| 230 | + | }() | |
| 231 | + | select { | |
| 232 | + | case <-got: | |
| 233 | + | t.Fatal("a line was sent before it existed") | |
| 234 | + | case <-time.After(150 * time.Millisecond): | |
| 235 | + | } | |
| 236 | + | close(wake) // the append happens | |
| 237 | + | select { | |
| 238 | + | case b := <-got: | |
| 239 | + | require.Len(t, b.Lines, 1) | |
| 240 | + | assert.Equal(t, "late line", b.Lines[0].Text) | |
| 241 | + | case <-time.After(5 * time.Second): | |
| 242 | + | t.Fatal("the reader did not wake up") | |
| 243 | + | } | |
| 244 | + | require.NoError(t, <-errc) | |
| 245 | + | }) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // S8: a stream that never idles revalidates the token too. | |
| 249 | + | func TestGetJobLogRevalidatesABusyStream(t *testing.T) { | |
| 250 | + | t.Parallel() | |
| 251 | + | auth := mocks.NewRevalidator(t) | |
| 252 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Auth = auth }) | |
| 253 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 254 | + | u.expectLogJob(j) | |
| 255 | + | u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{})) | |
| 256 | + | // A token principal; the log keeps having lines to send. | |
| 257 | + | tok := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead}}) | |
| 258 | + | u.logs.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]domain.CILogLine{{Time: now, Text: "x"}}, nil) | |
| 259 | + | revoked := domain.ErrUnauthenticated | |
| 260 | + | auth.EXPECT().Revalidate(mock.Anything, tok).Return(revoked).Once() | |
| 261 | + | ||
| 262 | + | sent := 0 | |
| 263 | + | start := time.Now() | |
| 264 | + | err := u.ci.GetJobLog(context.Background(), tok, j.Repo, ports.GetJobLogParams{JobID: jobIDOne, Follow: true}, func(ports.LogBatch) error { | |
| 265 | + | sent++ | |
| 266 | + | time.Sleep(usecase.DefaultRevalidateEvery / 2) | |
| 267 | + | if time.Since(start) > 30*time.Second { | |
| 268 | + | return context.DeadlineExceeded | |
| 269 | + | } | |
| 270 | + | return nil | |
| 271 | + | }) | |
| 272 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked token stops a busy stream") | |
| 273 | + | assert.GreaterOrEqual(t, sent, 2) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // S9: more than 1,000 checks on a commit is an error for the merge rule, not a | |
| 277 | + | // list cut at 1,000. | |
| 278 | + | func TestCommitChecksRefusesATruncatedList(t *testing.T) { | |
| 279 | + | t.Parallel() | |
| 280 | + | u := newCIUnit(t, nil) | |
| 281 | + | repo := name(t, "proj") | |
| 282 | + | u.store.EXPECT().ListChecks(mock.Anything, repo, "abc", usecase.MaxCIChecks).Return(make([]domain.CICheck, usecase.MaxCIChecks), true, nil).Once() | |
| 283 | + | _, err := u.ci.CommitChecks(context.Background(), repo, "abc") | |
| 284 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 285 | + | ||
| 286 | + | u.store.EXPECT().ListChecks(mock.Anything, repo, "def", usecase.MaxCIChecks).Return([]domain.CICheck{{Name: "a"}}, false, nil).Once() | |
| 287 | + | checks, err := u.ci.CommitChecks(context.Background(), repo, "def") | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | assert.Len(t, checks, 1) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // logCapture is a context whose logger writes into a buffer the test reads. | |
| 293 | + | func logCapture(t *testing.T) (context.Context, *bytes.Buffer) { | |
| 294 | + | t.Helper() | |
| 295 | + | var buf bytes.Buffer | |
| 296 | + | log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}) | |
| 297 | + | return zerowrap.WithCtx(context.Background(), log), &buf | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // M5, M6: the sweep removes the runs older than the retention (with the clock | |
| 301 | + | // moved, not waited for), the logs of runs no record has any more, and says so | |
| 302 | + | // when it cannot. | |
| 303 | + | func TestSweep(t *testing.T) { | |
| 304 | + | t.Parallel() | |
| 305 | + | retention := 30 * 24 * time.Hour | |
| 306 | + | ||
| 307 | + | t.Run("old runs go with their logs, orphan logs too", func(t *testing.T) { | |
| 308 | + | t.Parallel() | |
| 309 | + | u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Retention = retention }) | |
| 310 | + | u.clock.t = now.Add(100 * 24 * time.Hour) | |
| 311 | + | ctx, out := logCapture(t) | |
| 312 | + | u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil) | |
| 313 | + | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once() | |
| 314 | + | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil) | |
| 315 | + | u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil) | |
| 316 | + | u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| 317 | + | u.store.EXPECT().GetRunSource(mock.Anything, int64(7)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| 318 | + | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{6, 7}).Return(nil).Once() | |
| 319 | + | u.ci.Sweep(ctx) | |
| 320 | + | assert.NotContains(t, out.String(), `"level":"error"`) | |
| 321 | + | assert.Contains(t, out.String(), "old runs removed") | |
| 322 | + | }) | |
| 323 | + | ||
| 324 | + | t.Run("a store that fails ends the sweep, logged", func(t *testing.T) { | |
| 325 | + | t.Parallel() | |
| 326 | + | u := newCIUnit(t, nil) | |
| 327 | + | ctx, out := logCapture(t) | |
| 328 | + | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, errors.New("kv closed")) | |
| 329 | + | u.ci.Sweep(ctx) // the log store expects no call | |
| 330 | + | assert.Contains(t, out.String(), "sweep the old runs") | |
| 331 | + | assert.Contains(t, out.String(), "kv closed") | |
| 332 | + | }) | |
| 333 | + | ||
| 334 | + | t.Run("a log directory that cannot be removed is logged and the orphans are still looked for", func(t *testing.T) { | |
| 335 | + | t.Parallel() | |
| 336 | + | u := newCIUnit(t, nil) | |
| 337 | + | ctx, out := logCapture(t) | |
| 338 | + | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil) | |
| 339 | + | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(errors.New("permission denied")).Once() | |
| 340 | + | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 341 | + | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| 342 | + | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once() | |
| 343 | + | u.ci.Sweep(ctx) | |
| 344 | + | assert.Contains(t, out.String(), "remove the logs of the old runs") | |
| 345 | + | assert.Contains(t, out.String(), "permission denied") | |
| 346 | + | assert.Contains(t, out.String(), "remove the logs of runs that no longer exist") | |
| 347 | + | assert.Contains(t, out.String(), "disk on fire") | |
| 348 | + | }) | |
| 349 | + | ||
| 350 | + | t.Run("a listing of the log directories that fails is logged", func(t *testing.T) { | |
| 351 | + | t.Parallel() | |
| 352 | + | u := newCIUnit(t, nil) | |
| 353 | + | ctx, out := logCapture(t) | |
| 354 | + | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 355 | + | u.logs.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error")) | |
| 356 | + | u.ci.Sweep(ctx) | |
| 357 | + | assert.Contains(t, out.String(), "list the log directories") | |
| 358 | + | }) | |
| 359 | + | ||
| 360 | + | t.Run("a run with a lookup error keeps its logs", func(t *testing.T) { | |
| 361 | + | t.Parallel() | |
| 362 | + | u := newCIUnit(t, nil) | |
| 363 | + | ctx, out := logCapture(t) | |
| 364 | + | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 365 | + | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 366 | + | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed")) | |
| 367 | + | u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing | |
| 368 | + | assert.Contains(t, out.String(), "look up a run") | |
| 369 | + | }) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // M5: a log that would pass its limit appends nothing; the server adds a last | |
| 373 | + | // line saying so, fails the job and answers failed_precondition with the end. | |
| 374 | + | func TestLogLimitEndsTheJob(t *testing.T) { | |
| 375 | + | t.Parallel() | |
| 376 | + | u := newCIUnit(t, nil) | |
| 377 | + | j := ciJob(t, domain.CIPhaseRunning, jobSecret) | |
| 378 | + | j.Steps = []domain.CIStep{{Number: 1, State: domain.CIStepInProgress, StartedAt: now, HasLines: true}} | |
| 379 | + | u.expectToken(jobSecret, j) | |
| 380 | + | u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil) | |
| 381 | + | u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(10, nil) | |
| 382 | + | u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(10), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit) | |
| 383 | + | ||
| 384 | + | // stopForLogLimit: the run is loaded, the last line is forced in, the job ends. | |
| 385 | + | u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo, WorkflowYAML: "on: push\njobs:\n build:\n runs-on: x\n steps:\n - run: echo\n"}, nil) | |
| 386 | + | u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1, WorkflowPath: "w.yml"}, nil) | |
| 387 | + | u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{j}, nil) | |
| 388 | + | var forced []domain.CILogLine | |
| 389 | + | u.logs.EXPECT().AppendForced(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 390 | + | func(_ context.Context, _ ports.CILogRef, lines []domain.CILogLine) (int64, error) { | |
| 391 | + | forced = lines | |
| 392 | + | return 11, nil | |
| 393 | + | }) | |
| 394 | + | var written ports.CIWrite | |
| 395 | + | u.store.EXPECT().Apply(mock.Anything, mock.Anything).RunAndReturn( | |
| 396 | + | func(_ context.Context, w ports.CIWrite) (uint64, error) { written = w; return 5, nil }) | |
| 397 | + | u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo, Visibility: domain.VisibilityPrivate}, nil) | |
| 398 | + | u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(11, nil).Maybe() | |
| 399 | + | u.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe() | |
| 400 | + | ||
| 401 | + | _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 10, []domain.CILogLine{{Time: now, Text: "one more"}}) | |
| 402 | + | ended, ok := domain.IsJobEnded(err) | |
| 403 | + | require.True(t, ok, "%v", err) | |
| 404 | + | assert.Equal(t, domain.CIFailure, ended.Conclusion) | |
| 405 | + | assert.Contains(t, ended.Message, "512 MiB") | |
| 406 | + | ||
| 407 | + | require.Len(t, forced, 1) | |
| 408 | + | assert.Contains(t, forced[0].Text, "##[error]") | |
| 409 | + | assert.Contains(t, forced[0].Text, "512 MiB") | |
| 410 | + | require.Len(t, written.Jobs, 1) | |
| 411 | + | assert.Equal(t, domain.CIPhaseEnded, written.Jobs[0].Phase) | |
| 412 | + | assert.Equal(t, domain.CIFailure, written.Jobs[0].Conclusion) | |
| 413 | + | assert.Equal(t, domain.CIStepCompleted, written.Jobs[0].Steps[0].State, "an open step is completed with the job") | |
| 414 | + | } | |
A internal/usecase/ci_runs.go +584 -0
| @@ -0,0 +1,584 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "path" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const runTokenPrefix = "ru1:" | |
| 21 | + | ||
| 22 | + | // --- runs and jobs --- | |
| 23 | + | ||
| 24 | + | // ListRuns returns a page of the runs of a repository, newest first. The | |
| 25 | + | // principal needs the access of reading the repository. | |
| 26 | + | func (c *CI) ListRuns(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error) { | |
| 27 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 28 | + | return ports.RunPage{}, err | |
| 29 | + | } | |
| 30 | + | n, err := pageSize(params.PageSize) | |
| 31 | + | if err != nil { | |
| 32 | + | return ports.RunPage{}, err | |
| 33 | + | } | |
| 34 | + | var before int64 | |
| 35 | + | if params.PageToken != "" { | |
| 36 | + | raw, ok := decodeToken(params.PageToken, runTokenPrefix) | |
| 37 | + | id, perr := strconv.ParseInt(raw, 10, 64) | |
| 38 | + | if !ok || perr != nil || id < 1 { | |
| 39 | + | return ports.RunPage{}, fmt.Errorf("%w: runs", domain.ErrInvalidPageToken) | |
| 40 | + | } | |
| 41 | + | before = id | |
| 42 | + | } | |
| 43 | + | match := runFilter(params) | |
| 44 | + | // The store returns runs by id; the filters are applied while paging, so a | |
| 45 | + | // page of a filtered listing is full unless the runs ran out. | |
| 46 | + | var kept []domain.CIRun | |
| 47 | + | cursor := before | |
| 48 | + | for len(kept) <= n { | |
| 49 | + | batch, err := c.cfg.Store.ListRuns(ctx, name, cursor, n+1) | |
| 50 | + | if err != nil { | |
| 51 | + | return ports.RunPage{}, err | |
| 52 | + | } | |
| 53 | + | for _, r := range batch { | |
| 54 | + | if match(r) { | |
| 55 | + | kept = append(kept, r) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | if len(batch) < n+1 { | |
| 59 | + | break | |
| 60 | + | } | |
| 61 | + | cursor = batch[len(batch)-1].ID | |
| 62 | + | } | |
| 63 | + | page := ports.RunPage{Runs: kept} | |
| 64 | + | if len(kept) > n { | |
| 65 | + | page.Runs = kept[:n] | |
| 66 | + | page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(kept[n-1].ID, 10))) | |
| 67 | + | } | |
| 68 | + | return page, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // runFilter returns the test of the filters of a listing. | |
| 72 | + | func runFilter(p ports.ListRunsParams) func(domain.CIRun) bool { | |
| 73 | + | return func(r domain.CIRun) bool { | |
| 74 | + | switch { | |
| 75 | + | case p.Status != "" && r.Status != p.Status: | |
| 76 | + | return false | |
| 77 | + | case p.Branch != "" && r.Ref != "refs/heads/"+p.Branch: | |
| 78 | + | return false | |
| 79 | + | case p.Workflow != "" && r.WorkflowPath != p.Workflow && path.Base(r.WorkflowPath) != p.Workflow: | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | return true | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // runOf finds a run of the repository; a run of another repository is not | |
| 87 | + | // found. | |
| 88 | + | func (c *CI) runOf(ctx context.Context, name domain.RepoName, runID int64) (domain.CIRun, error) { | |
| 89 | + | run, err := c.cfg.Store.GetRun(ctx, name, runID) | |
| 90 | + | if err != nil { | |
| 91 | + | return domain.CIRun{}, err | |
| 92 | + | } | |
| 93 | + | return run, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // view adds the live line count to a job. | |
| 97 | + | func (c *CI) view(ctx context.Context, j domain.CIJob) ports.JobView { | |
| 98 | + | v := ports.JobView{CIJob: j} | |
| 99 | + | if j.Phase != domain.CIPhaseWaiting && j.Phase != domain.CIPhaseReady { | |
| 100 | + | v.Lines, _ = c.cfg.Logs.Count(ctx, logRef(j)) | |
| 101 | + | } | |
| 102 | + | if j.RunnerID != "" { | |
| 103 | + | if _, err := c.cfg.Store.GetRunner(ctx, j.RunnerID); errors.Is(err, domain.ErrNotFound) { | |
| 104 | + | v.RunnerGone = true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return v | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // GetRun returns a run with the jobs of an attempt (0 for the latest). | |
| 111 | + | func (c *CI) GetRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error) { | |
| 112 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 113 | + | return ports.RunDetail{}, err | |
| 114 | + | } | |
| 115 | + | run, err := c.runOf(ctx, name, runID) | |
| 116 | + | if err != nil { | |
| 117 | + | return ports.RunDetail{}, err | |
| 118 | + | } | |
| 119 | + | latest := run.Attempt | |
| 120 | + | if attempt < 0 || attempt > latest { | |
| 121 | + | return ports.RunDetail{}, domain.ErrNotFound | |
| 122 | + | } | |
| 123 | + | if attempt == 0 { | |
| 124 | + | attempt = latest | |
| 125 | + | } | |
| 126 | + | jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt) | |
| 127 | + | if err != nil { | |
| 128 | + | return ports.RunDetail{}, err | |
| 129 | + | } | |
| 130 | + | if attempt != latest { | |
| 131 | + | run = c.runAtAttempt(run, jobs, attempt) | |
| 132 | + | } | |
| 133 | + | d := ports.RunDetail{Run: run, LatestAttempt: latest} | |
| 134 | + | for _, j := range jobs { | |
| 135 | + | d.Jobs = append(d.Jobs, c.view(ctx, j)) | |
| 136 | + | } | |
| 137 | + | return d, nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // runAtAttempt describes an earlier attempt of a run from its jobs: the run | |
| 141 | + | // record holds the latest attempt only. | |
| 142 | + | func (c *CI) runAtAttempt(run domain.CIRun, jobs []domain.CIJob, attempt int32) domain.CIRun { | |
| 143 | + | rs := &runState{c: c, run: run, jobs: jobs, attempt: attempt} | |
| 144 | + | r := rs.recompute(c.now()) | |
| 145 | + | r.Attempt, r.Version = attempt, run.Version | |
| 146 | + | r.CancelRequested = false | |
| 147 | + | return r | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // jobOf finds a job of the repository by its id and attempt (0 for the | |
| 151 | + | // latest). The attempt is the one of the run it was kept by, or executed in. | |
| 152 | + | func (c *CI) jobOf(ctx context.Context, name domain.RepoName, jobID string, attempt int32) (domain.CIRun, domain.CIJob, error) { | |
| 153 | + | runID, _, _, ok := domain.ParseJobID(jobID) | |
| 154 | + | if !ok { | |
| 155 | + | return domain.CIRun{}, domain.CIJob{}, domain.ErrNotFound | |
| 156 | + | } | |
| 157 | + | run, err := c.runOf(ctx, name, runID) | |
| 158 | + | if err != nil { | |
| 159 | + | return domain.CIRun{}, domain.CIJob{}, err | |
| 160 | + | } | |
| 161 | + | if attempt < 0 || attempt > run.Attempt { | |
| 162 | + | return domain.CIRun{}, domain.CIJob{}, domain.ErrNotFound | |
| 163 | + | } | |
| 164 | + | if attempt == 0 { | |
| 165 | + | attempt = run.Attempt | |
| 166 | + | } | |
| 167 | + | j, err := c.cfg.Store.GetJob(ctx, runID, attempt, jobID) | |
| 168 | + | if err != nil { | |
| 169 | + | return domain.CIRun{}, domain.CIJob{}, err | |
| 170 | + | } | |
| 171 | + | return run, j, nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // GetJob returns one job with its steps. | |
| 175 | + | func (c *CI) GetJob(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32) (ports.JobView, error) { | |
| 176 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 177 | + | return ports.JobView{}, err | |
| 178 | + | } | |
| 179 | + | _, j, err := c.jobOf(ctx, name, jobID, attempt) | |
| 180 | + | if err != nil { | |
| 181 | + | return ports.JobView{}, err | |
| 182 | + | } | |
| 183 | + | return c.view(ctx, j), nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // --- log streaming --- | |
| 187 | + | ||
| 188 | + | // acquireStream counts a log stream against the limits. | |
| 189 | + | func (c *CI) acquireStream(client string) (release func(), err error) { | |
| 190 | + | c.mu.Lock() | |
| 191 | + | defer c.mu.Unlock() | |
| 192 | + | if c.streams >= MaxCILogStreams || (client != "" && c.perClient[client] >= MaxCILogStreamsPerClient) { | |
| 193 | + | return nil, fmt.Errorf("%w: too many log streams", domain.ErrResourceExhausted) | |
| 194 | + | } | |
| 195 | + | c.streams++ | |
| 196 | + | if client != "" { | |
| 197 | + | c.perClient[client]++ | |
| 198 | + | } | |
| 199 | + | return func() { | |
| 200 | + | c.mu.Lock() | |
| 201 | + | defer c.mu.Unlock() | |
| 202 | + | c.streams-- | |
| 203 | + | if client != "" { | |
| 204 | + | if c.perClient[client]--; c.perClient[client] <= 0 { | |
| 205 | + | delete(c.perClient, client) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | }, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // GetJobLog streams the lines of a job log from an offset, in batches of at | |
| 212 | + | // most 1,000 lines and 64 KiB. With Follow it goes on while the job runs, and | |
| 213 | + | // ends once the job completed and every line was sent. | |
| 214 | + | func (c *CI) GetJobLog(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error { | |
| 215 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 216 | + | return err | |
| 217 | + | } | |
| 218 | + | if params.Offset < 0 { | |
| 219 | + | return fmt.Errorf("%w: negative offset", domain.ErrInvalid) | |
| 220 | + | } | |
| 221 | + | _, j, err := c.jobOf(ctx, name, params.JobID, params.Attempt) | |
| 222 | + | if err != nil { | |
| 223 | + | return err | |
| 224 | + | } | |
| 225 | + | release, err := c.acquireStream(params.Client) | |
| 226 | + | if err != nil { | |
| 227 | + | return err | |
| 228 | + | } | |
| 229 | + | defer release() | |
| 230 | + | if params.Ready != nil { | |
| 231 | + | params.Ready() | |
| 232 | + | } | |
| 233 | + | ref := logRef(j) | |
| 234 | + | jref := jobRef(j) | |
| 235 | + | offset := params.Offset | |
| 236 | + | checked := time.Now() | |
| 237 | + | // revalidate asks again whether the token of a stored-token caller still | |
| 238 | + | // exists, at most every few seconds, whether the stream is busy or idle: a | |
| 239 | + | // token revoked while a long log is sent stops the stream. | |
| 240 | + | revalidate := func() error { | |
| 241 | + | if p.TokenID() == "" || c.cfg.Auth == nil || time.Since(checked) <= DefaultRevalidateEvery { | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | if err := c.cfg.Auth.Revalidate(ctx, p); err != nil { | |
| 245 | + | return err | |
| 246 | + | } | |
| 247 | + | checked = time.Now() | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | for { | |
| 251 | + | wakeJob, wakeLog := c.jobs.wait(jref), c.cfg.Logs.Changed(ref) | |
| 252 | + | cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID) | |
| 253 | + | if err != nil { | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | done := cur.Ended() | |
| 257 | + | lines, err := c.cfg.Logs.Read(ctx, ref, offset, domain.CIMaxLogLines, domain.CIMaxLogChunkBytes) | |
| 258 | + | switch { | |
| 259 | + | case errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrOutOfRange): | |
| 260 | + | // No line yet (a queued job has no log), or fewer lines than the | |
| 261 | + | // offset. Without follow, and for a job that ended (follow changes | |
| 262 | + | // nothing then), that is the answer (contract: not_found, | |
| 263 | + | // out_of_range). A job that runs may still write the line: wait. | |
| 264 | + | if !params.Follow || done { | |
| 265 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 266 | + | return domain.ErrNotFound | |
| 267 | + | } | |
| 268 | + | return fmt.Errorf("%w: the log has fewer than %d lines", domain.ErrOutOfRange, offset) | |
| 269 | + | } | |
| 270 | + | lines = nil | |
| 271 | + | case err != nil: | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | if len(lines) > 0 { | |
| 275 | + | if err := revalidate(); err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | batch := ports.LogBatch{Offset: offset, Lines: c.withSteps(ctx, cur, offset, lines)} | |
| 279 | + | if err := send(batch); err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | offset += int64(len(lines)) | |
| 283 | + | continue // more may be waiting | |
| 284 | + | } | |
| 285 | + | if !params.Follow || done { | |
| 286 | + | return nil | |
| 287 | + | } | |
| 288 | + | if err := revalidate(); err != nil { | |
| 289 | + | return err | |
| 290 | + | } | |
| 291 | + | select { | |
| 292 | + | case <-wakeJob: | |
| 293 | + | case <-wakeLog: | |
| 294 | + | case <-time.After(DefaultRevalidateEvery): | |
| 295 | + | case <-ctx.Done(): | |
| 296 | + | return nil | |
| 297 | + | case <-c.ctx.Done(): | |
| 298 | + | return nil | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // withSteps tags the lines of a batch with their step from the step ranges. | |
| 304 | + | func (c *CI) withSteps(_ context.Context, j domain.CIJob, offset int64, lines []domain.CILogLine) []ports.JobLogLine { | |
| 305 | + | out := make([]ports.JobLogLine, len(lines)) | |
| 306 | + | for i, l := range lines { | |
| 307 | + | out[i] = ports.JobLogLine{Step: domain.StepOfLine(j.Steps, offset+int64(i)), CILogLine: l} | |
| 308 | + | } | |
| 309 | + | return out | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // --- cancel and rerun --- | |
| 313 | + | ||
| 314 | + | // CancelRun cancels the latest attempt of a run. The principal needs ci:write | |
| 315 | + | // on the repository. Jobs not given to a runner end at once; the others get a | |
| 316 | + | // cancel request, and the run completes when they have ended. | |
| 317 | + | func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64) (domain.CIRun, error) { | |
| 318 | + | if _, err := c.writableRepo(ctx, p, name); err != nil { | |
| 319 | + | return domain.CIRun{}, err | |
| 320 | + | } | |
| 321 | + | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 322 | + | return domain.CIRun{}, err | |
| 323 | + | } | |
| 324 | + | var out domain.CIRun | |
| 325 | + | err := c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 326 | + | if rs.run.Status == domain.CICompleted { | |
| 327 | + | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | |
| 328 | + | } | |
| 329 | + | if !rs.run.CancelRequested { | |
| 330 | + | rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true | |
| 331 | + | rs.cancelAll("Canceled by " + authorOf(p)) | |
| 332 | + | } | |
| 333 | + | if err := rs.flush(ctx); err != nil { | |
| 334 | + | return err | |
| 335 | + | } | |
| 336 | + | out = rs.run | |
| 337 | + | return nil | |
| 338 | + | }) | |
| 339 | + | if err != nil { | |
| 340 | + | return domain.CIRun{}, err | |
| 341 | + | } | |
| 342 | + | ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run cancelled") | |
| 343 | + | return out, nil | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // RerunRun starts a new attempt of a completed run, from the stored workflow | |
| 347 | + | // file: all the jobs, or with failedOnly the jobs that did not succeed and | |
| 348 | + | // those that need them; the others are kept with their result, steps and log. | |
| 349 | + | func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) { | |
| 350 | + | repo, err := c.writableRepo(ctx, p, name) | |
| 351 | + | if err != nil { | |
| 352 | + | return domain.CIRun{}, err | |
| 353 | + | } | |
| 354 | + | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 355 | + | return domain.CIRun{}, err | |
| 356 | + | } | |
| 357 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 358 | + | if rs.run.Status != domain.CICompleted { | |
| 359 | + | return fmt.Errorf("%w: run %d has not completed", domain.ErrPreconditionFailed, runID) | |
| 360 | + | } | |
| 361 | + | if len(rs.jobs) == 0 { | |
| 362 | + | return fmt.Errorf("%w: run %d has no job to run again", domain.ErrPreconditionFailed, runID) | |
| 363 | + | } | |
| 364 | + | redo, err := rerunSet(rs, failedOnly) | |
| 365 | + | if err != nil { | |
| 366 | + | return err | |
| 367 | + | } | |
| 368 | + | next := rs.run.Attempt + 1 | |
| 369 | + | now := c.now() | |
| 370 | + | w := ports.CIWrite{} | |
| 371 | + | var fresh []domain.CIJob | |
| 372 | + | for _, j := range rs.jobs { | |
| 373 | + | if !redo[j.Key] { | |
| 374 | + | kept := j // kept with its own attempt, steps and log | |
| 375 | + | kept.RunAttempt, kept.Version = next, 0 | |
| 376 | + | w.Jobs = append(w.Jobs, kept) | |
| 377 | + | fresh = append(fresh, kept) | |
| 378 | + | continue | |
| 379 | + | } | |
| 380 | + | if j.Combo > 0 { | |
| 381 | + | continue // the matrix is expanded again when the job is planned | |
| 382 | + | } | |
| 383 | + | n := domain.CIJob{ | |
| 384 | + | ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo, | |
| 385 | + | Key: j.Key, Name: j.Key, Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: j.Index, CreatedAt: now, | |
| 386 | + | } | |
| 387 | + | w.Jobs = append(w.Jobs, n) | |
| 388 | + | fresh = append(fresh, n) | |
| 389 | + | } | |
| 390 | + | run := rs.run | |
| 391 | + | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | |
| 392 | + | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | |
| 393 | + | run.CancelRequested, run.CancelRequestedAt = false, time.Time{} | |
| 394 | + | w.Runs = []domain.CIRun{run} | |
| 395 | + | rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}} | |
| 396 | + | for _, j := range fresh { | |
| 397 | + | w.Checks = append(w.Checks, rs2.checkOf(j)) | |
| 398 | + | } | |
| 399 | + | v, err := c.cfg.Store.Apply(ctx, w) | |
| 400 | + | if err != nil { | |
| 401 | + | return err | |
| 402 | + | } | |
| 403 | + | run.Version = v | |
| 404 | + | c.flows.drop(runID) | |
| 405 | + | c.publishRun(ctx, repo, run) | |
| 406 | + | for _, j := range w.Jobs { | |
| 407 | + | j.Version = v | |
| 408 | + | c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 409 | + | } | |
| 410 | + | return nil | |
| 411 | + | }) | |
| 412 | + | if err != nil { | |
| 413 | + | return domain.CIRun{}, err | |
| 414 | + | } | |
| 415 | + | // Plan the new attempt: the kept jobs count as the results of their needs. | |
| 416 | + | if err := c.advanceRun(ctx, runID); err != nil { | |
| 417 | + | return domain.CIRun{}, err | |
| 418 | + | } | |
| 419 | + | run, err := c.runOf(ctx, name, runID) | |
| 420 | + | if err != nil { | |
| 421 | + | return domain.CIRun{}, err | |
| 422 | + | } | |
| 423 | + | ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Int32("attempt", run.Attempt).Bool("failed_only", failedOnly). | |
| 424 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run started again") | |
| 425 | + | return run, nil | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // rerunSet returns the keys of the jobs to run again: all of them, or the ones | |
| 429 | + | // that did not succeed and those that need them, directly or not. | |
| 430 | + | func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |
| 431 | + | redo := map[string]bool{} | |
| 432 | + | if !failedOnly { | |
| 433 | + | for _, j := range rs.jobs { | |
| 434 | + | redo[j.Key] = true | |
| 435 | + | } | |
| 436 | + | return redo, nil | |
| 437 | + | } | |
| 438 | + | for _, j := range rs.jobs { | |
| 439 | + | switch j.Conclusion { | |
| 440 | + | case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired: | |
| 441 | + | redo[j.Key] = true | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | if len(redo) == 0 { | |
| 445 | + | return nil, fmt.Errorf("%w: no job of run %d failed", domain.ErrPreconditionFailed, rs.run.ID) | |
| 446 | + | } | |
| 447 | + | for changed := true; changed; { | |
| 448 | + | changed = false | |
| 449 | + | for _, j := range rs.jobs { | |
| 450 | + | if redo[j.Key] { | |
| 451 | + | continue | |
| 452 | + | } | |
| 453 | + | if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) { | |
| 454 | + | redo[j.Key], changed = true, true | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | return redo, nil | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func (w *wfCache) drop(runID int64) { | |
| 462 | + | w.mu.Lock() | |
| 463 | + | delete(w.m, runID) | |
| 464 | + | w.mu.Unlock() | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // --- checks --- | |
| 468 | + | ||
| 469 | + | // CommitChecks returns the checks of a commit, without access control. A | |
| 470 | + | // commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted): | |
| 471 | + | // the merge rule cannot answer from a part of them. | |
| 472 | + | func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) { | |
| 473 | + | checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks) | |
| 474 | + | if err != nil { | |
| 475 | + | return nil, err | |
| 476 | + | } | |
| 477 | + | if more { | |
| 478 | + | return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks) | |
| 479 | + | } | |
| 480 | + | return checks, nil | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // ListChecks returns the checks of a commit: the one of each job of the latest | |
| 484 | + | // attempt of each run on it. A commit without a run has none. | |
| 485 | + | func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.RepoName, sha string) (ports.ChecksResult, error) { | |
| 486 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 487 | + | return ports.ChecksResult{}, err | |
| 488 | + | } | |
| 489 | + | if !isHex40(sha) { | |
| 490 | + | return ports.ChecksResult{}, fmt.Errorf("%w: a commit is 40 lowercase hexadecimal characters", domain.ErrInvalidObjectID) | |
| 491 | + | } | |
| 492 | + | checks, more, err := c.cfg.Store.ListChecks(ctx, name, sha, MaxCIChecks) | |
| 493 | + | if err != nil { | |
| 494 | + | return ports.ChecksResult{}, err | |
| 495 | + | } | |
| 496 | + | res := ports.ChecksResult{Truncated: more} | |
| 497 | + | var runners []domain.CIRunner | |
| 498 | + | for _, ck := range checks { | |
| 499 | + | v := ports.CheckView{CICheck: ck} | |
| 500 | + | if ck.Status == domain.CIQueued { | |
| 501 | + | if runners == nil { | |
| 502 | + | runners, _ = c.cfg.Store.ListRunners(ctx) | |
| 503 | + | } | |
| 504 | + | v.WaitingForRunner = c.noRunnerFor(ctx, ck, runners) | |
| 505 | + | } | |
| 506 | + | res.Checks = append(res.Checks, v) | |
| 507 | + | } | |
| 508 | + | return res, nil | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // noRunnerFor reports whether no registered runner can take a queued check's | |
| 512 | + | // job. A job that waits for its needs is not waiting for a runner. | |
| 513 | + | func (c *CI) noRunnerFor(ctx context.Context, ck domain.CICheck, runners []domain.CIRunner) bool { | |
| 514 | + | if ck.JobID == "" { | |
| 515 | + | return false | |
| 516 | + | } | |
| 517 | + | j, err := c.cfg.Store.GetJob(ctx, ck.RunID, ck.Attempt, ck.JobID) | |
| 518 | + | if err != nil || j.Phase != domain.CIPhaseReady { | |
| 519 | + | return false | |
| 520 | + | } | |
| 521 | + | return !slices.ContainsFunc(runners, func(r domain.CIRunner) bool { return domain.LabelsMatch(j.Labels, r.Labels) }) | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // --- runners --- | |
| 525 | + | ||
| 526 | + | // ListRunners returns a page of the runners ordered by name. The principal | |
| 527 | + | // needs runner:register on a token that is not restricted to repositories. | |
| 528 | + | func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) { | |
| 529 | + | if err := canManageRunners(p); err != nil { | |
| 530 | + | return ports.RunnerPage{}, err | |
| 531 | + | } | |
| 532 | + | n, err := pageSize(size) | |
| 533 | + | if err != nil { | |
| 534 | + | return ports.RunnerPage{}, err | |
| 535 | + | } | |
| 536 | + | after := "" | |
| 537 | + | if token != "" { | |
| 538 | + | raw, ok := decodeToken(token, "rn1:") | |
| 539 | + | if !ok || raw == "" { | |
| 540 | + | return ports.RunnerPage{}, fmt.Errorf("%w: runners", domain.ErrInvalidPageToken) | |
| 541 | + | } | |
| 542 | + | after = raw | |
| 543 | + | } | |
| 544 | + | all, err := c.cfg.Store.ListRunners(ctx) | |
| 545 | + | if err != nil { | |
| 546 | + | return ports.RunnerPage{}, err | |
| 547 | + | } | |
| 548 | + | busy := map[string]bool{} | |
| 549 | + | if active, err := c.cfg.Store.ListActiveJobs(ctx); err == nil { | |
| 550 | + | for _, j := range active { | |
| 551 | + | if j.Phase == domain.CIPhaseRunning { | |
| 552 | + | busy[j.RunnerID] = true | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | var page ports.RunnerPage | |
| 557 | + | var rows []domain.CIRunner | |
| 558 | + | rows, page.NextPageToken = pageAfter(all, n, | |
| 559 | + | func(r domain.CIRunner) bool { return after == "" || strings.ToLower(r.Name) > after }, | |
| 560 | + | func(r domain.CIRunner) string { | |
| 561 | + | return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name))) | |
| 562 | + | }) | |
| 563 | + | c.mu.Lock() | |
| 564 | + | defer c.mu.Unlock() | |
| 565 | + | for _, r := range rows { | |
| 566 | + | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]}) | |
| 567 | + | } | |
| 568 | + | return page, nil | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // RemoveRunner removes a runner: its token stops working at once and a poll of | |
| 572 | + | // it in progress ends with unauthenticated. A job it runs is not affected. | |
| 573 | + | func (c *CI) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | |
| 574 | + | if err := canManageRunners(p); err != nil { | |
| 575 | + | return err | |
| 576 | + | } | |
| 577 | + | r, err := c.cfg.Store.RemoveRunner(ctx, id) | |
| 578 | + | if err != nil { | |
| 579 | + | return err | |
| 580 | + | } | |
| 581 | + | c.forgetRunners([]domain.CIRunner{r}) | |
| 582 | + | ctxLog(ctx).Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner removed") | |
| 583 | + | return nil | |
| 584 | + | } | |
A internal/usecase/ci_trigger.go +512 -0
| @@ -0,0 +1,512 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "path" | |
| 12 | + | "runtime/debug" | |
| 13 | + | "slices" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/zerowrap" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite-actions/workflow" | |
| 21 | + | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | workflowDir = ".github/workflows" | |
| 27 | + | // maxRunError bounds Run.error (8 KiB, run.proto). | |
| 28 | + | maxRunError = 8 << 10 | |
| 29 | + | // maxPushCommits is the number of commits of the push event payload. | |
| 30 | + | maxPushCommits = 20 | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // ciTrigger is a push waiting to be planned. | |
| 34 | + | type ciTrigger struct { | |
| 35 | + | repo domain.RepoName | |
| 36 | + | updates []domain.RefUpdate | |
| 37 | + | actor string | |
| 38 | + | at time.Time | |
| 39 | + | log zerowrap.Logger | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // OnPush queues the refs of a push or a merge for planning and returns at once; | |
| 43 | + | // a worker plans them. A full queue drops the trigger, logged at error: a push | |
| 44 | + | // is never slowed down or failed by CI. | |
| 45 | + | func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) { | |
| 46 | + | var kept []domain.RefUpdate | |
| 47 | + | for _, u := range updates { | |
| 48 | + | if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) { | |
| 49 | + | kept = append(kept, u) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if len(kept) == 0 { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 56 | + | c.mu.Lock() | |
| 57 | + | closed := c.closed | |
| 58 | + | c.mu.Unlock() | |
| 59 | + | if closed { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | select { | |
| 63 | + | case c.queue <- t: | |
| 64 | + | default: | |
| 65 | + | t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run") | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (c *CI) triggerLoop() { | |
| 70 | + | defer c.wg.Done() | |
| 71 | + | for { | |
| 72 | + | select { | |
| 73 | + | case <-c.ctx.Done(): | |
| 74 | + | return | |
| 75 | + | case t := <-c.queue: | |
| 76 | + | c.planPush(t) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // planPush plans one push; a panic or an error is logged and never escapes. | |
| 82 | + | func (c *CI) planPush(t ciTrigger) { | |
| 83 | + | ctx := zerowrap.WithCtx(c.ctx, t.log) | |
| 84 | + | defer func() { | |
| 85 | + | if r := recover(); r != nil { | |
| 86 | + | t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked") | |
| 87 | + | } | |
| 88 | + | }() | |
| 89 | + | if err := c.handlePush(ctx, t); err != nil && ctx.Err() == nil { | |
| 90 | + | t.log.Error().Err(err).Str("repo", t.repo.String()).Msg("ci trigger failed") | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // handlePush starts the workflows that match each ref of the push. | |
| 95 | + | func (c *CI) handlePush(ctx context.Context, t ciTrigger) error { | |
| 96 | + | // CI is on once a runner is registered (plan, "Config"): without one a push | |
| 97 | + | // makes no run and no check, so a forge that does not use CI shows nothing. | |
| 98 | + | runners, err := c.cfg.Store.ListRunners(ctx) | |
| 99 | + | if err != nil { | |
| 100 | + | return fmt.Errorf("list the runners: %w", err) | |
| 101 | + | } | |
| 102 | + | if len(runners) == 0 { | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | tags := 0 | |
| 106 | + | for _, u := range t.updates { | |
| 107 | + | if strings.HasPrefix(u.Ref, "refs/tags/") { | |
| 108 | + | tags++ | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | var errs []error | |
| 112 | + | for _, u := range t.updates { | |
| 113 | + | if err := c.handleRef(ctx, t, u, tags); err != nil { | |
| 114 | + | errs = append(errs, fmt.Errorf("%s: %w", u.Ref, err)) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return errors.Join(errs...) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // pushFacts is what is known of one pushed ref. | |
| 121 | + | type pushFacts struct { | |
| 122 | + | update domain.RefUpdate | |
| 123 | + | commit domain.ObjectID // the commit the ref points to, tags followed | |
| 124 | + | head domain.Commit | |
| 125 | + | commits []domain.Commit // oldest first, at most maxPushCommits (branches only) | |
| 126 | + | forced bool | |
| 127 | + | created bool | |
| 128 | + | files []string | |
| 129 | + | // filesTruncated: the list of changed files is not available. | |
| 130 | + | filesTruncated bool | |
| 131 | + | tagCount int | |
| 132 | + | isTag bool | |
| 133 | + | // defaultBranch is the default branch of the repository, "" when unknown. | |
| 134 | + | defaultBranch string | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error { | |
| 138 | + | isTag := strings.HasPrefix(u.Ref, "refs/tags/") | |
| 139 | + | if isTag && tagCount > workflow.MaxTagsPerPush { | |
| 140 | + | return nil // GitHub creates no event for the tags of such a push | |
| 141 | + | } | |
| 142 | + | repo, err := c.cfg.Meta.Get(ctx, t.repo) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil // deleted meanwhile | |
| 145 | + | } | |
| 146 | + | facts, err := c.collect(ctx, t.repo, u, isTag, tagCount) | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | files, err := c.workflowFiles(ctx, t.repo, facts.commit) | |
| 151 | + | if err != nil || len(files) == 0 { | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | skip := workflow.SkipCI(facts.head.Message) | |
| 155 | + | var errs []error | |
| 156 | + | for _, f := range files { | |
| 157 | + | wf, perrs := workflow.Parse(f.path, f.content) | |
| 158 | + | switch { | |
| 159 | + | case f.problem != "": | |
| 160 | + | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem)) | |
| 161 | + | case len(perrs) > 0: | |
| 162 | + | var lines []string | |
| 163 | + | for _, e := range perrs { | |
| 164 | + | lines = append(lines, e.Error()) | |
| 165 | + | } | |
| 166 | + | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n"))) | |
| 167 | + | case skip || !wf.MatchPush(workflow.PushInfo{ | |
| 168 | + | Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount, | |
| 169 | + | }): | |
| 170 | + | default: | |
| 171 | + | errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return errors.Join(errs...) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // collect reads what the payload and the filters need from Git. | |
| 178 | + | func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpdate, isTag bool, tagCount int) (pushFacts, error) { | |
| 179 | + | newID, err := domain.ParseObjectID(u.New) | |
| 180 | + | if err != nil { | |
| 181 | + | return pushFacts{}, err | |
| 182 | + | } | |
| 183 | + | f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount} | |
| 184 | + | if def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo); err == nil { | |
| 185 | + | f.defaultBranch = def | |
| 186 | + | } | |
| 187 | + | res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID)) | |
| 188 | + | if err != nil { | |
| 189 | + | return f, fmt.Errorf("resolve %s: %w", u.New, err) | |
| 190 | + | } | |
| 191 | + | f.commit = res.CommitID | |
| 192 | + | if isTag { | |
| 193 | + | cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 194 | + | if err != nil { | |
| 195 | + | return f, fmt.Errorf("read the commit of the tag: %w", err) | |
| 196 | + | } | |
| 197 | + | if len(cs) == 0 { | |
| 198 | + | return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound) | |
| 199 | + | } | |
| 200 | + | f.head = cs[0] | |
| 201 | + | return f, nil | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | var exclude domain.ObjectID | |
| 205 | + | if !f.created { | |
| 206 | + | if old, err := domain.ParseObjectID(u.Old); err == nil { | |
| 207 | + | exclude = old | |
| 208 | + | if ok, err := c.cfg.Git.IsAncestor(ctx, repo, old, f.commit); err == nil && !ok { | |
| 209 | + | f.forced = true | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | base := exclude | |
| 214 | + | if f.created { | |
| 215 | + | base = c.defaultBase(ctx, repo, strings.TrimPrefix(u.Ref, "refs/heads/"), f.commit) | |
| 216 | + | exclude = base | |
| 217 | + | } | |
| 218 | + | cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit) | |
| 219 | + | if err != nil { | |
| 220 | + | return f, fmt.Errorf("read the commits of the push: %w", err) | |
| 221 | + | } | |
| 222 | + | if len(cs) == 0 { | |
| 223 | + | // A new branch at a commit the default branch has, or a branch rewound | |
| 224 | + | // to an ancestor: no new commit, no changed file. GitHub still runs | |
| 225 | + | // the push workflows that have no paths filter, for the head commit. | |
| 226 | + | head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1) | |
| 227 | + | if err != nil { | |
| 228 | + | return f, fmt.Errorf("read the head commit of the push: %w", err) | |
| 229 | + | } | |
| 230 | + | if len(head) == 0 { | |
| 231 | + | return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound) | |
| 232 | + | } | |
| 233 | + | f.head = head[0] | |
| 234 | + | return f, nil | |
| 235 | + | } | |
| 236 | + | f.head = cs[0] | |
| 237 | + | n := min(len(cs), maxPushCommits) | |
| 238 | + | f.commits = slices.Clone(cs[:n]) | |
| 239 | + | slices.Reverse(f.commits) | |
| 240 | + | if more || base == f.commit { | |
| 241 | + | // More than 1,000 commits: the diff is not looked at (it runs). | |
| 242 | + | f.filesTruncated = more | |
| 243 | + | } | |
| 244 | + | if !more && base != f.commit { | |
| 245 | + | paths, _, err := c.cfg.Diff.ChangedPaths(ctx, repo, base, f.commit, workflow.MaxFilesEvaluated) | |
| 246 | + | if err != nil { | |
| 247 | + | f.filesTruncated = true // diff unavailable: runs | |
| 248 | + | } else { | |
| 249 | + | f.files = paths | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return f, nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // defaultBase is the merge base of a new branch with the default branch, the | |
| 256 | + | // zero id when there is none (every file of the branch counts). | |
| 257 | + | func (c *CI) defaultBase(ctx context.Context, repo domain.RepoName, branch string, head domain.ObjectID) domain.ObjectID { | |
| 258 | + | def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo) | |
| 259 | + | if err != nil || !exists || def == branch { | |
| 260 | + | return domain.ObjectID{} | |
| 261 | + | } | |
| 262 | + | tip, err := c.cfg.Git.BranchHead(ctx, repo, def) | |
| 263 | + | if err != nil { | |
| 264 | + | return domain.ObjectID{} | |
| 265 | + | } | |
| 266 | + | base, err := c.cfg.Git.MergeBase(ctx, repo, tip, head) | |
| 267 | + | if err != nil { | |
| 268 | + | return domain.ObjectID{} | |
| 269 | + | } | |
| 270 | + | return base | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | type workflowFile struct { | |
| 274 | + | path string | |
| 275 | + | content []byte | |
| 276 | + | // problem is set when the file cannot even be parsed (too large). | |
| 277 | + | problem string | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // workflowFiles reads .github/workflows/*.yml|*.yaml at the commit. | |
| 281 | + | func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | |
| 282 | + | dir, err := domain.ParseTreePath(workflowDir) | |
| 283 | + | if err != nil { | |
| 284 | + | return nil, err | |
| 285 | + | } | |
| 286 | + | tree, err := c.cfg.Reader.GetTree(ctx, repo, domain.RevisionOf(commit), dir, false) | |
| 287 | + | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotDirectory) { | |
| 288 | + | return nil, nil | |
| 289 | + | } | |
| 290 | + | if err != nil { | |
| 291 | + | return nil, fmt.Errorf("list %s: %w", workflowDir, err) | |
| 292 | + | } | |
| 293 | + | var out []workflowFile | |
| 294 | + | for _, e := range tree.Entries { | |
| 295 | + | ext := strings.ToLower(path.Ext(e.Name)) | |
| 296 | + | if (e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable) || (ext != ".yml" && ext != ".yaml") { | |
| 297 | + | continue | |
| 298 | + | } | |
| 299 | + | if len(out) == maxCIWorkflowFiles { | |
| 300 | + | break | |
| 301 | + | } | |
| 302 | + | wf := workflowFile{path: e.Path} | |
| 303 | + | if wf.path == "" { | |
| 304 | + | wf.path = workflowDir + "/" + e.Name | |
| 305 | + | } | |
| 306 | + | if e.Size > maxCIWorkflowBytes { | |
| 307 | + | wf.problem = fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path) | |
| 308 | + | out = append(out, wf) | |
| 309 | + | continue | |
| 310 | + | } | |
| 311 | + | tp, err := domain.ParseTreePath(wf.path) | |
| 312 | + | if err != nil { | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp) | |
| 316 | + | if err != nil { | |
| 317 | + | return nil, fmt.Errorf("read %s: %w", wf.path, err) | |
| 318 | + | } | |
| 319 | + | wf.content, err = io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1)) | |
| 320 | + | _ = rc.Close() | |
| 321 | + | if err != nil { | |
| 322 | + | return nil, fmt.Errorf("read %s: %w", wf.path, err) | |
| 323 | + | } | |
| 324 | + | if len(wf.content) > maxCIWorkflowBytes { | |
| 325 | + | wf.content, wf.problem = nil, fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path) | |
| 326 | + | } | |
| 327 | + | out = append(out, wf) | |
| 328 | + | } | |
| 329 | + | return out, nil | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // --- the run --- | |
| 333 | + | ||
| 334 | + | func firstLine(s string) string { | |
| 335 | + | line, _, _ := strings.Cut(s, "\n") | |
| 336 | + | return truncateRunes(strings.TrimSpace(line), 256) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // startInvalid records the failed run of an invalid workflow file: no job, the | |
| 340 | + | // errors as Run.error, like GitHub's "Invalid workflow file". | |
| 341 | + | func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error { | |
| 342 | + | now := c.now() | |
| 343 | + | draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 344 | + | run := domain.CIRun{ | |
| 345 | + | Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath, | |
| 346 | + | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | |
| 347 | + | Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), | |
| 348 | + | CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| 349 | + | } | |
| 350 | + | return ports.CIRunDraft{ | |
| 351 | + | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"}, | |
| 352 | + | Checks: []domain.CICheck{{ | |
| 353 | + | ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted, | |
| 354 | + | Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: wfPath, CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| 355 | + | }}, | |
| 356 | + | } | |
| 357 | + | }) | |
| 358 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 359 | + | return nil | |
| 360 | + | } | |
| 361 | + | if err != nil { | |
| 362 | + | return err | |
| 363 | + | } | |
| 364 | + | c.publishRun(ctx, repo, draft.Run) | |
| 365 | + | log := logRun(ctx, draft.Run) | |
| 366 | + | log.Info().Str("workflow", wfPath).Msg("ci run failed: invalid workflow file") | |
| 367 | + | return nil | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun) { | |
| 371 | + | c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: run, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // startRun creates the run of a matching workflow with one waiting record per | |
| 375 | + | // job of the file, then plans the jobs that can start. | |
| 376 | + | func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error { | |
| 377 | + | now := c.now() | |
| 378 | + | name := wf.Name | |
| 379 | + | if name == "" { | |
| 380 | + | name = wfPath | |
| 381 | + | } | |
| 382 | + | draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft { | |
| 383 | + | run := domain.CIRun{ | |
| 384 | + | Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, | |
| 385 | + | Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, | |
| 386 | + | Status: domain.CIQueued, CreatedAt: now, | |
| 387 | + | } | |
| 388 | + | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | |
| 389 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf), | |
| 390 | + | }} | |
| 391 | + | for i, j := range wf.Jobs { | |
| 392 | + | job := domain.CIJob{ | |
| 393 | + | ID: domain.NewJobID(id, i, 0), RunID: id, Attempt: 1, RunAttempt: 1, Repo: repo.Name, Key: j.ID, Name: j.ID, | |
| 394 | + | Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: i, CreatedAt: now, | |
| 395 | + | } | |
| 396 | + | d.Jobs = append(d.Jobs, job) | |
| 397 | + | d.Checks = append(d.Checks, domain.CICheck{ | |
| 398 | + | ID: job.ID, Repo: repo.Name, Sha: run.Sha, Name: job.Name, Status: domain.CIQueued, RunID: id, JobID: job.ID, | |
| 399 | + | Attempt: 1, WorkflowName: name, CreatedAt: now, | |
| 400 | + | }) | |
| 401 | + | } | |
| 402 | + | return d | |
| 403 | + | }) | |
| 404 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 405 | + | return nil | |
| 406 | + | } | |
| 407 | + | if err != nil { | |
| 408 | + | return err | |
| 409 | + | } | |
| 410 | + | c.publishRun(ctx, repo, draft.Run) | |
| 411 | + | for _, j := range draft.Jobs { | |
| 412 | + | c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 413 | + | } | |
| 414 | + | log := logRun(ctx, draft.Run) | |
| 415 | + | log.Info().Str("workflow", wfPath).Str("ref", f.update.Ref).Int("jobs", len(draft.Jobs)).Msg("ci run created") | |
| 416 | + | return c.advanceRun(ctx, draft.Run.ID) | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // --- the github context --- | |
| 420 | + | ||
| 421 | + | // shortID is the 12-character form of an object id that GitHub puts in the | |
| 422 | + | // compare URL of a push. | |
| 423 | + | func shortID(id string) string { | |
| 424 | + | if len(id) > 12 { | |
| 425 | + | return id[:12] | |
| 426 | + | } | |
| 427 | + | return id | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | // retentionDays is the retention in days, as github.retention_days shows it | |
| 431 | + | // (at least 1). | |
| 432 | + | func (c *CI) retentionDays() int { | |
| 433 | + | return max(int(c.cfg.Retention/(24*time.Hour)), 1) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | type obj = map[string]any | |
| 437 | + | ||
| 438 | + | func person(name, email string) obj { return obj{"name": name, "email": email} } | |
| 439 | + | ||
| 440 | + | func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj { | |
| 441 | + | ts := cm.Committer.Time.UTC().Format(time.RFC3339) | |
| 442 | + | tree := "" | |
| 443 | + | if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil { | |
| 444 | + | tree = id.String() | |
| 445 | + | } | |
| 446 | + | return obj{ | |
| 447 | + | "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts, | |
| 448 | + | "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(), | |
| 449 | + | "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""}, | |
| 450 | + | "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""}, | |
| 451 | + | "added": []string{}, "removed": []string{}, "modified": []string{}, | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // githubJSON builds the "github" context of the run as GitHub does for a push | |
| 456 | + | // (the job-dependent keys are added when a job is planned). Values that | |
| 457 | + | // GitHub's documentation does not settle are marked for the recordings. | |
| 458 | + | // | |
| 459 | + | // Checked against the recordings of ermite-conformance (push-branch-create, | |
| 460 | + | // push-tag, push-force, ctx-github-names): ids and counters are strings, so is | |
| 461 | + | // retention_days; "compare" has 12-character ids, the commit page for a created | |
| 462 | + | // ref and "<before>...<after>" for an update; repository.default_branch and | |
| 463 | + | // master_branch are the default branch. | |
| 464 | + | // | |
| 465 | + | // TODO(verify with recording): the empty added/removed/modified lists of the | |
| 466 | + | // push commits. | |
| 467 | + | func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string { | |
| 468 | + | full := c.cfg.Owner + "/" + repo.Name.String() | |
| 469 | + | refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch" | |
| 470 | + | if f.isTag { | |
| 471 | + | refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag" | |
| 472 | + | } | |
| 473 | + | repoURL := c.cfg.PublicURL + "/" + repo.Name.String() | |
| 474 | + | before := f.update.Old | |
| 475 | + | if f.created { | |
| 476 | + | before = domain.ZeroOID | |
| 477 | + | } | |
| 478 | + | compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New) | |
| 479 | + | if f.created { | |
| 480 | + | compare = repoURL + "/commit/" + shortID(f.update.New) | |
| 481 | + | } | |
| 482 | + | commits := make([]any, 0, len(f.commits)) | |
| 483 | + | for _, cm := range f.commits { | |
| 484 | + | commits = append(commits, c.commitJSON(ctx, repo, cm)) | |
| 485 | + | } | |
| 486 | + | event := obj{ | |
| 487 | + | "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced, | |
| 488 | + | "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head), | |
| 489 | + | "pusher": person(run.Actor, ""), | |
| 490 | + | "repository": obj{ | |
| 491 | + | "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate, | |
| 492 | + | "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git", | |
| 493 | + | "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL, | |
| 494 | + | }, | |
| 495 | + | "sender": obj{"login": run.Actor, "type": "User"}, | |
| 496 | + | } | |
| 497 | + | wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref | |
| 498 | + | gh := obj{ | |
| 499 | + | "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false, | |
| 500 | + | "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git", | |
| 501 | + | "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef, | |
| 502 | + | "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10), | |
| 503 | + | "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3", | |
| 504 | + | "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()), | |
| 505 | + | "head_ref": "", "base_ref": "", | |
| 506 | + | } | |
| 507 | + | b, err := json.Marshal(gh) | |
| 508 | + | if err != nil { | |
| 509 | + | return "{}" | |
| 510 | + | } | |
| 511 | + | return string(b) | |
| 512 | + | } | |
A internal/usecase/ci_unit_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | "bnema.dev/ermite/internal/usecase" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // mergeUnit is a merge request that is ready to merge, with a checks reader. | |
| 20 | + | type mergeUnit struct { | |
| 21 | + | *mrUnit | |
| 22 | + | checks *mocks.CIChecksReader | |
| 23 | + | trig *mocks.WorkflowTrigger | |
| 24 | + | head domain.ObjectID | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func newMergeUnit(t *testing.T, block bool) *mergeUnit { | |
| 28 | + | t.Helper() | |
| 29 | + | u := &mrUnit{ | |
| 30 | + | meta: mocks.NewRepoMetaStore(t), | |
| 31 | + | store: mocks.NewMergeRequestStore(t), | |
| 32 | + | repos: mocks.NewGitRepos(t), | |
| 33 | + | git: mocks.NewMergeGit(t), | |
| 34 | + | repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate}, | |
| 35 | + | } | |
| 36 | + | u.repo.Settings = domain.DefaultRepoSettings() | |
| 37 | + | u.repo.Settings.FailingChecksBlock = block | |
| 38 | + | clock := mocks.NewClock(t) | |
| 39 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 40 | + | sched := mocks.NewMaintenanceScheduler(t) | |
| 41 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe() | |
| 42 | + | m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)} | |
| 43 | + | u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 44 | + | Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git, | |
| 45 | + | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched, | |
| 46 | + | CI: m.trig, Checks: m.checks, | |
| 47 | + | }) | |
| 48 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) | |
| 49 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{ | |
| 50 | + | Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", | |
| 51 | + | }, nil) | |
| 52 | + | tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111") | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil) | |
| 55 | + | u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil) | |
| 56 | + | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil) | |
| 57 | + | return m | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (m *mergeUnit) merge() error { | |
| 61 | + | _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head}) | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // AC5: the merge rule under FailingChecksBlock. | |
| 66 | + | func TestMergeRuleOfFailingChecksBlock(t *testing.T) { | |
| 67 | + | t.Parallel() | |
| 68 | + | check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck { | |
| 69 | + | return domain.CICheck{Name: name, Status: status, Conclusion: c} | |
| 70 | + | } | |
| 71 | + | tests := []struct { | |
| 72 | + | name string | |
| 73 | + | checks []domain.CICheck | |
| 74 | + | blocked string // part of the message | |
| 75 | + | }{ | |
| 76 | + | {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"}, | |
| 77 | + | {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"}, | |
| 78 | + | {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"}, | |
| 79 | + | {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"}, | |
| 80 | + | {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"}, | |
| 81 | + | {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"}, | |
| 82 | + | } | |
| 83 | + | for _, tt := range tests { | |
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 86 | + | m := newMergeUnit(t, true) | |
| 87 | + | m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil) | |
| 88 | + | // The merge stops at the rule: the branch mock expects no move. | |
| 89 | + | err := m.merge() | |
| 90 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 91 | + | assert.Contains(t, err.Error(), tt.blocked) | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | t.Run("too many checks to tell", func(t *testing.T) { | |
| 95 | + | t.Parallel() | |
| 96 | + | m := newMergeUnit(t, true) | |
| 97 | + | m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted) | |
| 98 | + | err := m.merge() | |
| 99 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 100 | + | assert.Contains(t, err.Error(), "too many checks") | |
| 101 | + | }) | |
| 102 | + | // The cases where the merge goes on (no check, all passing, rule off) need | |
| 103 | + | // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real. | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestCombineChecks(t *testing.T) { | |
| 107 | + | t.Parallel() | |
| 108 | + | ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck { | |
| 109 | + | return domain.CICheck{Status: s, Conclusion: c} | |
| 110 | + | } | |
| 111 | + | assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil)) | |
| 112 | + | assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)})) | |
| 113 | + | assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")})) | |
| 114 | + | assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestLabelsMatchAndRunnerLabels(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64") | |
| 120 | + | assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have) | |
| 121 | + | assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive") | |
| 122 | + | assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have)) | |
| 123 | + | assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have)) | |
| 124 | + | assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have)) | |
| 125 | + | assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches") | |
| 126 | + | assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestStepRanges(t *testing.T) { | |
| 130 | + | t.Parallel() | |
| 131 | + | steps := []domain.CIStep{ | |
| 132 | + | {Number: 1, HasLines: true, LogStart: 0}, | |
| 133 | + | {Number: 2}, | |
| 134 | + | {Number: 3, HasLines: true, LogStart: 4}, | |
| 135 | + | {Number: 4, HasLines: true, LogStart: 9}, | |
| 136 | + | {Number: 5}, | |
| 137 | + | } | |
| 138 | + | got := domain.StepRanges(steps, 12) | |
| 139 | + | assert.Equal(t, []domain.StepRange{{Start: 0, End: 4}, {Start: 4, End: 4}, {Start: 4, End: 9}, {Start: 9, End: 12}, {Start: 12, End: 12}}, got) | |
| 140 | + | assert.EqualValues(t, 3, domain.StepOfLine(steps, 5)) | |
| 141 | + | assert.EqualValues(t, 1, domain.StepOfLine(steps, 0)) | |
| 142 | + | assert.EqualValues(t, 4, domain.StepOfLine(steps, 11)) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestJobIDsRoundTripAndRejectOthers(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 147 | + | id := domain.NewJobID(42, 3, 1) | |
| 148 | + | run, index, combo, ok := domain.ParseJobID(id) | |
| 149 | + | require.True(t, ok) | |
| 150 | + | assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo}) | |
| 151 | + | for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} { | |
| 152 | + | _, _, _, ok := domain.ParseJobID(bad) | |
| 153 | + | assert.False(t, ok, bad) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestOutputLimitsAndNames(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"})) | |
| 160 | + | assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit") | |
| 161 | + | assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"})) | |
| 162 | + | assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case") | |
| 163 | + | assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))})) | |
| 164 | + | assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestTokenShapes(t *testing.T) { | |
| 168 | + | t.Parallel() | |
| 169 | + | good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0" | |
| 170 | + | assert.True(t, domain.IsJobToken(good)) | |
| 171 | + | assert.False(t, domain.IsRunnerToken(good)) | |
| 172 | + | assert.False(t, domain.IsJobToken(good[:len(good)-1])) | |
| 173 | + | assert.False(t, domain.IsJobToken("ermite_"+good[10:])) | |
| 174 | + | assert.False(t, domain.IsJobToken(good+"=")) | |
| 175 | + | } | |
A internal/usecase/ci_watch.go +292 -0
| @@ -0,0 +1,292 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "runtime/debug" | |
| 9 | + | "strconv" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const sweepEvery = 24 * time.Hour | |
| 16 | + | ||
| 17 | + | // advanceRun plans what can start in the latest attempt of a run. | |
| 18 | + | func (c *CI) advanceRun(ctx context.Context, runID int64) error { | |
| 19 | + | return c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 20 | + | if err := rs.advance(ctx); err != nil { | |
| 21 | + | return err | |
| 22 | + | } | |
| 23 | + | return rs.flush(ctx) | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // resume plans again the runs that were open when the server stopped: jobs | |
| 28 | + | // whose needs ended while it was down are planned now. Running jobs go on: | |
| 29 | + | // their tokens are in the store, and the heartbeat deadline counts from the | |
| 30 | + | // start of this process. | |
| 31 | + | func (c *CI) resume() { | |
| 32 | + | ctx := c.ctx | |
| 33 | + | runs, err := c.cfg.Store.ListOpenRuns(ctx) | |
| 34 | + | if err != nil { | |
| 35 | + | ctxLog(ctx).Error().Err(err).Msg("ci: list the open runs") | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | for _, r := range runs { | |
| 39 | + | if err := c.advanceRun(ctx, r.ID); err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil { | |
| 40 | + | ctxLog(ctx).Error().Err(err).Int64("run_id", r.ID).Msg("ci: plan an open run again") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | c.wakeQueue() | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // watchdog looks at the jobs in flight every tick, and sweeps the old runs | |
| 47 | + | // once a day. | |
| 48 | + | func (c *CI) watchdog() { | |
| 49 | + | defer c.wg.Done() | |
| 50 | + | tick := time.NewTicker(c.cfg.Tick) | |
| 51 | + | defer tick.Stop() | |
| 52 | + | lastSweep := time.Time{} | |
| 53 | + | for { | |
| 54 | + | select { | |
| 55 | + | case <-c.ctx.Done(): | |
| 56 | + | return | |
| 57 | + | case <-tick.C: | |
| 58 | + | } | |
| 59 | + | c.guard("watchdog", func() { | |
| 60 | + | c.checkActive(c.ctx) | |
| 61 | + | c.checkQueue(c.ctx) | |
| 62 | + | if now := c.now(); now.Sub(lastSweep) >= sweepEvery { | |
| 63 | + | lastSweep = now | |
| 64 | + | c.sweep(c.ctx) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // guard runs fn and logs a panic instead of letting it end the worker. | |
| 71 | + | func (c *CI) guard(what string, fn func()) { | |
| 72 | + | defer func() { | |
| 73 | + | if r := recover(); r != nil { | |
| 74 | + | ctxLog(c.ctx).Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("worker", what).Msg("ci worker panicked") | |
| 75 | + | } | |
| 76 | + | }() | |
| 77 | + | fn() | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // checkActive applies the server's deadlines to the jobs given to runners: | |
| 81 | + | // acknowledgement, heartbeat, time budget, and the grace after a cancel. | |
| 82 | + | func (c *CI) checkActive(ctx context.Context) { | |
| 83 | + | jobs, err := c.cfg.Store.ListActiveJobs(ctx) | |
| 84 | + | if err != nil { | |
| 85 | + | ctxLog(ctx).Error().Err(err).Msg("ci: list the active jobs") | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | now := c.now() | |
| 89 | + | for _, j := range jobs { | |
| 90 | + | if !c.due(j, now) { | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 94 | + | cur := rs.find(j.ID) | |
| 95 | + | if cur == nil || !cur.Active() { | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | if !c.enforce(ctx, rs, cur, c.now()) { | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | if err := rs.advance(ctx); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | return rs.flush(ctx) | |
| 105 | + | }) | |
| 106 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil { | |
| 107 | + | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: enforce a deadline") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // lastBeat is the time of the last sign of life of an active job: the later | |
| 113 | + | // of its last heartbeat, its acknowledgement and the start of the server. | |
| 114 | + | func (c *CI) lastBeat(j domain.CIJob) time.Time { | |
| 115 | + | c.mu.Lock() | |
| 116 | + | beat := c.heartbeats[jobRef(j)] | |
| 117 | + | c.mu.Unlock() | |
| 118 | + | for _, t := range []time.Time{j.AckedAt, c.boot} { | |
| 119 | + | if t.After(beat) { | |
| 120 | + | beat = t | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return beat | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // due is a cheap test, on the listed copy, of whether a deadline may have | |
| 127 | + | // passed. | |
| 128 | + | func (c *CI) due(j domain.CIJob, now time.Time) bool { | |
| 129 | + | switch { | |
| 130 | + | case j.Phase == domain.CIPhaseAssigned: | |
| 131 | + | return now.Sub(c.assignedSince(j)) > c.cfg.AckTimeout | |
| 132 | + | case j.CancelReason != domain.CICancelNone: | |
| 133 | + | return now.Sub(j.CancelRequestedAt) > c.cfg.CancelGrace | |
| 134 | + | } | |
| 135 | + | return now.Sub(c.lastBeat(j)) > c.cfg.HeartbeatDeadline || now.After(c.deadline(j)) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // assignedSince is when the acknowledgement of an assignment starts to count: | |
| 139 | + | // its assignment, or the start of this process when that is later, so that a | |
| 140 | + | // runner that could not reach a server that was down is not punished for it | |
| 141 | + | // (the same grace as the heartbeat deadline). | |
| 142 | + | func (c *CI) assignedSince(j domain.CIJob) time.Time { | |
| 143 | + | if c.boot.After(j.AssignedAt) { | |
| 144 | + | return c.boot | |
| 145 | + | } | |
| 146 | + | return j.AssignedAt | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // deadline is when the job's time budget ends for the server: timeout-minutes | |
| 150 | + | // from the assignment, plus the slack that lets the runner's own timeout win. | |
| 151 | + | func (c *CI) deadline(j domain.CIJob) time.Time { | |
| 152 | + | return j.AssignedAt.Add(time.Duration(j.TimeoutMinutes) * time.Minute).Add(c.cfg.TimeoutSlack) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // enforce applies the deadlines to a job read under the lock of its run and | |
| 156 | + | // reports whether it changed. | |
| 157 | + | func (c *CI) enforce(ctx context.Context, rs *runState, j *domain.CIJob, now time.Time) bool { | |
| 158 | + | switch { | |
| 159 | + | case j.Phase == domain.CIPhaseAssigned && now.Sub(c.assignedSince(*j)) > c.cfg.AckTimeout: | |
| 160 | + | c.cancelAssignment(ctx, rs, j) | |
| 161 | + | return true | |
| 162 | + | case j.CancelReason != domain.CICancelNone && now.Sub(j.CancelRequestedAt) > c.cfg.CancelGrace: | |
| 163 | + | conclusion := domain.CICancelled | |
| 164 | + | if j.CancelReason == domain.CICancelTimeout { | |
| 165 | + | conclusion = domain.CITimedOut | |
| 166 | + | } | |
| 167 | + | rs.end(j, conclusion, "The runner "+j.RunnerName+" did not answer the cancellation request in time; the job was ended by the server.") | |
| 168 | + | return true | |
| 169 | + | case j.Phase == domain.CIPhaseRunning && now.Sub(c.lastBeat(*j)) > c.cfg.HeartbeatDeadline: | |
| 170 | + | rs.end(j, domain.CIFailure, domain.CILostMessage(j.RunnerName)) | |
| 171 | + | return true | |
| 172 | + | case j.CancelReason == domain.CICancelNone && now.After(c.deadline(*j)): | |
| 173 | + | rs.requestCancel(j, domain.CICancelTimeout, "The job exceeded its timeout of "+itoa(int64(j.TimeoutMinutes))+" minutes") | |
| 174 | + | return true | |
| 175 | + | } | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func itoa(n int64) string { return strconv.FormatInt(n, 10) } | |
| 180 | + | ||
| 181 | + | // cancelAssignment takes a job back from a runner that did not acknowledge | |
| 182 | + | // it: the token is revoked, what the assignment stored is dropped, and the job | |
| 183 | + | // is queued again with a fresh token at its next assignment. Three in a row | |
| 184 | + | // fail the job. | |
| 185 | + | func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) { | |
| 186 | + | ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled") | |
| 187 | + | _ = c.cfg.Logs.Reset(ctx, logRef(*j)) | |
| 188 | + | j.AssignCancels++ | |
| 189 | + | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | |
| 190 | + | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | |
| 191 | + | j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{} | |
| 192 | + | c.forgetPoll(jobRef(*j)) | |
| 193 | + | if j.CancelReason != domain.CICancelNone { | |
| 194 | + | // Cancelled (by the owner, fail-fast or the time budget) before the | |
| 195 | + | // runner answered: it is over, not queued again for another runner. | |
| 196 | + | conclusion := domain.CICancelled | |
| 197 | + | if j.CancelReason == domain.CICancelTimeout { | |
| 198 | + | conclusion = domain.CITimedOut | |
| 199 | + | } | |
| 200 | + | rs.end(j, conclusion, "") | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | if j.AssignCancels >= domain.CIMaxAssignCancels { | |
| 204 | + | rs.end(j, domain.CIFailure, "The job was given to a runner "+itoa(int64(domain.CIMaxAssignCancels))+" times and none of them acknowledged it.") | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | j.Phase, j.ReadyAt = domain.CIPhaseReady, c.now() | |
| 208 | + | rs.touch(j) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // checkQueue fails the jobs that no runner took within the queue timeout. | |
| 212 | + | func (c *CI) checkQueue(ctx context.Context) { | |
| 213 | + | ready, err := c.cfg.Store.ListReadyJobs(ctx, 0) | |
| 214 | + | if err != nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | now := c.now() | |
| 218 | + | for _, j := range ready { | |
| 219 | + | if now.Sub(j.ReadyAt) <= c.cfg.QueueTimeout { | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 223 | + | cur := rs.find(j.ID) | |
| 224 | + | if cur == nil || cur.Phase != domain.CIPhaseReady || c.now().Sub(cur.ReadyAt) <= c.cfg.QueueTimeout { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | rs.end(cur, domain.CIFailure, domain.CIQueueTimeoutMessage) | |
| 228 | + | if err := rs.advance(ctx); err != nil { | |
| 229 | + | return err | |
| 230 | + | } | |
| 231 | + | return rs.flush(ctx) | |
| 232 | + | }) | |
| 233 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil { | |
| 234 | + | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: end a job nobody took") | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // sweep removes the runs older than the retention, and the logs of runs that | |
| 240 | + | // no longer exist (a repository deleted while the server was down). Every | |
| 241 | + | // failure is logged and the next step still runs: a log directory that cannot | |
| 242 | + | // be removed is tried again at the next sweep. | |
| 243 | + | func (c *CI) sweep(ctx context.Context) { | |
| 244 | + | log := ctxLog(ctx) | |
| 245 | + | ids, err := c.cfg.Store.Sweep(ctx, c.now().Add(-c.cfg.Retention)) | |
| 246 | + | if err != nil { | |
| 247 | + | log.Error().Err(err).Msg("ci: sweep the old runs") | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | if len(ids) > 0 { | |
| 251 | + | if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil { | |
| 252 | + | log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs") | |
| 253 | + | } | |
| 254 | + | for _, id := range ids { | |
| 255 | + | c.locks.Delete(id) | |
| 256 | + | } | |
| 257 | + | log.Info().Int("runs", len(ids)).Msg("ci: old runs removed") | |
| 258 | + | } | |
| 259 | + | onDisk, err := c.cfg.Logs.RunIDs(ctx) | |
| 260 | + | if err != nil { | |
| 261 | + | log.Error().Err(err).Msg("ci: list the log directories") | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | var orphans []int64 | |
| 265 | + | for _, id := range onDisk { | |
| 266 | + | switch _, err := c.cfg.Store.GetRunSource(ctx, id); { | |
| 267 | + | case errors.Is(err, domain.ErrNotFound): | |
| 268 | + | orphans = append(orphans, id) | |
| 269 | + | case err != nil: | |
| 270 | + | log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a log directory") | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | if len(orphans) > 0 { | |
| 274 | + | if err := c.cfg.Logs.RemoveRuns(ctx, orphans); err != nil { | |
| 275 | + | log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the logs of runs that no longer exist") | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // RunIDs lists the runs of a repository, for the cleanup that follows its | |
| 281 | + | // deletion. | |
| 282 | + | func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) { | |
| 283 | + | return c.cfg.Store.RunIDs(ctx, repo) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // RemoveLogs deletes the logs of runs of a repository that was deleted. | |
| 287 | + | func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error { | |
| 288 | + | for _, id := range runIDs { | |
| 289 | + | c.locks.Delete(id) | |
| 290 | + | } | |
| 291 | + | return c.cfg.Logs.RemoveRuns(ctx, runIDs) | |
| 292 | + | } | |
M internal/usecase/events.go +15 -0
| @@ -176,6 +176,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 176 | 176 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 177 | 177 | case domain.CommentAdded: | |
| 178 | 178 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 179 | + | case domain.RunUpdated: | |
| 180 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 181 | + | case domain.JobUpdated: | |
| 182 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 179 | 183 | } | |
| 180 | 184 | return bufferedEvent{known: true} | |
| 181 | 185 | } | |
| @@ -479,6 +483,17 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 479 | 483 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 480 | 484 | case domain.CommentAdded: | |
| 481 | 485 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 486 | + | case domain.RunUpdated: | |
| 487 | + | // CI events go on the topic of their repository only, never on home. | |
| 488 | + | if !w.repos[ev.Run.Repo] { | |
| 489 | + | return nil | |
| 490 | + | } | |
| 491 | + | return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send) | |
| 492 | + | case domain.JobUpdated: | |
| 493 | + | if !w.repos[ev.Job.Repo] { | |
| 494 | + | return nil | |
| 495 | + | } | |
| 496 | + | return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send) | |
| 482 | 497 | } | |
| 483 | 498 | return nil // an event this version does not stream | |
| 484 | 499 | } | |
M internal/usecase/export_test.go +8 -1
| @@ -2,7 +2,14 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package usecase | |
| 4 | 4 | ||
| 5 | - | import "bnema.dev/ermite/internal/domain" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | ) | |
| 6 | 10 | ||
| 7 | 11 | // AuthorOf exposes authorOf to the tests. | |
| 8 | 12 | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |
| 13 | + | ||
| 14 | + | // Sweep exposes the daily sweep to the tests. | |
| 15 | + | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | |
M internal/usecase/mergerequests.go +51 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "strconv" | |
| 11 | + | "strings" | |
| 11 | 12 | "sync" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/zerowrap" | |
| @@ -48,6 +49,8 @@ type MergeRequests struct { | |||
| 48 | 49 | bus ports.EventBus | |
| 49 | 50 | clock ports.Clock | |
| 50 | 51 | maint ports.MaintenanceScheduler | |
| 52 | + | ci ports.WorkflowTrigger // nil: no CI | |
| 53 | + | checks ports.CIChecksReader // nil: no CI, so no check blocks a merge | |
| 51 | 54 | ||
| 52 | 55 | mu sync.Mutex | |
| 53 | 56 | locks map[domain.RepoName]chan struct{} | |
| @@ -71,13 +74,18 @@ type MergeRequestsConfig struct { | |||
| 71 | 74 | Clock ports.Clock | |
| 72 | 75 | // Maint queues the maintenance that follows a merge, as it does a push. | |
| 73 | 76 | Maint ports.MaintenanceScheduler | |
| 77 | + | // CI starts the workflows a merge triggers (GitHub runs "push" workflows | |
| 78 | + | // on a merge) and Checks gives the checks the merge rule reads. Both are | |
| 79 | + | // optional: without them there is no CI. | |
| 80 | + | CI ports.WorkflowTrigger | |
| 81 | + | Checks ports.CIChecksReader | |
| 74 | 82 | } | |
| 75 | 83 | ||
| 76 | 84 | // NewMergeRequests builds the merge request use cases. | |
| 77 | 85 | func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |
| 78 | 86 | return &MergeRequests{ | |
| 79 | 87 | meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer, | |
| 80 | - | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, | |
| 88 | + | committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, | |
| 81 | 89 | locks: make(map[domain.RepoName]chan struct{}), | |
| 82 | 90 | } | |
| 83 | 91 | } | |
| @@ -753,6 +761,11 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 753 | 761 | case !ok: | |
| 754 | 762 | return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target) | |
| 755 | 763 | } | |
| 764 | + | if settings.FailingChecksBlock { | |
| 765 | + | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 766 | + | return domain.MergeRequest{}, false, err | |
| 767 | + | } | |
| 768 | + | } | |
| 756 | 769 | if params.DeleteSource { | |
| 757 | 770 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 758 | 771 | return domain.MergeRequest{}, false, err | |
| @@ -792,9 +805,46 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 792 | 805 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 793 | 806 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 794 | 807 | }) | |
| 808 | + | if m.ci != nil { | |
| 809 | + | // GitHub runs the "push" workflows of a merge, with the owner as pusher. | |
| 810 | + | m.ci.OnPush(ctx, name, updates[:1], domain.Owner()) | |
| 811 | + | } | |
| 795 | 812 | return mr, deleted, nil | |
| 796 | 813 | } | |
| 797 | 814 | ||
| 815 | + | // checksAllow is the merge rule of FailingChecksBlock: every check of the | |
| 816 | + | // head must have ended as success, skipped or neutral. A check still queued or | |
| 817 | + | // running blocks too (the merge could land code that then fails), and a head | |
| 818 | + | // without any check merges. | |
| 819 | + | func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error { | |
| 820 | + | if m.checks == nil { | |
| 821 | + | return nil | |
| 822 | + | } | |
| 823 | + | checks, err := m.checks.CommitChecks(ctx, name, head.String()) | |
| 824 | + | if errors.Is(err, domain.ErrResourceExhausted) { | |
| 825 | + | return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err) | |
| 826 | + | } | |
| 827 | + | if err != nil { | |
| 828 | + | return fmt.Errorf("read the checks of %s: %w", head, err) | |
| 829 | + | } | |
| 830 | + | var failing, pending []string | |
| 831 | + | for _, c := range checks { | |
| 832 | + | switch { | |
| 833 | + | case c.Status != domain.CICompleted: | |
| 834 | + | pending = append(pending, c.Name) | |
| 835 | + | case !c.Conclusion.PassesMerge(): | |
| 836 | + | failing = append(failing, c.Name) | |
| 837 | + | } | |
| 838 | + | } | |
| 839 | + | switch { | |
| 840 | + | case len(failing) > 0: | |
| 841 | + | return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", ")) | |
| 842 | + | case len(pending) > 0: | |
| 843 | + | return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", ")) | |
| 844 | + | } | |
| 845 | + | return nil | |
| 846 | + | } | |
| 847 | + | ||
| 798 | 848 | // checkDeletable refuses to delete the source branch of mr when it is the | |
| 799 | 849 | // default branch or the source or target of another open request. It runs | |
| 800 | 850 | // under the merge lock, which Create also holds while it checks its branches | |
M internal/usecase/pushes.go +14 -2
| @@ -27,7 +27,8 @@ type Pushes struct { | |||
| 27 | 27 | clock ports.Clock | |
| 28 | 28 | maint ports.MaintenanceScheduler | |
| 29 | 29 | auth ports.Revalidator | |
| 30 | - | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 30 | + | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 31 | + | ci ports.WorkflowTrigger // nil: no CI | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | var ( | |
| @@ -49,6 +50,13 @@ func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes { | |||
| 49 | 50 | return u | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 53 | + | // WithWorkflowTrigger makes Record start the workflows that match the push. | |
| 54 | + | // Call it before the first push; it returns u. | |
| 55 | + | func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes { | |
| 56 | + | u.ci = t | |
| 57 | + | return u | |
| 58 | + | } | |
| 59 | + | ||
| 52 | 60 | // Open decides whether the principal may push to the repository and, with | |
| 53 | 61 | // create, creates it when it does not exist yet (private, published as | |
| 54 | 62 | // RepoCreated by Repos.Create). create is for the first request of a push only. | |
| @@ -121,7 +129,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 121 | 129 | ||
| 122 | 130 | // Record is the post-receive step of an accepted push: it sets pushed_at, | |
| 123 | 131 | // repairs HEAD when it points to a missing branch, tells the reader that the | |
| 124 | - | // repository changed, publishes a PushEvent and schedules maintenance. Every | |
| 132 | + | // repository changed, queues the workflows the push triggers, publishes a | |
| 133 | + | // PushEvent and schedules maintenance. Every | |
| 125 | 134 | // step runs even when an earlier one fails; the failures are returned together | |
| 126 | 135 | // and the push itself is not affected. | |
| 127 | 136 | func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| @@ -139,6 +148,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 139 | 148 | if u.fresh != nil { | |
| 140 | 149 | u.fresh.Refresh(ctx, name) | |
| 141 | 150 | } | |
| 151 | + | if u.ci != nil { | |
| 152 | + | u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push | |
| 153 | + | } | |
| 142 | 154 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 143 | 155 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 144 | 156 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
M internal/usecase/repos.go +8 -0
| @@ -21,6 +21,7 @@ type Repos struct { | |||
| 21 | 21 | git ports.GitRepos | |
| 22 | 22 | bus ports.EventBus | |
| 23 | 23 | clock ports.Clock | |
| 24 | + | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -31,6 +32,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, | |||
| 31 | 32 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 35 | + | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | |
| 36 | + | // before the first request; it returns r. | |
| 37 | + | func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | |
| 38 | + | r.ci = c | |
| 39 | + | return r | |
| 40 | + | } | |
| 41 | + | ||
| 34 | 42 | // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the | |
| 35 | 43 | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | |
| 36 | 44 | // also when a concurrent Create wins) and publishes the directory. A failure | |
M internal/usecase/repos_write.go +14 -0
| @@ -191,9 +191,23 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 191 | 191 | case err != nil: | |
| 192 | 192 | return fmt.Errorf("trash repository %q: %w", name, err) | |
| 193 | 193 | } | |
| 194 | + | var runIDs []int64 | |
| 195 | + | if r.ci != nil { | |
| 196 | + | // Listed before the records go: the logs are found by run id. | |
| 197 | + | if ids, err := r.ci.RunIDs(ctx, name); err == nil { | |
| 198 | + | runIDs = ids | |
| 199 | + | } else { | |
| 200 | + | log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep") | |
| 201 | + | } | |
| 202 | + | } | |
| 194 | 203 | if err := r.meta.Delete(ctx, name); err != nil { | |
| 195 | 204 | return fmt.Errorf("delete metadata of %q: %w", name, err) | |
| 196 | 205 | } | |
| 206 | + | if len(runIDs) > 0 { | |
| 207 | + | if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil { | |
| 208 | + | log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will") | |
| 209 | + | } | |
| 210 | + | } | |
| 197 | 211 | log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted") | |
| 198 | 212 | r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt}) | |
| 199 | 213 | return nil | |
A proto/ermite/storage/ci.proto +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of ermite: CI runs, jobs, checks and | |
| 4 | + | // runners (ADR 0025). Logs are not stored here: they are files under | |
| 5 | + | // $ERMITE_DATA_DIR/ci/logs. | |
| 6 | + | ||
| 7 | + | syntax = "proto3"; | |
| 8 | + | ||
| 9 | + | package ermite.storage; | |
| 10 | + | ||
| 11 | + | import "google/protobuf/timestamp.proto"; | |
| 12 | + | ||
| 13 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| 14 | + | ||
| 15 | + | // CIStatus is where a run, a job or a check is in its life. | |
| 16 | + | enum CIStatus { | |
| 17 | + | CI_STATUS_UNSPECIFIED = 0; | |
| 18 | + | CI_STATUS_QUEUED = 1; | |
| 19 | + | CI_STATUS_IN_PROGRESS = 2; | |
| 20 | + | CI_STATUS_COMPLETED = 3; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CIConclusion is how a run, a job, a step or a check ended. | |
| 24 | + | enum CIConclusion { | |
| 25 | + | CI_CONCLUSION_UNSPECIFIED = 0; | |
| 26 | + | CI_CONCLUSION_SUCCESS = 1; | |
| 27 | + | CI_CONCLUSION_FAILURE = 2; | |
| 28 | + | CI_CONCLUSION_CANCELLED = 3; | |
| 29 | + | CI_CONCLUSION_SKIPPED = 4; | |
| 30 | + | CI_CONCLUSION_TIMED_OUT = 5; | |
| 31 | + | CI_CONCLUSION_NEUTRAL = 6; | |
| 32 | + | CI_CONCLUSION_ACTION_REQUIRED = 7; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // CIStepState is where a step is in its life. | |
| 36 | + | enum CIStepState { | |
| 37 | + | CI_STEP_STATE_UNSPECIFIED = 0; | |
| 38 | + | CI_STEP_STATE_PENDING = 1; | |
| 39 | + | CI_STEP_STATE_IN_PROGRESS = 2; | |
| 40 | + | CI_STEP_STATE_COMPLETED = 3; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // CIJobPhase is where a job is for the server. | |
| 44 | + | enum CIJobPhase { | |
| 45 | + | CI_JOB_PHASE_UNSPECIFIED = 0; | |
| 46 | + | // Waits for the jobs it needs, or for a free max-parallel slot. | |
| 47 | + | CI_JOB_PHASE_WAITING = 1; | |
| 48 | + | // Planned and waiting for a runner. | |
| 49 | + | CI_JOB_PHASE_READY = 2; | |
| 50 | + | // Given to a runner that has not acknowledged it yet. | |
| 51 | + | CI_JOB_PHASE_ASSIGNED = 3; | |
| 52 | + | // Acknowledged by its runner. | |
| 53 | + | CI_JOB_PHASE_RUNNING = 4; | |
| 54 | + | // Over. | |
| 55 | + | CI_JOB_PHASE_ENDED = 5; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // CIIsolation is how a runner isolates jobs. | |
| 59 | + | enum CIIsolation { | |
| 60 | + | CI_ISOLATION_UNSPECIFIED = 0; | |
| 61 | + | CI_ISOLATION_VM = 1; | |
| 62 | + | CI_ISOLATION_CONTAINER_SYSBOX = 2; | |
| 63 | + | CI_ISOLATION_CONTAINER_PRIVILEGED = 3; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // CICancelReason says why a job is asked to stop. | |
| 67 | + | enum CICancelReason { | |
| 68 | + | CI_CANCEL_REASON_UNSPECIFIED = 0; | |
| 69 | + | CI_CANCEL_REASON_USER = 1; | |
| 70 | + | CI_CANCEL_REASON_FAIL_FAST = 2; | |
| 71 | + | CI_CANCEL_REASON_TIMEOUT = 3; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // CIRun is stored under "run/<repo>/<id:020d>". | |
| 75 | + | message CIRun { | |
| 76 | + | // 18 and 19 were the workflow file and the github context, which moved to | |
| 77 | + | // CIRunSource. | |
| 78 | + | reserved 18, 19; | |
| 79 | + | string repo = 1; | |
| 80 | + | int64 id = 2; | |
| 81 | + | int64 number = 3; | |
| 82 | + | int32 attempt = 4; | |
| 83 | + | string workflow_path = 5; | |
| 84 | + | string workflow_name = 6; | |
| 85 | + | string title = 7; | |
| 86 | + | string event = 8; | |
| 87 | + | string ref = 9; | |
| 88 | + | string sha = 10; | |
| 89 | + | string actor = 11; | |
| 90 | + | CIStatus status = 12; | |
| 91 | + | CIConclusion conclusion = 13; | |
| 92 | + | string error = 14; | |
| 93 | + | google.protobuf.Timestamp created_at = 15; | |
| 94 | + | google.protobuf.Timestamp started_at = 16; | |
| 95 | + | google.protobuf.Timestamp completed_at = 17; | |
| 96 | + | bool cancel_requested = 20; | |
| 97 | + | google.protobuf.Timestamp cancel_requested_at = 21; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned | |
| 101 | + | // from, kept apart so that listing runs does not decode it. | |
| 102 | + | message CIRunSource { | |
| 103 | + | int64 run_id = 4; | |
| 104 | + | // The workflow file at sha, as is. | |
| 105 | + | string workflow_yaml = 1; | |
| 106 | + | // The "github" context of the run as JSON, without "job" and "run_attempt". | |
| 107 | + | string github_json = 2; | |
| 108 | + | // Repository of the run: the key of the run record is run/<repo>/<id>, and | |
| 109 | + | // the runner side knows a run by its id only. | |
| 110 | + | string repo = 3; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // CIStep is a step of a job. | |
| 114 | + | message CIStep { | |
| 115 | + | // 8 and 10 were the end of the step, which is derived from the next step's | |
| 116 | + | // start and the line count. | |
| 117 | + | reserved 8, 10; | |
| 118 | + | int32 number = 1; | |
| 119 | + | string name = 2; | |
| 120 | + | CIStepState state = 3; | |
| 121 | + | CIConclusion conclusion = 4; | |
| 122 | + | google.protobuf.Timestamp started_at = 5; | |
| 123 | + | google.protobuf.Timestamp completed_at = 6; | |
| 124 | + | // Number of the first log line of the step; meaningful with has_lines. The | |
| 125 | + | // end of a step is the start of the next step that has lines, or the line | |
| 126 | + | // count of the job: it is never stored. | |
| 127 | + | int64 log_start = 7; | |
| 128 | + | bool has_lines = 9; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | |
| 132 | + | message CIJob { | |
| 133 | + | // 36 was the line count of the log, which the log file holds. | |
| 134 | + | reserved 36; | |
| 135 | + | string id = 1; | |
| 136 | + | int64 run_id = 2; | |
| 137 | + | // Attempt in which the job executed (the one of its log). | |
| 138 | + | int32 attempt = 3; | |
| 139 | + | string repo = 4; | |
| 140 | + | string key = 5; | |
| 141 | + | string name = 6; | |
| 142 | + | CIJobPhase phase = 7; | |
| 143 | + | CIConclusion conclusion = 8; | |
| 144 | + | string error = 9; | |
| 145 | + | repeated string labels = 10; | |
| 146 | + | repeated string needs = 11; | |
| 147 | + | string runner_id = 12; | |
| 148 | + | string runner_name = 13; | |
| 149 | + | CIIsolation isolation = 14; | |
| 150 | + | repeated CIStep steps = 15; | |
| 151 | + | google.protobuf.Timestamp created_at = 16; | |
| 152 | + | google.protobuf.Timestamp started_at = 17; | |
| 153 | + | google.protobuf.Timestamp completed_at = 18; | |
| 154 | + | // Position of the job in the workflow file, and of the instance in its | |
| 155 | + | // matrix. A combo of -1 is a job whose matrix is not expanded yet. | |
| 156 | + | int32 index = 19; | |
| 157 | + | int32 combo = 20; | |
| 158 | + | string matrix_json = 21; | |
| 159 | + | // Number of instances of the job's matrix, for the strategy context. | |
| 160 | + | int32 combo_total = 22; | |
| 161 | + | int32 timeout_minutes = 23; | |
| 162 | + | bool fail_fast = 24; | |
| 163 | + | int32 max_parallel = 25; | |
| 164 | + | map<string, string> outputs = 26; | |
| 165 | + | bytes token_hash = 27; | |
| 166 | + | google.protobuf.Timestamp ready_at = 28; | |
| 167 | + | google.protobuf.Timestamp assigned_at = 29; | |
| 168 | + | google.protobuf.Timestamp acked_at = 30; | |
| 169 | + | int32 assign_cancels = 31; | |
| 170 | + | google.protobuf.Timestamp token_valid_until = 32; | |
| 171 | + | CICancelReason cancel_reason = 33; | |
| 172 | + | string cancel_message = 34; | |
| 173 | + | google.protobuf.Timestamp cancel_requested_at = 35; | |
| 174 | + | // Set when the job is planned: runs-on, name and contexts are evaluated. | |
| 175 | + | bool planned = 37; | |
| 176 | + | // Attempt of the run this record belongs to; it is the key. A job kept by a | |
| 177 | + | // re-run of the failed jobs is copied with the run attempt of the re-run and | |
| 178 | + | // keeps its own attempt. | |
| 179 | + | int32 run_attempt = 38; | |
| 180 | + | // The contexts JSON given to the runner, computed when the job is planned | |
| 181 | + | // (needs outputs are final then). | |
| 182 | + | string contexts_json = 39; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | |
| 186 | + | // "run-<run id>" for a run that failed without a job (invalid workflow file). | |
| 187 | + | message CICheck { | |
| 188 | + | string id = 1; | |
| 189 | + | string repo = 2; | |
| 190 | + | string sha = 3; | |
| 191 | + | string name = 4; | |
| 192 | + | CIStatus status = 5; | |
| 193 | + | CIConclusion conclusion = 6; | |
| 194 | + | int64 run_id = 7; | |
| 195 | + | string job_id = 8; | |
| 196 | + | int32 attempt = 9; | |
| 197 | + | string workflow_name = 10; | |
| 198 | + | google.protobuf.Timestamp created_at = 11; | |
| 199 | + | google.protobuf.Timestamp started_at = 12; | |
| 200 | + | google.protobuf.Timestamp completed_at = 13; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // CIRunner is stored under "runner/<id>". | |
| 204 | + | message CIRunner { | |
| 205 | + | string id = 1; | |
| 206 | + | string name = 2; | |
| 207 | + | repeated string labels = 3; | |
| 208 | + | CIIsolation isolation = 4; | |
| 209 | + | string version = 5; | |
| 210 | + | string arch = 6; | |
| 211 | + | string os = 7; | |
| 212 | + | google.protobuf.Timestamp registered_at = 8; | |
| 213 | + | bytes token_hash = 9; | |
| 214 | + | } | |