Merge origin/main into feat/passkeys
175 files · +23618 -641
M README.md +53 -3
| @@ -27,32 +27,63 @@ 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 | - | `ermite version` prints the version (and the server's when `ERMITE_URL` is set). | |
| 33 | + | `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI). | |
| 32 | 34 | ||
| 33 | 35 | ## Remote CLI | |
| 34 | 36 | ||
| 35 | 37 | The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | |
| 36 | 38 | ||
| 39 | + | Each setting comes from the environment or from a config file, the environment first: | |
| 40 | + | ||
| 41 | + | | Setting | Environment | Config file key | | |
| 42 | + | |---|---|---| | |
| 43 | + | | server address | `ERMITE_URL` | `url` | | |
| 44 | + | | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it | | |
| 45 | + | ||
| 46 | + | The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …). | |
| 47 | + | ||
| 48 | + | ```ini | |
| 49 | + | # ~/.config/ermite/config | |
| 50 | + | url = https://git.example.com | |
| 51 | + | token.cmd = pass show ermite/token | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | ```sh | |
| 55 | + | ermite config set url https://git.example.com | |
| 56 | + | ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite | |
| 57 | + | ermite config show [--json] # path and stored values; unset removes a key | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set. | |
| 61 | + | ||
| 37 | 62 | ```sh | |
| 38 | 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | |
| 39 | 64 | 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 | |
| 65 | + | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once | |
| 41 | 66 | ermite token list | revoke <id> | |
| 67 | + | # a token for an AI agent that cannot touch main: | |
| 68 | + | ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/ | |
| 42 | 69 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | |
| 43 | 70 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | |
| 44 | 71 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | |
| 72 | + | ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]] | |
| 73 | + | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | |
| 74 | + | ermite runner list | remove <id> | |
| 45 | 75 | ermite web login # prints a one-time sign-in link (60 s) | |
| 46 | 76 | ermite web logout --all # signs every browser out | |
| 47 | 77 | ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out | |
| 48 | 78 | ermite signing-key show # public key and allowed_signers line of squash commits | |
| 79 | + | ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file | |
| 49 | 80 | ermite version # client and server, warns about missing server features | |
| 50 | 81 | ermite watch [repo] [--json] # follows events, reconnects and resumes | |
| 51 | 82 | ``` | |
| 52 | 83 | ||
| 53 | 84 | 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. | |
| 54 | 85 | ||
| 55 | - | `--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. | |
| 86 | + | `--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. | |
| 56 | 87 | ||
| 57 | 88 | ## Git over HTTP | |
| 58 | 89 | ||
| @@ -63,10 +94,29 @@ git push http://u:<token>@git.example.com/<repo>.git main | |||
| 63 | 94 | ||
| 64 | 95 | Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`). | |
| 65 | 96 | ||
| 97 | + | ## Branch-limited tokens | |
| 98 | + | ||
| 99 | + | For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes. | |
| 100 | + | ||
| 101 | + | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | |
| 102 | + | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | |
| 103 | + | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | |
| 104 | + | - CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository. | |
| 105 | + | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | |
| 106 | + | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | |
| 107 | + | ||
| 66 | 108 | ## Merge requests | |
| 67 | 109 | ||
| 68 | 110 | 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). | |
| 69 | 111 | ||
| 112 | + | ## CI | |
| 113 | + | ||
| 114 | + | 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. | |
| 115 | + | ||
| 116 | + | 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. | |
| 117 | + | ||
| 118 | + | `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`. | |
| 119 | + | ||
| 70 | 120 | ## Web sign-in | |
| 71 | 121 | ||
| 72 | 122 | Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`. | |
A gen/ermite/storage/ci.pb.go +1603 -0 generated file: collapsed
M gen/ermite/storage/repo.pb.go +16 -5 generated file: collapsed
M go.mod +4 -2
| @@ -12,12 +12,14 @@ tool ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | - | bnema.dev/ermite-api v0.0.0-20261012153643-fad97fd3242a | |
| 15 | + | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 16 | + | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |
| 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 | |
| 19 | 20 | github.com/a-h/templ v0.3.1020 | |
| 20 | 21 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 22 | + | github.com/bnema/kvconf v0.1.2 | |
| 21 | 23 | github.com/bnema/zerowrap v1.4.1 | |
| 22 | 24 | github.com/descope/virtualwebauthn v1.0.5 | |
| 23 | 25 | github.com/go-webauthn/webauthn v0.18.2 | |
| @@ -27,6 +29,7 @@ require ( | |||
| 27 | 29 | github.com/yuin/goldmark v1.8.6 | |
| 28 | 30 | golang.org/x/crypto v0.57.0 | |
| 29 | 31 | golang.org/x/sys v0.49.0 | |
| 32 | + | golang.org/x/term v0.46.0 | |
| 30 | 33 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 | |
| 31 | 34 | google.golang.org/protobuf v1.36.12 | |
| 32 | 35 | ) | |
| @@ -159,7 +162,6 @@ require ( | |||
| 159 | 162 | golang.org/x/mod v0.41.0 // indirect | |
| 160 | 163 | golang.org/x/net v0.59.0 // indirect | |
| 161 | 164 | golang.org/x/sync v0.23.0 // indirect | |
| 162 | - | golang.org/x/term v0.46.0 // indirect | |
| 163 | 165 | golang.org/x/text v0.42.0 // indirect | |
| 164 | 166 | golang.org/x/tools v0.51.0 // indirect | |
| 165 | 167 | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |
M go.sum +6 -2 generated file: collapsed
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 +5 -2
| @@ -139,13 +139,16 @@ 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.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys, | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, | |
| 147 | + | client.FeaturePasskeys, | |
| 146 | 148 | })) | |
| 147 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 148 | 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, | |
| 149 | 152 | })) | |
| 150 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 151 | 154 | } | |
M internal/adapters/client/config.go +8 -7
| @@ -48,18 +48,19 @@ type Config struct { | |||
| 48 | 48 | UserAgent string | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | - | // ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates | |
| 52 | - | // them. Timeout and UserAgent are left for the caller to set. | |
| 53 | - | func ConfigFromEnv(getenv func(string) string) (Config, error) { | |
| 54 | - | raw := strings.TrimSpace(getenv(EnvURL)) | |
| 51 | + | // NewConfig validates the server address and the token, however they were | |
| 52 | + | // found (environment, config file or token command). The token is trimmed and | |
| 53 | + | // may be empty (anonymous). Timeout and UserAgent are left for the caller. | |
| 54 | + | func NewConfig(rawURL, token string) (Config, error) { | |
| 55 | + | raw := strings.TrimSpace(rawURL) | |
| 55 | 56 | if raw == "" { | |
| 56 | - | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL) | |
| 57 | + | return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL) | |
| 57 | 58 | } | |
| 58 | 59 | base, err := ParseURL(raw) | |
| 59 | 60 | if err != nil { | |
| 60 | - | return Config{}, fmt.Errorf("%s: %w", EnvURL, err) | |
| 61 | + | return Config{}, fmt.Errorf("invalid server URL: %w", err) | |
| 61 | 62 | } | |
| 62 | - | return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil | |
| 63 | + | return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil | |
| 63 | 64 | } | |
| 64 | 65 | ||
| 65 | 66 | // ParseURL validates a server address: an absolute http(s) URL with a host, | |
M internal/adapters/client/config_test.go +11 -10
| @@ -11,20 +11,21 @@ import ( | |||
| 11 | 11 | "bnema.dev/ermite/internal/adapters/client" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | func env(kv map[string]string) func(string) string { | |
| 15 | - | return func(k string) string { return kv[k] } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func TestConfigFromEnv(t *testing.T) { | |
| 19 | - | cfg, err := client.ConfigFromEnv(env(map[string]string{ | |
| 20 | - | client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n", | |
| 21 | - | })) | |
| 14 | + | func TestNewConfig(t *testing.T) { | |
| 15 | + | cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n") | |
| 22 | 16 | require.NoError(t, err) | |
| 23 | 17 | assert.Equal(t, "https://Git.Example.com/base", cfg.URL) | |
| 24 | 18 | assert.Equal(t, "ermite_abc", cfg.Token) | |
| 25 | 19 | } | |
| 26 | 20 | ||
| 27 | - | func TestConfigFromEnvRejects(t *testing.T) { | |
| 21 | + | func TestNewConfigUnsetTellsHowToFixIt(t *testing.T) { | |
| 22 | + | _, err := client.NewConfig(" ", "") | |
| 23 | + | require.Error(t, err) | |
| 24 | + | assert.Contains(t, err.Error(), "ERMITE_URL") | |
| 25 | + | assert.Contains(t, err.Error(), "ermite config set url") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestNewConfigRejects(t *testing.T) { | |
| 28 | 29 | tests := []struct { | |
| 29 | 30 | name, url, want string | |
| 30 | 31 | }{ | |
| @@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) { | |||
| 39 | 40 | } | |
| 40 | 41 | for _, tt := range tests { | |
| 41 | 42 | t.Run(tt.name, func(t *testing.T) { | |
| 42 | - | _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url})) | |
| 43 | + | _, err := client.NewConfig(tt.url, "") | |
| 43 | 44 | require.Error(t, err) | |
| 44 | 45 | assert.Contains(t, err.Error(), tt.want) | |
| 45 | 46 | assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed") | |
M internal/adapters/client/features.go +12 -1
| @@ -26,11 +26,22 @@ const ( | |||
| 26 | 26 | // (`ermite web passkey`). A server lists it only when passkeys are | |
| 27 | 27 | // available (https public URL with a DNS host name). | |
| 28 | 28 | FeaturePasskeys = "passkeys" | |
| 29 | + | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |
| 30 | + | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |
| 31 | + | // A server without it ignores the field and would create an unlimited | |
| 32 | + | // token: `token create --branch-prefix` reads the features first and | |
| 33 | + | // refuses when it is missing (the main guard), and also checks the token it | |
| 34 | + | // gets back (a backstop). | |
| 35 | + | FeatureTokenBranchPrefixes = "token.branch_prefixes" | |
| 36 | + | // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner | |
| 37 | + | // calls (`ermite runner`). | |
| 38 | + | FeatureCIRuns = "ci.runs" | |
| 39 | + | FeatureCIRunners = "ci.runners" | |
| 29 | 40 | ) | |
| 30 | 41 | ||
| 31 | 42 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 32 | 43 | func UsedFeatures() []string { | |
| 33 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys} | |
| 44 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeaturePasskeys} | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 36 | 47 | // 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/mergerequests_test.go +3 -2
| @@ -358,7 +358,8 @@ func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain | |||
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | 360 | // TestDeleteRacingWithWrites: writes racing with Delete either land before it | |
| 361 | - | // and are deleted, or are refused after it; nothing is left for a repository | |
| 361 | + | // and are deleted, or are refused (not found after it, or a conflict when the | |
| 362 | + | // retries run out under this contention); nothing is left for a repository | |
| 362 | 363 | // created again under the name. | |
| 363 | 364 | func TestDeleteRacingWithWrites(t *testing.T) { | |
| 364 | 365 | ctx := context.Background() | |
| @@ -376,7 +377,7 @@ func TestDeleteRacingWithWrites(t *testing.T) { | |||
| 376 | 377 | if w%2 == 0 { | |
| 377 | 378 | err := mrs.SetRead(ctx, n, mr.Number, oid('a')) | |
| 378 | 379 | if err != nil { | |
| 379 | - | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 380 | + | assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err) | |
| 380 | 381 | return | |
| 381 | 382 | } | |
| 382 | 383 | continue | |
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/kvstore/store.go +23 -4
| @@ -17,8 +17,11 @@ import ( | |||
| 17 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | - | // SchemaVersion is the storage schema this build reads and writes. | |
| 21 | - | const SchemaVersion = 1 | |
| 20 | + | // SchemaVersion is the storage schema this build reads and writes. Version 2 | |
| 21 | + | // adds branch prefixes to tokens: a build that does not know them would treat | |
| 22 | + | // a branch-limited token as unrestricted, so it must refuse the data | |
| 23 | + | // (ErrSchemaTooNew) instead. | |
| 24 | + | const SchemaVersion = 2 | |
| 22 | 25 | ||
| 23 | 26 | const schemaKey = "meta/schema" | |
| 24 | 27 | ||
| @@ -33,8 +36,8 @@ type Store struct { | |||
| 33 | 36 | var _ ports.RepoMetaStore = (*Store)(nil) | |
| 34 | 37 | ||
| 35 | 38 | // Open opens (or creates) the database in dir and checks the storage schema: | |
| 36 | - | // a missing version is written as SchemaVersion, a higher one stops with | |
| 37 | - | // ErrSchemaTooNew. dir must exist. | |
| 39 | + | // a missing version is written as SchemaVersion, a lower one is upgraded to it, | |
| 40 | + | // a higher one stops with ErrSchemaTooNew. dir must exist. | |
| 38 | 41 | func Open(ctx context.Context, dir string) (*Store, error) { | |
| 39 | 42 | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 40 | 43 | if err != nil { | |
| @@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 107 | 110 | case sv.GetVersion() == 0: | |
| 108 | 111 | return fmt.Errorf("decode storage schema: invalid version 0") | |
| 109 | 112 | } | |
| 113 | + | if sv.GetVersion() < SchemaVersion { | |
| 114 | + | // Older data stays readable as it is (the changes are additive); | |
| 115 | + | // the new version is recorded so that older builds refuse it. | |
| 116 | + | var b kv.Batch | |
| 117 | + | b.RequireVersion(key, item.Version) | |
| 118 | + | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion})) | |
| 119 | + | _, err := s.db.Apply(ctx, &b) | |
| 120 | + | if errors.Is(err, kv.ErrConflict) { | |
| 121 | + | continue // changed meanwhile: read it again | |
| 122 | + | } | |
| 123 | + | if err != nil { | |
| 124 | + | return fmt.Errorf("upgrade storage schema: %w", err) | |
| 125 | + | } | |
| 126 | + | log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded") | |
| 127 | + | return nil | |
| 128 | + | } | |
| 110 | 129 | log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked") | |
| 111 | 130 | return nil | |
| 112 | 131 | } | |
M internal/adapters/kvstore/store_test.go +18 -5
| @@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 { | |||
| 191 | 191 | func TestSchema(t *testing.T) { | |
| 192 | 192 | ctx := context.Background() | |
| 193 | 193 | ||
| 194 | - | t.Run("missing is written as 1", func(t *testing.T) { | |
| 194 | + | t.Run("missing is written as the current version", func(t *testing.T) { | |
| 195 | 195 | dir := t.TempDir() | |
| 196 | 196 | s, err := kvstore.Open(ctx, dir) | |
| 197 | 197 | require.NoError(t, err) | |
| 198 | 198 | require.NoError(t, s.Close(ctx)) | |
| 199 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | |
| 199 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 200 | 200 | }) | |
| 201 | 201 | ||
| 202 | 202 | t.Run("current is accepted and kept", func(t *testing.T) { | |
| @@ -207,15 +207,28 @@ func TestSchema(t *testing.T) { | |||
| 207 | 207 | s, err = kvstore.Open(ctx, dir) | |
| 208 | 208 | require.NoError(t, err) | |
| 209 | 209 | require.NoError(t, s.Close(ctx)) | |
| 210 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | |
| 210 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) { | |
| 214 | + | dir := t.TempDir() | |
| 215 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | |
| 216 | + | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |
| 217 | + | s, err := kvstore.Open(ctx, dir) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | got, err := s.Get(ctx, name(t, "old")) | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | assert.Equal(t, "old", got.Name.String()) | |
| 222 | + | require.NoError(t, s.Close(ctx)) | |
| 223 | + | assert.Equal(t, uint32(2), readSchema(t, dir)) | |
| 211 | 224 | }) | |
| 212 | 225 | ||
| 213 | 226 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | |
| 214 | 227 | dir := t.TempDir() | |
| 215 | - | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 228 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) | |
| 216 | 229 | _, err := kvstore.Open(ctx, dir) | |
| 217 | 230 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 218 | - | assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept") | |
| 231 | + | assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept") | |
| 219 | 232 | }) | |
| 220 | 233 | ||
| 221 | 234 | t.Run("garbage and zero are errors", func(t *testing.T) { | |
M internal/adapters/kvstore/tokens.go +13 -0
| @@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |||
| 191 | 191 | return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict) | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | // encodeToken refuses what the use case already refused (scopes, branch | |
| 195 | + | // prefixes): a malformed prefix must never reach the store, where it would | |
| 196 | + | // limit a token wrongly or not at all. | |
| 197 | + | // | |
| 198 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy | |
| 194 | 199 | func encodeToken(t domain.Token) ([]byte, error) { | |
| 195 | 200 | if !validTokenID(t.ID) { | |
| 196 | 201 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | |
| @@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||
| 221 | 226 | } | |
| 222 | 227 | pb.Repos = append(pb.Repos, r.String()) | |
| 223 | 228 | } | |
| 229 | + | for _, bp := range t.BranchPrefixes { | |
| 230 | + | if _, err := domain.ParseBranchPrefix(bp); err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | pb.BranchPrefixes = append(pb.BranchPrefixes, bp) | |
| 234 | + | } | |
| 224 | 235 | return mustMarshal(pb), nil | |
| 225 | 236 | } | |
| 226 | 237 | ||
| @@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) { | |||
| 256 | 267 | } | |
| 257 | 268 | t.Repos = append(t.Repos, name) | |
| 258 | 269 | } | |
| 270 | + | // Kept as stored: a token with prefixes stays limited, whatever they say. | |
| 271 | + | t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes()) | |
| 259 | 272 | return t, nil | |
| 260 | 273 | } | |
M internal/adapters/kvstore/tokens_test.go +53 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 15 | 16 | "bnema.dev/ermite/internal/domain" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) { | |||
| 192 | 193 | assert.Len(t, list, 1) | |
| 193 | 194 | } | |
| 194 | 195 | } | |
| 196 | + | ||
| 197 | + | func TestTokensBranchPrefixes(t *testing.T) { | |
| 198 | + | ctx := context.Background() | |
| 199 | + | ||
| 200 | + | t.Run("round trip, order kept", func(t *testing.T) { | |
| 201 | + | s := openStore(t, t.TempDir()) | |
| 202 | + | tok := newToken(t, "t1", "s1") | |
| 203 | + | tok.BranchPrefixes = []string{"b/", "agent/claude/"} | |
| 204 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |
| 205 | + | got, err := s.Tokens().Get(ctx, "t1") | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | assert.Equal(t, tok, got) | |
| 208 | + | byHash, err := s.Tokens().FindByHash(ctx, tok.Hash) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes) | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) { | |
| 214 | + | dir := t.TempDir() | |
| 215 | + | old := newToken(t, "old", "s-old") | |
| 216 | + | putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{ | |
| 217 | + | Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"}, | |
| 218 | + | })) | |
| 219 | + | s := openStore(t, dir) | |
| 220 | + | got, err := s.Tokens().Get(ctx, "old") | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | assert.Empty(t, got.BranchPrefixes) | |
| 223 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes) | |
| 224 | + | }) | |
| 225 | + | ||
| 226 | + | t.Run("an invalid prefix is not stored", func(t *testing.T) { | |
| 227 | + | s := openStore(t, t.TempDir()) | |
| 228 | + | for _, bad := range []string{"", "agent", "/", "a b/"} { | |
| 229 | + | tok := newToken(t, "t1", "s1") | |
| 230 | + | tok.BranchPrefixes = []string{bad} | |
| 231 | + | assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad) | |
| 232 | + | } | |
| 233 | + | _, err := s.Tokens().Get(ctx, "t1") | |
| 234 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 235 | + | }) | |
| 236 | + | ||
| 237 | + | t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) { | |
| 238 | + | s := openStore(t, t.TempDir()) | |
| 239 | + | tok := newToken(t, "t1", "s1") | |
| 240 | + | tok.BranchPrefixes = []string{"agent/"} | |
| 241 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |
| 242 | + | require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour))) | |
| 243 | + | got, err := s.Tokens().Get(ctx, "t1") | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | assert.Equal(t, []string{"agent/"}, got.BranchPrefixes) | |
| 246 | + | }) | |
| 247 | + | } | |
M internal/adapters/rpc/api.go +12 -1
| @@ -28,7 +28,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 28 | 28 | // Features are the optional capabilities of the contract that this server | |
| 29 | 29 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 30 | 30 | // when Config.Passkeys is set. | |
| 31 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 31 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 32 | 32 | ||
| 33 | 33 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | 34 | const FeaturePasskeys = "passkeys" | |
| @@ -51,6 +51,11 @@ type Config struct { | |||
| 51 | 51 | // budget of failures. Leave it unset rather than assigning a typed nil | |
| 52 | 52 | // pointer (a nil *T in the interface is not nil, and would be called). | |
| 53 | 53 | Limiter ports.FailureLimiter | |
| 54 | + | // Runner serves RunnerService and Runs serves RunService; without them the | |
| 55 | + | // CI services are not served. Leave them unset rather than assigning a | |
| 56 | + | // typed nil pointer. | |
| 57 | + | Runner ports.RunnerService | |
| 58 | + | Runs ports.RunService | |
| 54 | 59 | // Sessions are the owner's web sessions; nil serves no SessionService. | |
| 55 | 60 | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | |
| 56 | 61 | // the interface is not nil, and the service would be served and called). | |
| @@ -94,6 +99,12 @@ func New(cfg Config) *API { | |||
| 94 | 99 | if cfg.Sessions != nil { | |
| 95 | 100 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 96 | 101 | } | |
| 102 | + | if cfg.Runner != nil { | |
| 103 | + | mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...)) | |
| 104 | + | } | |
| 105 | + | if cfg.Runs != nil { | |
| 106 | + | mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...)) | |
| 107 | + | } | |
| 97 | 108 | return &API{handler: mux} | |
| 98 | 109 | } | |
| 99 | 110 | ||
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 +8 -0
| @@ -5,6 +5,7 @@ package rpc | |||
| 5 | 5 | import ( | |
| 6 | 6 | "encoding/base64" | |
| 7 | 7 | "fmt" | |
| 8 | + | "slices" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "google.golang.org/protobuf/types/known/timestamppb" | |
| @@ -156,6 +157,7 @@ func tokenToPB(t domain.Token) *pb.Token { | |||
| 156 | 157 | for _, r := range t.Repos { | |
| 157 | 158 | out.Repos = append(out.Repos, r.String()) | |
| 158 | 159 | } | |
| 160 | + | out.BranchPrefixes = slices.Clone(t.BranchPrefixes) | |
| 159 | 161 | return out | |
| 160 | 162 | } | |
| 161 | 163 | ||
| @@ -392,6 +394,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 392 | 394 | case domain.CommentAdded: | |
| 393 | 395 | out.Time = timestamp(ev.At) | |
| 394 | 396 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 397 | + | case domain.RunUpdated: | |
| 398 | + | out.Time = timestamp(ev.At) | |
| 399 | + | out.Kind = runEventToPB(ev) | |
| 400 | + | case domain.JobUpdated: | |
| 401 | + | out.Time = timestamp(ev.At) | |
| 402 | + | out.Kind = jobEventToPB(ev) | |
| 395 | 403 | default: | |
| 396 | 404 | return nil | |
| 397 | 405 | } | |
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/rpc/token.go +1 -0
| @@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb. | |||
| 34 | 34 | } | |
| 35 | 35 | params.Repos = append(params.Repos, name) | |
| 36 | 36 | } | |
| 37 | + | params.BranchPrefixes = req.Msg.GetBranchPrefixes() | |
| 37 | 38 | created, err := s.tokens.Create(ctx, caller(ctx), params) | |
| 38 | 39 | if err != nil { | |
| 39 | 40 | return nil, connectError(ctx, err) | |
M internal/adapters/rpc/token_test.go +31 -0
| @@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||
| 64 | 64 | require.NoError(t, err) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | func TestTokenServiceBranchPrefixes(t *testing.T) { | |
| 68 | + | t.Parallel() | |
| 69 | + | tokens := mocks.NewTokenService(t) | |
| 70 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |
| 71 | + | return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes) | |
| 72 | + | })).Return(ports.CreatedToken{ | |
| 73 | + | Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}}, | |
| 74 | + | Secret: "s", | |
| 75 | + | }, nil).Once() | |
| 76 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 77 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 78 | + | })) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes()) | |
| 81 | + | ||
| 82 | + | // A malformed prefix is the use case's invalid_token: invalid_argument. | |
| 83 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once() | |
| 84 | + | _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 85 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"}, | |
| 86 | + | })) | |
| 87 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 88 | + | ||
| 89 | + | // Listing returns them too; a token without any has none. | |
| 90 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, ""). | |
| 91 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once() | |
| 92 | + | list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes()) | |
| 95 | + | assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes()) | |
| 96 | + | } | |
| 97 | + | ||
| 67 | 98 | func TestTokenServiceRevokeToken(t *testing.T) { | |
| 68 | 99 | t.Parallel() | |
| 69 | 100 | tests := []struct { | |
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/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | | | |
| 14 | 15 | | `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 | | | |
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
| @@ -0,0 +1,2022 @@ | |||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | // templ: version: v0.3.1020 | |
| 4 | + | package web | |
| 5 | + | ||
| 6 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |
| 7 | + | ||
| 8 | + | import "github.com/a-h/templ" | |
| 9 | + | import templruntime "github.com/a-h/templ/runtime" | |
| 10 | + | ||
| 11 | + | // The CI pages: the runs of a repository, a run with its jobs, a job with its | |
| 12 | + | // steps and its log (actions.go). A state is always a word as well as a | |
| 13 | + | // symbol and a colour. | |
| 14 | + | ||
| 15 | + | // CheckIcon is the symbol of a state: a mark in the colour of its kind, with | |
| 16 | + | // the state as its text for a screen reader. | |
| 17 | + | func CheckIcon(kind, state string) templ.Component { | |
| 18 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 19 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 20 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 21 | + | return templ_7745c5c3_CtxErr | |
| 22 | + | } | |
| 23 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 24 | + | if !templ_7745c5c3_IsBuffer { | |
| 25 | + | defer func() { | |
| 26 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 27 | + | if templ_7745c5c3_Err == nil { | |
| 28 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 29 | + | } | |
| 30 | + | }() | |
| 31 | + | } | |
| 32 | + | ctx = templ.InitializeContext(ctx) | |
| 33 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |
| 34 | + | if templ_7745c5c3_Var1 == nil { | |
| 35 | + | templ_7745c5c3_Var1 = templ.NopComponent | |
| 36 | + | } | |
| 37 | + | ctx = templ.ClearChildren(ctx) | |
| 38 | + | var templ_7745c5c3_Var2 = []any{"ck", "ck-" + kind} | |
| 39 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) | |
| 40 | + | if templ_7745c5c3_Err != nil { | |
| 41 | + | return templ_7745c5c3_Err | |
| 42 | + | } | |
| 43 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span class=\"") | |
| 44 | + | if templ_7745c5c3_Err != nil { | |
| 45 | + | return templ_7745c5c3_Err | |
| 46 | + | } | |
| 47 | + | var templ_7745c5c3_Var3 string | |
| 48 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) | |
| 49 | + | if templ_7745c5c3_Err != nil { | |
| 50 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0} | |
| 51 | + | } | |
| 52 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) | |
| 53 | + | if templ_7745c5c3_Err != nil { | |
| 54 | + | return templ_7745c5c3_Err | |
| 55 | + | } | |
| 56 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" role=\"img\" aria-label=\"") | |
| 57 | + | if templ_7745c5c3_Err != nil { | |
| 58 | + | return templ_7745c5c3_Err | |
| 59 | + | } | |
| 60 | + | var templ_7745c5c3_Var4 string | |
| 61 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(state) | |
| 62 | + | if templ_7745c5c3_Err != nil { | |
| 63 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 10, Col: 65} | |
| 64 | + | } | |
| 65 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) | |
| 66 | + | if templ_7745c5c3_Err != nil { | |
| 67 | + | return templ_7745c5c3_Err | |
| 68 | + | } | |
| 69 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"") | |
| 70 | + | if templ_7745c5c3_Err != nil { | |
| 71 | + | return templ_7745c5c3_Err | |
| 72 | + | } | |
| 73 | + | var templ_7745c5c3_Var5 string | |
| 74 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(state) | |
| 75 | + | if templ_7745c5c3_Err != nil { | |
| 76 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 10, Col: 81} | |
| 77 | + | } | |
| 78 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) | |
| 79 | + | if templ_7745c5c3_Err != nil { | |
| 80 | + | return templ_7745c5c3_Err | |
| 81 | + | } | |
| 82 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></span>") | |
| 83 | + | if templ_7745c5c3_Err != nil { | |
| 84 | + | return templ_7745c5c3_Err | |
| 85 | + | } | |
| 86 | + | return nil | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func Actions(l Layout, p ActionsPage) templ.Component { | |
| 91 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 92 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 93 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 94 | + | return templ_7745c5c3_CtxErr | |
| 95 | + | } | |
| 96 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 97 | + | if !templ_7745c5c3_IsBuffer { | |
| 98 | + | defer func() { | |
| 99 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 100 | + | if templ_7745c5c3_Err == nil { | |
| 101 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 102 | + | } | |
| 103 | + | }() | |
| 104 | + | } | |
| 105 | + | ctx = templ.InitializeContext(ctx) | |
| 106 | + | templ_7745c5c3_Var6 := templ.GetChildren(ctx) | |
| 107 | + | if templ_7745c5c3_Var6 == nil { | |
| 108 | + | templ_7745c5c3_Var6 = templ.NopComponent | |
| 109 | + | } | |
| 110 | + | ctx = templ.ClearChildren(ctx) | |
| 111 | + | templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 112 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 113 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 114 | + | if !templ_7745c5c3_IsBuffer { | |
| 115 | + | defer func() { | |
| 116 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 117 | + | if templ_7745c5c3_Err == nil { | |
| 118 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 119 | + | } | |
| 120 | + | }() | |
| 121 | + | } | |
| 122 | + | ctx = templ.InitializeContext(ctx) | |
| 123 | + | if len(p.Runs) == 0 { | |
| 124 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"dim\">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>") | |
| 125 | + | if templ_7745c5c3_Err != nil { | |
| 126 | + | return templ_7745c5c3_Err | |
| 127 | + | } | |
| 128 | + | } else { | |
| 129 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table class=\"list runs\" aria-label=\"Runs\"><thead><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></thead> <tbody id=\"run-rows\" data-rows>") | |
| 130 | + | if templ_7745c5c3_Err != nil { | |
| 131 | + | return templ_7745c5c3_Err | |
| 132 | + | } | |
| 133 | + | for _, r := range p.Runs { | |
| 134 | + | templ_7745c5c3_Err = RunListRow(r).Render(ctx, templ_7745c5c3_Buffer) | |
| 135 | + | if templ_7745c5c3_Err != nil { | |
| 136 | + | return templ_7745c5c3_Err | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</tbody></table>") | |
| 140 | + | if templ_7745c5c3_Err != nil { | |
| 141 | + | return templ_7745c5c3_Err | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") | |
| 145 | + | if templ_7745c5c3_Err != nil { | |
| 146 | + | return templ_7745c5c3_Err | |
| 147 | + | } | |
| 148 | + | if p.More != "" { | |
| 149 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"more\"><a href=\"") | |
| 150 | + | if templ_7745c5c3_Err != nil { | |
| 151 | + | return templ_7745c5c3_Err | |
| 152 | + | } | |
| 153 | + | var templ_7745c5c3_Var8 templ.SafeURL | |
| 154 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.More)) | |
| 155 | + | if templ_7745c5c3_Err != nil { | |
| 156 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 30, Col: 50} | |
| 157 | + | } | |
| 158 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 159 | + | if templ_7745c5c3_Err != nil { | |
| 160 | + | return templ_7745c5c3_Err | |
| 161 | + | } | |
| 162 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" rel=\"next\">older runs</a></p>") | |
| 163 | + | if templ_7745c5c3_Err != nil { | |
| 164 | + | return templ_7745c5c3_Err | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return nil | |
| 168 | + | }) | |
| 169 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) | |
| 170 | + | if templ_7745c5c3_Err != nil { | |
| 171 | + | return templ_7745c5c3_Err | |
| 172 | + | } | |
| 173 | + | return nil | |
| 174 | + | }) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // RunListRow is a row of the list of runs; its id is the target of the live | |
| 178 | + | // updates. | |
| 179 | + | func RunListRow(r RunRow) templ.Component { | |
| 180 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 181 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 182 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 183 | + | return templ_7745c5c3_CtxErr | |
| 184 | + | } | |
| 185 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 186 | + | if !templ_7745c5c3_IsBuffer { | |
| 187 | + | defer func() { | |
| 188 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 189 | + | if templ_7745c5c3_Err == nil { | |
| 190 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 191 | + | } | |
| 192 | + | }() | |
| 193 | + | } | |
| 194 | + | ctx = templ.InitializeContext(ctx) | |
| 195 | + | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 196 | + | if templ_7745c5c3_Var9 == nil { | |
| 197 | + | templ_7745c5c3_Var9 = templ.NopComponent | |
| 198 | + | } | |
| 199 | + | ctx = templ.ClearChildren(ctx) | |
| 200 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<tr id=\"") | |
| 201 | + | if templ_7745c5c3_Err != nil { | |
| 202 | + | return templ_7745c5c3_Err | |
| 203 | + | } | |
| 204 | + | var templ_7745c5c3_Var10 string | |
| 205 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("run-" + r.ID) | |
| 206 | + | if templ_7745c5c3_Err != nil { | |
| 207 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 38, Col: 23} | |
| 208 | + | } | |
| 209 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) | |
| 210 | + | if templ_7745c5c3_Err != nil { | |
| 211 | + | return templ_7745c5c3_Err | |
| 212 | + | } | |
| 213 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><td class=\"c-st\">") | |
| 214 | + | if templ_7745c5c3_Err != nil { | |
| 215 | + | return templ_7745c5c3_Err | |
| 216 | + | } | |
| 217 | + | templ_7745c5c3_Err = CheckIcon(r.Kind, r.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 218 | + | if templ_7745c5c3_Err != nil { | |
| 219 | + | return templ_7745c5c3_Err | |
| 220 | + | } | |
| 221 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td class=\"c-name\"><a href=\"") | |
| 222 | + | if templ_7745c5c3_Err != nil { | |
| 223 | + | return templ_7745c5c3_Err | |
| 224 | + | } | |
| 225 | + | var templ_7745c5c3_Var11 templ.SafeURL | |
| 226 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 227 | + | if templ_7745c5c3_Err != nil { | |
| 228 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 41, Col: 34} | |
| 229 | + | } | |
| 230 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 231 | + | if templ_7745c5c3_Err != nil { | |
| 232 | + | return templ_7745c5c3_Err | |
| 233 | + | } | |
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">") | |
| 235 | + | if templ_7745c5c3_Err != nil { | |
| 236 | + | return templ_7745c5c3_Err | |
| 237 | + | } | |
| 238 | + | var templ_7745c5c3_Var12 string | |
| 239 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Workflow) | |
| 240 | + | if templ_7745c5c3_Err != nil { | |
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 41, Col: 49} | |
| 242 | + | } | |
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 244 | + | if templ_7745c5c3_Err != nil { | |
| 245 | + | return templ_7745c5c3_Err | |
| 246 | + | } | |
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a> <span class=\"mute\">") | |
| 248 | + | if templ_7745c5c3_Err != nil { | |
| 249 | + | return templ_7745c5c3_Err | |
| 250 | + | } | |
| 251 | + | var templ_7745c5c3_Var13 string | |
| 252 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number) | |
| 253 | + | if templ_7745c5c3_Err != nil { | |
| 254 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 42, Col: 32} | |
| 255 | + | } | |
| 256 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 257 | + | if templ_7745c5c3_Err != nil { | |
| 258 | + | return templ_7745c5c3_Err | |
| 259 | + | } | |
| 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ") | |
| 261 | + | if templ_7745c5c3_Err != nil { | |
| 262 | + | return templ_7745c5c3_Err | |
| 263 | + | } | |
| 264 | + | if r.Attempt > 1 { | |
| 265 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"pill\">attempt ") | |
| 266 | + | if templ_7745c5c3_Err != nil { | |
| 267 | + | return templ_7745c5c3_Err | |
| 268 | + | } | |
| 269 | + | var templ_7745c5c3_Var14 string | |
| 270 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(r.Attempt))) | |
| 271 | + | if templ_7745c5c3_Err != nil { | |
| 272 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 44, Col: 53} | |
| 273 | + | } | |
| 274 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 275 | + | if templ_7745c5c3_Err != nil { | |
| 276 | + | return templ_7745c5c3_Err | |
| 277 | + | } | |
| 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>") | |
| 279 | + | if templ_7745c5c3_Err != nil { | |
| 280 | + | return templ_7745c5c3_Err | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"c-sub\"><a href=\"") | |
| 284 | + | if templ_7745c5c3_Err != nil { | |
| 285 | + | return templ_7745c5c3_Err | |
| 286 | + | } | |
| 287 | + | var templ_7745c5c3_Var15 templ.SafeURL | |
| 288 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 289 | + | if templ_7745c5c3_Err != nil { | |
| 290 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 52} | |
| 291 | + | } | |
| 292 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 293 | + | if templ_7745c5c3_Err != nil { | |
| 294 | + | return templ_7745c5c3_Err | |
| 295 | + | } | |
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">") | |
| 297 | + | if templ_7745c5c3_Err != nil { | |
| 298 | + | return templ_7745c5c3_Err | |
| 299 | + | } | |
| 300 | + | var templ_7745c5c3_Var16 string | |
| 301 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 302 | + | if templ_7745c5c3_Err != nil { | |
| 303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 64} | |
| 304 | + | } | |
| 305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 306 | + | if templ_7745c5c3_Err != nil { | |
| 307 | + | return templ_7745c5c3_Err | |
| 308 | + | } | |
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ") | |
| 310 | + | if templ_7745c5c3_Err != nil { | |
| 311 | + | return templ_7745c5c3_Err | |
| 312 | + | } | |
| 313 | + | var templ_7745c5c3_Var17 string | |
| 314 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) | |
| 315 | + | if templ_7745c5c3_Err != nil { | |
| 316 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 80} | |
| 317 | + | } | |
| 318 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 319 | + | if templ_7745c5c3_Err != nil { | |
| 320 | + | return templ_7745c5c3_Err | |
| 321 | + | } | |
| 322 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-ref\"><a href=\"") | |
| 323 | + | if templ_7745c5c3_Err != nil { | |
| 324 | + | return templ_7745c5c3_Err | |
| 325 | + | } | |
| 326 | + | var templ_7745c5c3_Var18 templ.SafeURL | |
| 327 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref)) | |
| 328 | + | if templ_7745c5c3_Err != nil { | |
| 329 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 54} | |
| 330 | + | } | |
| 331 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 332 | + | if templ_7745c5c3_Err != nil { | |
| 333 | + | return templ_7745c5c3_Err | |
| 334 | + | } | |
| 335 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") | |
| 336 | + | if templ_7745c5c3_Err != nil { | |
| 337 | + | return templ_7745c5c3_Err | |
| 338 | + | } | |
| 339 | + | var templ_7745c5c3_Var19 string | |
| 340 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref) | |
| 341 | + | if templ_7745c5c3_Err != nil { | |
| 342 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 64} | |
| 343 | + | } | |
| 344 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 345 | + | if templ_7745c5c3_Err != nil { | |
| 346 | + | return templ_7745c5c3_Err | |
| 347 | + | } | |
| 348 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a></td><td class=\"c-who dim\">") | |
| 349 | + | if templ_7745c5c3_Err != nil { | |
| 350 | + | return templ_7745c5c3_Err | |
| 351 | + | } | |
| 352 | + | var templ_7745c5c3_Var20 string | |
| 353 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Actor) | |
| 354 | + | if templ_7745c5c3_Err != nil { | |
| 355 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 49, Col: 33} | |
| 356 | + | } | |
| 357 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 358 | + | if templ_7745c5c3_Err != nil { | |
| 359 | + | return templ_7745c5c3_Err | |
| 360 | + | } | |
| 361 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"c-dur dim\">") | |
| 362 | + | if templ_7745c5c3_Err != nil { | |
| 363 | + | return templ_7745c5c3_Err | |
| 364 | + | } | |
| 365 | + | var templ_7745c5c3_Var21 string | |
| 366 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 367 | + | if templ_7745c5c3_Err != nil { | |
| 368 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 33} | |
| 369 | + | } | |
| 370 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 371 | + | if templ_7745c5c3_Err != nil { | |
| 372 | + | return templ_7745c5c3_Err | |
| 373 | + | } | |
| 374 | + | var templ_7745c5c3_Var22 string | |
| 375 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 376 | + | if templ_7745c5c3_Err != nil { | |
| 377 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 40} | |
| 378 | + | } | |
| 379 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 380 | + | if templ_7745c5c3_Err != nil { | |
| 381 | + | return templ_7745c5c3_Err | |
| 382 | + | } | |
| 383 | + | var templ_7745c5c3_Var23 string | |
| 384 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(r.Duration) | |
| 385 | + | if templ_7745c5c3_Err != nil { | |
| 386 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 54} | |
| 387 | + | } | |
| 388 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 389 | + | if templ_7745c5c3_Err != nil { | |
| 390 | + | return templ_7745c5c3_Err | |
| 391 | + | } | |
| 392 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-when dim\">") | |
| 393 | + | if templ_7745c5c3_Err != nil { | |
| 394 | + | return templ_7745c5c3_Err | |
| 395 | + | } | |
| 396 | + | if r.WhenAt != "" { | |
| 397 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<time datetime=\"") | |
| 398 | + | if templ_7745c5c3_Err != nil { | |
| 399 | + | return templ_7745c5c3_Err | |
| 400 | + | } | |
| 401 | + | var templ_7745c5c3_Var24 string | |
| 402 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 403 | + | if templ_7745c5c3_Err != nil { | |
| 404 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 29} | |
| 405 | + | } | |
| 406 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) | |
| 407 | + | if templ_7745c5c3_Err != nil { | |
| 408 | + | return templ_7745c5c3_Err | |
| 409 | + | } | |
| 410 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" title=\"") | |
| 411 | + | if templ_7745c5c3_Err != nil { | |
| 412 | + | return templ_7745c5c3_Err | |
| 413 | + | } | |
| 414 | + | var templ_7745c5c3_Var25 string | |
| 415 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 416 | + | if templ_7745c5c3_Err != nil { | |
| 417 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 48} | |
| 418 | + | } | |
| 419 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) | |
| 420 | + | if templ_7745c5c3_Err != nil { | |
| 421 | + | return templ_7745c5c3_Err | |
| 422 | + | } | |
| 423 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | |
| 424 | + | if templ_7745c5c3_Err != nil { | |
| 425 | + | return templ_7745c5c3_Err | |
| 426 | + | } | |
| 427 | + | var templ_7745c5c3_Var26 string | |
| 428 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 429 | + | if templ_7745c5c3_Err != nil { | |
| 430 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 59} | |
| 431 | + | } | |
| 432 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 433 | + | if templ_7745c5c3_Err != nil { | |
| 434 | + | return templ_7745c5c3_Err | |
| 435 | + | } | |
| 436 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</time>") | |
| 437 | + | if templ_7745c5c3_Err != nil { | |
| 438 | + | return templ_7745c5c3_Err | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr>") | |
| 442 | + | if templ_7745c5c3_Err != nil { | |
| 443 | + | return templ_7745c5c3_Err | |
| 444 | + | } | |
| 445 | + | return nil | |
| 446 | + | }) | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func RunDetail(l Layout, p RunPage) templ.Component { | |
| 450 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 451 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 452 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 453 | + | return templ_7745c5c3_CtxErr | |
| 454 | + | } | |
| 455 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 456 | + | if !templ_7745c5c3_IsBuffer { | |
| 457 | + | defer func() { | |
| 458 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 459 | + | if templ_7745c5c3_Err == nil { | |
| 460 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 461 | + | } | |
| 462 | + | }() | |
| 463 | + | } | |
| 464 | + | ctx = templ.InitializeContext(ctx) | |
| 465 | + | templ_7745c5c3_Var27 := templ.GetChildren(ctx) | |
| 466 | + | if templ_7745c5c3_Var27 == nil { | |
| 467 | + | templ_7745c5c3_Var27 = templ.NopComponent | |
| 468 | + | } | |
| 469 | + | ctx = templ.ClearChildren(ctx) | |
| 470 | + | templ_7745c5c3_Var28 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 471 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 472 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 473 | + | if !templ_7745c5c3_IsBuffer { | |
| 474 | + | defer func() { | |
| 475 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 476 | + | if templ_7745c5c3_Err == nil { | |
| 477 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 478 | + | } | |
| 479 | + | }() | |
| 480 | + | } | |
| 481 | + | ctx = templ.InitializeContext(ctx) | |
| 482 | + | templ_7745c5c3_Err = LiveSlot("run-notice").Render(ctx, templ_7745c5c3_Buffer) | |
| 483 | + | if templ_7745c5c3_Err != nil { | |
| 484 | + | return templ_7745c5c3_Err | |
| 485 | + | } | |
| 486 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " <div id=\"run-body\">") | |
| 487 | + | if templ_7745c5c3_Err != nil { | |
| 488 | + | return templ_7745c5c3_Err | |
| 489 | + | } | |
| 490 | + | templ_7745c5c3_Err = RunBody(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 491 | + | if templ_7745c5c3_Err != nil { | |
| 492 | + | return templ_7745c5c3_Err | |
| 493 | + | } | |
| 494 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>") | |
| 495 | + | if templ_7745c5c3_Err != nil { | |
| 496 | + | return templ_7745c5c3_Err | |
| 497 | + | } | |
| 498 | + | return nil | |
| 499 | + | }) | |
| 500 | + | templ_7745c5c3_Err = Shell(l, runRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var28), templ_7745c5c3_Buffer) | |
| 501 | + | if templ_7745c5c3_Err != nil { | |
| 502 | + | return templ_7745c5c3_Err | |
| 503 | + | } | |
| 504 | + | return nil | |
| 505 | + | }) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | // runBodyFragment is the body of the run page as a live fragment. | |
| 509 | + | func runBodyFragment(p RunPage) templ.Component { | |
| 510 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 511 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 512 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 513 | + | return templ_7745c5c3_CtxErr | |
| 514 | + | } | |
| 515 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 516 | + | if !templ_7745c5c3_IsBuffer { | |
| 517 | + | defer func() { | |
| 518 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 519 | + | if templ_7745c5c3_Err == nil { | |
| 520 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 521 | + | } | |
| 522 | + | }() | |
| 523 | + | } | |
| 524 | + | ctx = templ.InitializeContext(ctx) | |
| 525 | + | templ_7745c5c3_Var29 := templ.GetChildren(ctx) | |
| 526 | + | if templ_7745c5c3_Var29 == nil { | |
| 527 | + | templ_7745c5c3_Var29 = templ.NopComponent | |
| 528 | + | } | |
| 529 | + | ctx = templ.ClearChildren(ctx) | |
| 530 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div id=\"run-body\">") | |
| 531 | + | if templ_7745c5c3_Err != nil { | |
| 532 | + | return templ_7745c5c3_Err | |
| 533 | + | } | |
| 534 | + | templ_7745c5c3_Err = RunBody(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 535 | + | if templ_7745c5c3_Err != nil { | |
| 536 | + | return templ_7745c5c3_Err | |
| 537 | + | } | |
| 538 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>") | |
| 539 | + | if templ_7745c5c3_Err != nil { | |
| 540 | + | return templ_7745c5c3_Err | |
| 541 | + | } | |
| 542 | + | return nil | |
| 543 | + | }) | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // RunBody is the jobs of a run, replaced by the live updates. | |
| 547 | + | func RunBody(p RunPage) templ.Component { | |
| 548 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 549 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 550 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 551 | + | return templ_7745c5c3_CtxErr | |
| 552 | + | } | |
| 553 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 554 | + | if !templ_7745c5c3_IsBuffer { | |
| 555 | + | defer func() { | |
| 556 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 557 | + | if templ_7745c5c3_Err == nil { | |
| 558 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 559 | + | } | |
| 560 | + | }() | |
| 561 | + | } | |
| 562 | + | ctx = templ.InitializeContext(ctx) | |
| 563 | + | templ_7745c5c3_Var30 := templ.GetChildren(ctx) | |
| 564 | + | if templ_7745c5c3_Var30 == nil { | |
| 565 | + | templ_7745c5c3_Var30 = templ.NopComponent | |
| 566 | + | } | |
| 567 | + | ctx = templ.ClearChildren(ctx) | |
| 568 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<h2 class=\"run-h\">") | |
| 569 | + | if templ_7745c5c3_Err != nil { | |
| 570 | + | return templ_7745c5c3_Err | |
| 571 | + | } | |
| 572 | + | templ_7745c5c3_Err = CheckIcon(p.Kind, p.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 573 | + | if templ_7745c5c3_Err != nil { | |
| 574 | + | return templ_7745c5c3_Err | |
| 575 | + | } | |
| 576 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>") | |
| 577 | + | if templ_7745c5c3_Err != nil { | |
| 578 | + | return templ_7745c5c3_Err | |
| 579 | + | } | |
| 580 | + | var templ_7745c5c3_Var31 string | |
| 581 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(p.Workflow) | |
| 582 | + | if templ_7745c5c3_Err != nil { | |
| 583 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 64} | |
| 584 | + | } | |
| 585 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 586 | + | if templ_7745c5c3_Err != nil { | |
| 587 | + | return templ_7745c5c3_Err | |
| 588 | + | } | |
| 589 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <span class=\"mute\">") | |
| 590 | + | if templ_7745c5c3_Err != nil { | |
| 591 | + | return templ_7745c5c3_Err | |
| 592 | + | } | |
| 593 | + | var templ_7745c5c3_Var32 string | |
| 594 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number) | |
| 595 | + | if templ_7745c5c3_Err != nil { | |
| 596 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 103} | |
| 597 | + | } | |
| 598 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 599 | + | if templ_7745c5c3_Err != nil { | |
| 600 | + | return templ_7745c5c3_Err | |
| 601 | + | } | |
| 602 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span></h2>") | |
| 603 | + | if templ_7745c5c3_Err != nil { | |
| 604 | + | return templ_7745c5c3_Err | |
| 605 | + | } | |
| 606 | + | if p.Error != "" { | |
| 607 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<pre class=\"run-err\">") | |
| 608 | + | if templ_7745c5c3_Err != nil { | |
| 609 | + | return templ_7745c5c3_Err | |
| 610 | + | } | |
| 611 | + | var templ_7745c5c3_Var33 string | |
| 612 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error) | |
| 613 | + | if templ_7745c5c3_Err != nil { | |
| 614 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 79, Col: 32} | |
| 615 | + | } | |
| 616 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 617 | + | if templ_7745c5c3_Err != nil { | |
| 618 | + | return templ_7745c5c3_Err | |
| 619 | + | } | |
| 620 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</pre>") | |
| 621 | + | if templ_7745c5c3_Err != nil { | |
| 622 | + | return templ_7745c5c3_Err | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<ul class=\"jobs\">") | |
| 626 | + | if templ_7745c5c3_Err != nil { | |
| 627 | + | return templ_7745c5c3_Err | |
| 628 | + | } | |
| 629 | + | for _, j := range p.Jobs { | |
| 630 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<li id=\"") | |
| 631 | + | if templ_7745c5c3_Err != nil { | |
| 632 | + | return templ_7745c5c3_Err | |
| 633 | + | } | |
| 634 | + | var templ_7745c5c3_Var34 string | |
| 635 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID) | |
| 636 | + | if templ_7745c5c3_Err != nil { | |
| 637 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 83, Col: 25} | |
| 638 | + | } | |
| 639 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | |
| 640 | + | if templ_7745c5c3_Err != nil { | |
| 641 | + | return templ_7745c5c3_Err | |
| 642 | + | } | |
| 643 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><a class=\"job-h\" href=\"") | |
| 644 | + | if templ_7745c5c3_Err != nil { | |
| 645 | + | return templ_7745c5c3_Err | |
| 646 | + | } | |
| 647 | + | var templ_7745c5c3_Var35 templ.SafeURL | |
| 648 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href)) | |
| 649 | + | if templ_7745c5c3_Err != nil { | |
| 650 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 84, Col: 49} | |
| 651 | + | } | |
| 652 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 653 | + | if templ_7745c5c3_Err != nil { | |
| 654 | + | return templ_7745c5c3_Err | |
| 655 | + | } | |
| 656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">") | |
| 657 | + | if templ_7745c5c3_Err != nil { | |
| 658 | + | return templ_7745c5c3_Err | |
| 659 | + | } | |
| 660 | + | templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 661 | + | if templ_7745c5c3_Err != nil { | |
| 662 | + | return templ_7745c5c3_Err | |
| 663 | + | } | |
| 664 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"j-name\">") | |
| 665 | + | if templ_7745c5c3_Err != nil { | |
| 666 | + | return templ_7745c5c3_Err | |
| 667 | + | } | |
| 668 | + | var templ_7745c5c3_Var36 string | |
| 669 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 670 | + | if templ_7745c5c3_Err != nil { | |
| 671 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 86, Col: 34} | |
| 672 | + | } | |
| 673 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 674 | + | if templ_7745c5c3_Err != nil { | |
| 675 | + | return templ_7745c5c3_Err | |
| 676 | + | } | |
| 677 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span> <span class=\"dim\">") | |
| 678 | + | if templ_7745c5c3_Err != nil { | |
| 679 | + | return templ_7745c5c3_Err | |
| 680 | + | } | |
| 681 | + | var templ_7745c5c3_Var37 string | |
| 682 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 683 | + | if templ_7745c5c3_Err != nil { | |
| 684 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 32} | |
| 685 | + | } | |
| 686 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 687 | + | if templ_7745c5c3_Err != nil { | |
| 688 | + | return templ_7745c5c3_Err | |
| 689 | + | } | |
| 690 | + | var templ_7745c5c3_Var38 string | |
| 691 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 692 | + | if templ_7745c5c3_Err != nil { | |
| 693 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 39} | |
| 694 | + | } | |
| 695 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 696 | + | if templ_7745c5c3_Err != nil { | |
| 697 | + | return templ_7745c5c3_Err | |
| 698 | + | } | |
| 699 | + | var templ_7745c5c3_Var39 string | |
| 700 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 701 | + | if templ_7745c5c3_Err != nil { | |
| 702 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 53} | |
| 703 | + | } | |
| 704 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 705 | + | if templ_7745c5c3_Err != nil { | |
| 706 | + | return templ_7745c5c3_Err | |
| 707 | + | } | |
| 708 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></a> ") | |
| 709 | + | if templ_7745c5c3_Err != nil { | |
| 710 | + | return templ_7745c5c3_Err | |
| 711 | + | } | |
| 712 | + | if j.Runner != "" { | |
| 713 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"mute\">on ") | |
| 714 | + | if templ_7745c5c3_Err != nil { | |
| 715 | + | return templ_7745c5c3_Err | |
| 716 | + | } | |
| 717 | + | var templ_7745c5c3_Var40 string | |
| 718 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j)) | |
| 719 | + | if templ_7745c5c3_Err != nil { | |
| 720 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 90, Col: 42} | |
| 721 | + | } | |
| 722 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 723 | + | if templ_7745c5c3_Err != nil { | |
| 724 | + | return templ_7745c5c3_Err | |
| 725 | + | } | |
| 726 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> ") | |
| 727 | + | if templ_7745c5c3_Err != nil { | |
| 728 | + | return templ_7745c5c3_Err | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | if len(j.Needs) > 0 { | |
| 732 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"mute\">needs ") | |
| 733 | + | if templ_7745c5c3_Err != nil { | |
| 734 | + | return templ_7745c5c3_Err | |
| 735 | + | } | |
| 736 | + | var templ_7745c5c3_Var41 string | |
| 737 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs)) | |
| 738 | + | if templ_7745c5c3_Err != nil { | |
| 739 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 93, Col: 50} | |
| 740 | + | } | |
| 741 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 742 | + | if templ_7745c5c3_Err != nil { | |
| 743 | + | return templ_7745c5c3_Err | |
| 744 | + | } | |
| 745 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> ") | |
| 746 | + | if templ_7745c5c3_Err != nil { | |
| 747 | + | return templ_7745c5c3_Err | |
| 748 | + | } | |
| 749 | + | } | |
| 750 | + | if j.Error != "" { | |
| 751 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<p class=\"job-err\">") | |
| 752 | + | if templ_7745c5c3_Err != nil { | |
| 753 | + | return templ_7745c5c3_Err | |
| 754 | + | } | |
| 755 | + | var templ_7745c5c3_Var42 string | |
| 756 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 757 | + | if templ_7745c5c3_Err != nil { | |
| 758 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 96, Col: 33} | |
| 759 | + | } | |
| 760 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 761 | + | if templ_7745c5c3_Err != nil { | |
| 762 | + | return templ_7745c5c3_Err | |
| 763 | + | } | |
| 764 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</p>") | |
| 765 | + | if templ_7745c5c3_Err != nil { | |
| 766 | + | return templ_7745c5c3_Err | |
| 767 | + | } | |
| 768 | + | } | |
| 769 | + | if len(j.Steps) > 0 { | |
| 770 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<ol class=\"steps\">") | |
| 771 | + | if templ_7745c5c3_Err != nil { | |
| 772 | + | return templ_7745c5c3_Err | |
| 773 | + | } | |
| 774 | + | for _, s := range j.Steps { | |
| 775 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<li>") | |
| 776 | + | if templ_7745c5c3_Err != nil { | |
| 777 | + | return templ_7745c5c3_Err | |
| 778 | + | } | |
| 779 | + | templ_7745c5c3_Err = CheckIcon(s.Kind, s.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 780 | + | if templ_7745c5c3_Err != nil { | |
| 781 | + | return templ_7745c5c3_Err | |
| 782 | + | } | |
| 783 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>") | |
| 784 | + | if templ_7745c5c3_Err != nil { | |
| 785 | + | return templ_7745c5c3_Err | |
| 786 | + | } | |
| 787 | + | var templ_7745c5c3_Var43 string | |
| 788 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 789 | + | if templ_7745c5c3_Err != nil { | |
| 790 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 53} | |
| 791 | + | } | |
| 792 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 793 | + | if templ_7745c5c3_Err != nil { | |
| 794 | + | return templ_7745c5c3_Err | |
| 795 | + | } | |
| 796 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> <span class=\"dim\">") | |
| 797 | + | if templ_7745c5c3_Err != nil { | |
| 798 | + | return templ_7745c5c3_Err | |
| 799 | + | } | |
| 800 | + | var templ_7745c5c3_Var44 string | |
| 801 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 802 | + | if templ_7745c5c3_Err != nil { | |
| 803 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 93} | |
| 804 | + | } | |
| 805 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 806 | + | if templ_7745c5c3_Err != nil { | |
| 807 | + | return templ_7745c5c3_Err | |
| 808 | + | } | |
| 809 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></li>") | |
| 810 | + | if templ_7745c5c3_Err != nil { | |
| 811 | + | return templ_7745c5c3_Err | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</ol>") | |
| 815 | + | if templ_7745c5c3_Err != nil { | |
| 816 | + | return templ_7745c5c3_Err | |
| 817 | + | } | |
| 818 | + | } | |
| 819 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>") | |
| 820 | + | if templ_7745c5c3_Err != nil { | |
| 821 | + | return templ_7745c5c3_Err | |
| 822 | + | } | |
| 823 | + | } | |
| 824 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>") | |
| 825 | + | if templ_7745c5c3_Err != nil { | |
| 826 | + | return templ_7745c5c3_Err | |
| 827 | + | } | |
| 828 | + | return nil | |
| 829 | + | }) | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | func runRail(p RunPage) templ.Component { | |
| 833 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 834 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 835 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 836 | + | return templ_7745c5c3_CtxErr | |
| 837 | + | } | |
| 838 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 839 | + | if !templ_7745c5c3_IsBuffer { | |
| 840 | + | defer func() { | |
| 841 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 842 | + | if templ_7745c5c3_Err == nil { | |
| 843 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 844 | + | } | |
| 845 | + | }() | |
| 846 | + | } | |
| 847 | + | ctx = templ.InitializeContext(ctx) | |
| 848 | + | templ_7745c5c3_Var45 := templ.GetChildren(ctx) | |
| 849 | + | if templ_7745c5c3_Var45 == nil { | |
| 850 | + | templ_7745c5c3_Var45 = templ.NopComponent | |
| 851 | + | } | |
| 852 | + | ctx = templ.ClearChildren(ctx) | |
| 853 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>") | |
| 854 | + | if templ_7745c5c3_Err != nil { | |
| 855 | + | return templ_7745c5c3_Err | |
| 856 | + | } | |
| 857 | + | var templ_7745c5c3_Var46 string | |
| 858 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.State) | |
| 859 | + | if templ_7745c5c3_Err != nil { | |
| 860 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 16} | |
| 861 | + | } | |
| 862 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 863 | + | if templ_7745c5c3_Err != nil { | |
| 864 | + | return templ_7745c5c3_Err | |
| 865 | + | } | |
| 866 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dd><dt>commit</dt><dd><a href=\"") | |
| 867 | + | if templ_7745c5c3_Err != nil { | |
| 868 | + | return templ_7745c5c3_Err | |
| 869 | + | } | |
| 870 | + | var templ_7745c5c3_Var47 templ.SafeURL | |
| 871 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref)) | |
| 872 | + | if templ_7745c5c3_Err != nil { | |
| 873 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 39} | |
| 874 | + | } | |
| 875 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 876 | + | if templ_7745c5c3_Err != nil { | |
| 877 | + | return templ_7745c5c3_Err | |
| 878 | + | } | |
| 879 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">") | |
| 880 | + | if templ_7745c5c3_Err != nil { | |
| 881 | + | return templ_7745c5c3_Err | |
| 882 | + | } | |
| 883 | + | var templ_7745c5c3_Var48 string | |
| 884 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short) | |
| 885 | + | if templ_7745c5c3_Err != nil { | |
| 886 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 51} | |
| 887 | + | } | |
| 888 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 889 | + | if templ_7745c5c3_Err != nil { | |
| 890 | + | return templ_7745c5c3_Err | |
| 891 | + | } | |
| 892 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a></dd><dt>ref</dt><dd><a href=\"") | |
| 893 | + | if templ_7745c5c3_Err != nil { | |
| 894 | + | return templ_7745c5c3_Err | |
| 895 | + | } | |
| 896 | + | var templ_7745c5c3_Var49 templ.SafeURL | |
| 897 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref)) | |
| 898 | + | if templ_7745c5c3_Err != nil { | |
| 899 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 41} | |
| 900 | + | } | |
| 901 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 902 | + | if templ_7745c5c3_Err != nil { | |
| 903 | + | return templ_7745c5c3_Err | |
| 904 | + | } | |
| 905 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">") | |
| 906 | + | if templ_7745c5c3_Err != nil { | |
| 907 | + | return templ_7745c5c3_Err | |
| 908 | + | } | |
| 909 | + | var templ_7745c5c3_Var50 string | |
| 910 | + | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref) | |
| 911 | + | if templ_7745c5c3_Err != nil { | |
| 912 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 51} | |
| 913 | + | } | |
| 914 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 915 | + | if templ_7745c5c3_Err != nil { | |
| 916 | + | return templ_7745c5c3_Err | |
| 917 | + | } | |
| 918 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a></dd><dt>actor</dt><dd>") | |
| 919 | + | if templ_7745c5c3_Err != nil { | |
| 920 | + | return templ_7745c5c3_Err | |
| 921 | + | } | |
| 922 | + | var templ_7745c5c3_Var51 string | |
| 923 | + | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor) | |
| 924 | + | if templ_7745c5c3_Err != nil { | |
| 925 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 121, Col: 16} | |
| 926 | + | } | |
| 927 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 928 | + | if templ_7745c5c3_Err != nil { | |
| 929 | + | return templ_7745c5c3_Err | |
| 930 | + | } | |
| 931 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</dd><dt>attempt</dt><dd>") | |
| 932 | + | if templ_7745c5c3_Err != nil { | |
| 933 | + | return templ_7745c5c3_Err | |
| 934 | + | } | |
| 935 | + | var templ_7745c5c3_Var52 string | |
| 936 | + | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt))) | |
| 937 | + | if templ_7745c5c3_Err != nil { | |
| 938 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 29} | |
| 939 | + | } | |
| 940 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 941 | + | if templ_7745c5c3_Err != nil { | |
| 942 | + | return templ_7745c5c3_Err | |
| 943 | + | } | |
| 944 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " of ") | |
| 945 | + | if templ_7745c5c3_Err != nil { | |
| 946 | + | return templ_7745c5c3_Err | |
| 947 | + | } | |
| 948 | + | var templ_7745c5c3_Var53 string | |
| 949 | + | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest))) | |
| 950 | + | if templ_7745c5c3_Err != nil { | |
| 951 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 56} | |
| 952 | + | } | |
| 953 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 954 | + | if templ_7745c5c3_Err != nil { | |
| 955 | + | return templ_7745c5c3_Err | |
| 956 | + | } | |
| 957 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</dd></dl>") | |
| 958 | + | if templ_7745c5c3_Err != nil { | |
| 959 | + | return templ_7745c5c3_Err | |
| 960 | + | } | |
| 961 | + | if len(p.Attempts) > 0 { | |
| 962 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<ul class=\"attempts\">") | |
| 963 | + | if templ_7745c5c3_Err != nil { | |
| 964 | + | return templ_7745c5c3_Err | |
| 965 | + | } | |
| 966 | + | for _, a := range p.Attempts { | |
| 967 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li>") | |
| 968 | + | if templ_7745c5c3_Err != nil { | |
| 969 | + | return templ_7745c5c3_Err | |
| 970 | + | } | |
| 971 | + | if a.Current { | |
| 972 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span aria-current=\"page\">") | |
| 973 | + | if templ_7745c5c3_Err != nil { | |
| 974 | + | return templ_7745c5c3_Err | |
| 975 | + | } | |
| 976 | + | var templ_7745c5c3_Var54 string | |
| 977 | + | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 978 | + | if templ_7745c5c3_Err != nil { | |
| 979 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 130, Col: 42} | |
| 980 | + | } | |
| 981 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 982 | + | if templ_7745c5c3_Err != nil { | |
| 983 | + | return templ_7745c5c3_Err | |
| 984 | + | } | |
| 985 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span>") | |
| 986 | + | if templ_7745c5c3_Err != nil { | |
| 987 | + | return templ_7745c5c3_Err | |
| 988 | + | } | |
| 989 | + | } else { | |
| 990 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<a href=\"") | |
| 991 | + | if templ_7745c5c3_Err != nil { | |
| 992 | + | return templ_7745c5c3_Err | |
| 993 | + | } | |
| 994 | + | var templ_7745c5c3_Var55 templ.SafeURL | |
| 995 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href)) | |
| 996 | + | if templ_7745c5c3_Err != nil { | |
| 997 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 38} | |
| 998 | + | } | |
| 999 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1000 | + | if templ_7745c5c3_Err != nil { | |
| 1001 | + | return templ_7745c5c3_Err | |
| 1002 | + | } | |
| 1003 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\">") | |
| 1004 | + | if templ_7745c5c3_Err != nil { | |
| 1005 | + | return templ_7745c5c3_Err | |
| 1006 | + | } | |
| 1007 | + | var templ_7745c5c3_Var56 string | |
| 1008 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 1009 | + | if templ_7745c5c3_Err != nil { | |
| 1010 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 50} | |
| 1011 | + | } | |
| 1012 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1013 | + | if templ_7745c5c3_Err != nil { | |
| 1014 | + | return templ_7745c5c3_Err | |
| 1015 | + | } | |
| 1016 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</a>") | |
| 1017 | + | if templ_7745c5c3_Err != nil { | |
| 1018 | + | return templ_7745c5c3_Err | |
| 1019 | + | } | |
| 1020 | + | } | |
| 1021 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</li>") | |
| 1022 | + | if templ_7745c5c3_Err != nil { | |
| 1023 | + | return templ_7745c5c3_Err | |
| 1024 | + | } | |
| 1025 | + | } | |
| 1026 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</ul>") | |
| 1027 | + | if templ_7745c5c3_Err != nil { | |
| 1028 | + | return templ_7745c5c3_Err | |
| 1029 | + | } | |
| 1030 | + | } | |
| 1031 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</section>") | |
| 1032 | + | if templ_7745c5c3_Err != nil { | |
| 1033 | + | return templ_7745c5c3_Err | |
| 1034 | + | } | |
| 1035 | + | if p.CancelAction != "" || p.RerunAction != "" { | |
| 1036 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">") | |
| 1037 | + | if templ_7745c5c3_Err != nil { | |
| 1038 | + | return templ_7745c5c3_Err | |
| 1039 | + | } | |
| 1040 | + | if p.CancelAction != "" { | |
| 1041 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form method=\"post\" action=\"") | |
| 1042 | + | if templ_7745c5c3_Err != nil { | |
| 1043 | + | return templ_7745c5c3_Err | |
| 1044 | + | } | |
| 1045 | + | var templ_7745c5c3_Var57 templ.SafeURL | |
| 1046 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction)) | |
| 1047 | + | if templ_7745c5c3_Err != nil { | |
| 1048 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 144, Col: 63} | |
| 1049 | + | } | |
| 1050 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1051 | + | if templ_7745c5c3_Err != nil { | |
| 1052 | + | return templ_7745c5c3_Err | |
| 1053 | + | } | |
| 1054 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>") | |
| 1055 | + | if templ_7745c5c3_Err != nil { | |
| 1056 | + | return templ_7745c5c3_Err | |
| 1057 | + | } | |
| 1058 | + | } | |
| 1059 | + | if p.RerunAction != "" { | |
| 1060 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<form method=\"post\" action=\"") | |
| 1061 | + | if templ_7745c5c3_Err != nil { | |
| 1062 | + | return templ_7745c5c3_Err | |
| 1063 | + | } | |
| 1064 | + | var templ_7745c5c3_Var58 templ.SafeURL | |
| 1065 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction)) | |
| 1066 | + | if templ_7745c5c3_Err != nil { | |
| 1067 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 147, Col: 62} | |
| 1068 | + | } | |
| 1069 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1070 | + | if templ_7745c5c3_Err != nil { | |
| 1071 | + | return templ_7745c5c3_Err | |
| 1072 | + | } | |
| 1073 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>") | |
| 1074 | + | if templ_7745c5c3_Err != nil { | |
| 1075 | + | return templ_7745c5c3_Err | |
| 1076 | + | } | |
| 1077 | + | } | |
| 1078 | + | if p.CanRerunFailed { | |
| 1079 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<form method=\"post\" action=\"") | |
| 1080 | + | if templ_7745c5c3_Err != nil { | |
| 1081 | + | return templ_7745c5c3_Err | |
| 1082 | + | } | |
| 1083 | + | var templ_7745c5c3_Var59 templ.SafeURL | |
| 1084 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction)) | |
| 1085 | + | if templ_7745c5c3_Err != nil { | |
| 1086 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 150, Col: 68} | |
| 1087 | + | } | |
| 1088 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1089 | + | if templ_7745c5c3_Err != nil { | |
| 1090 | + | return templ_7745c5c3_Err | |
| 1091 | + | } | |
| 1092 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>") | |
| 1093 | + | if templ_7745c5c3_Err != nil { | |
| 1094 | + | return templ_7745c5c3_Err | |
| 1095 | + | } | |
| 1096 | + | } | |
| 1097 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></section>") | |
| 1098 | + | if templ_7745c5c3_Err != nil { | |
| 1099 | + | return templ_7745c5c3_Err | |
| 1100 | + | } | |
| 1101 | + | } | |
| 1102 | + | return nil | |
| 1103 | + | }) | |
| 1104 | + | } | |
| 1105 | + | ||
| 1106 | + | // JobHead is the title of the job page with its state and its error, replaced | |
| 1107 | + | // by the live updates. total is the number of lines the log holds. | |
| 1108 | + | func JobHead(j JobRow, total int64) templ.Component { | |
| 1109 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1110 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1111 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1112 | + | return templ_7745c5c3_CtxErr | |
| 1113 | + | } | |
| 1114 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1115 | + | if !templ_7745c5c3_IsBuffer { | |
| 1116 | + | defer func() { | |
| 1117 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1118 | + | if templ_7745c5c3_Err == nil { | |
| 1119 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1120 | + | } | |
| 1121 | + | }() | |
| 1122 | + | } | |
| 1123 | + | ctx = templ.InitializeContext(ctx) | |
| 1124 | + | templ_7745c5c3_Var60 := templ.GetChildren(ctx) | |
| 1125 | + | if templ_7745c5c3_Var60 == nil { | |
| 1126 | + | templ_7745c5c3_Var60 = templ.NopComponent | |
| 1127 | + | } | |
| 1128 | + | ctx = templ.ClearChildren(ctx) | |
| 1129 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div id=\"job-head\" data-total=\"") | |
| 1130 | + | if templ_7745c5c3_Err != nil { | |
| 1131 | + | return templ_7745c5c3_Err | |
| 1132 | + | } | |
| 1133 | + | var templ_7745c5c3_Var61 string | |
| 1134 | + | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total)) | |
| 1135 | + | if templ_7745c5c3_Err != nil { | |
| 1136 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 160, Col: 46} | |
| 1137 | + | } | |
| 1138 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61) | |
| 1139 | + | if templ_7745c5c3_Err != nil { | |
| 1140 | + | return templ_7745c5c3_Err | |
| 1141 | + | } | |
| 1142 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"><h2 class=\"run-h\">") | |
| 1143 | + | if templ_7745c5c3_Err != nil { | |
| 1144 | + | return templ_7745c5c3_Err | |
| 1145 | + | } | |
| 1146 | + | templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 1147 | + | if templ_7745c5c3_Err != nil { | |
| 1148 | + | return templ_7745c5c3_Err | |
| 1149 | + | } | |
| 1150 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<span>") | |
| 1151 | + | if templ_7745c5c3_Err != nil { | |
| 1152 | + | return templ_7745c5c3_Err | |
| 1153 | + | } | |
| 1154 | + | var templ_7745c5c3_Var62 string | |
| 1155 | + | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 1156 | + | if templ_7745c5c3_Err != nil { | |
| 1157 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 61} | |
| 1158 | + | } | |
| 1159 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1160 | + | if templ_7745c5c3_Err != nil { | |
| 1161 | + | return templ_7745c5c3_Err | |
| 1162 | + | } | |
| 1163 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> <span class=\"dim\">") | |
| 1164 | + | if templ_7745c5c3_Err != nil { | |
| 1165 | + | return templ_7745c5c3_Err | |
| 1166 | + | } | |
| 1167 | + | var templ_7745c5c3_Var63 string | |
| 1168 | + | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 1169 | + | if templ_7745c5c3_Err != nil { | |
| 1170 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 98} | |
| 1171 | + | } | |
| 1172 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1173 | + | if templ_7745c5c3_Err != nil { | |
| 1174 | + | return templ_7745c5c3_Err | |
| 1175 | + | } | |
| 1176 | + | var templ_7745c5c3_Var64 string | |
| 1177 | + | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1178 | + | if templ_7745c5c3_Err != nil { | |
| 1179 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 105} | |
| 1180 | + | } | |
| 1181 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) | |
| 1182 | + | if templ_7745c5c3_Err != nil { | |
| 1183 | + | return templ_7745c5c3_Err | |
| 1184 | + | } | |
| 1185 | + | var templ_7745c5c3_Var65 string | |
| 1186 | + | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 1187 | + | if templ_7745c5c3_Err != nil { | |
| 1188 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 119} | |
| 1189 | + | } | |
| 1190 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1191 | + | if templ_7745c5c3_Err != nil { | |
| 1192 | + | return templ_7745c5c3_Err | |
| 1193 | + | } | |
| 1194 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span></h2>") | |
| 1195 | + | if templ_7745c5c3_Err != nil { | |
| 1196 | + | return templ_7745c5c3_Err | |
| 1197 | + | } | |
| 1198 | + | if j.Error != "" { | |
| 1199 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"job-err\">") | |
| 1200 | + | if templ_7745c5c3_Err != nil { | |
| 1201 | + | return templ_7745c5c3_Err | |
| 1202 | + | } | |
| 1203 | + | var templ_7745c5c3_Var66 string | |
| 1204 | + | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 1205 | + | if templ_7745c5c3_Err != nil { | |
| 1206 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 163, Col: 31} | |
| 1207 | + | } | |
| 1208 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) | |
| 1209 | + | if templ_7745c5c3_Err != nil { | |
| 1210 | + | return templ_7745c5c3_Err | |
| 1211 | + | } | |
| 1212 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>") | |
| 1213 | + | if templ_7745c5c3_Err != nil { | |
| 1214 | + | return templ_7745c5c3_Err | |
| 1215 | + | } | |
| 1216 | + | } | |
| 1217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div>") | |
| 1218 | + | if templ_7745c5c3_Err != nil { | |
| 1219 | + | return templ_7745c5c3_Err | |
| 1220 | + | } | |
| 1221 | + | return nil | |
| 1222 | + | }) | |
| 1223 | + | } | |
| 1224 | + | ||
| 1225 | + | // JobSteps is the steps of the job in the rail, replaced by the live updates. | |
| 1226 | + | func JobSteps(steps []StepView) templ.Component { | |
| 1227 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1228 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1229 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1230 | + | return templ_7745c5c3_CtxErr | |
| 1231 | + | } | |
| 1232 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1233 | + | if !templ_7745c5c3_IsBuffer { | |
| 1234 | + | defer func() { | |
| 1235 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1236 | + | if templ_7745c5c3_Err == nil { | |
| 1237 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1238 | + | } | |
| 1239 | + | }() | |
| 1240 | + | } | |
| 1241 | + | ctx = templ.InitializeContext(ctx) | |
| 1242 | + | templ_7745c5c3_Var67 := templ.GetChildren(ctx) | |
| 1243 | + | if templ_7745c5c3_Var67 == nil { | |
| 1244 | + | templ_7745c5c3_Var67 = templ.NopComponent | |
| 1245 | + | } | |
| 1246 | + | ctx = templ.ClearChildren(ctx) | |
| 1247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<ol class=\"steps\" id=\"job-steps\">") | |
| 1248 | + | if templ_7745c5c3_Err != nil { | |
| 1249 | + | return templ_7745c5c3_Err | |
| 1250 | + | } | |
| 1251 | + | for _, s := range steps { | |
| 1252 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<li>") | |
| 1253 | + | if templ_7745c5c3_Err != nil { | |
| 1254 | + | return templ_7745c5c3_Err | |
| 1255 | + | } | |
| 1256 | + | templ_7745c5c3_Err = CheckIcon(s.Kind, s.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 1257 | + | if templ_7745c5c3_Err != nil { | |
| 1258 | + | return templ_7745c5c3_Err | |
| 1259 | + | } | |
| 1260 | + | if s.LogEnd > s.LogStart { | |
| 1261 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<a href=\"") | |
| 1262 | + | if templ_7745c5c3_Err != nil { | |
| 1263 | + | return templ_7745c5c3_Err | |
| 1264 | + | } | |
| 1265 | + | var templ_7745c5c3_Var68 templ.SafeURL | |
| 1266 | + | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart))) | |
| 1267 | + | if templ_7745c5c3_Err != nil { | |
| 1268 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 55} | |
| 1269 | + | } | |
| 1270 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) | |
| 1271 | + | if templ_7745c5c3_Err != nil { | |
| 1272 | + | return templ_7745c5c3_Err | |
| 1273 | + | } | |
| 1274 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\">") | |
| 1275 | + | if templ_7745c5c3_Err != nil { | |
| 1276 | + | return templ_7745c5c3_Err | |
| 1277 | + | } | |
| 1278 | + | var templ_7745c5c3_Var69 string | |
| 1279 | + | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1280 | + | if templ_7745c5c3_Err != nil { | |
| 1281 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 66} | |
| 1282 | + | } | |
| 1283 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) | |
| 1284 | + | if templ_7745c5c3_Err != nil { | |
| 1285 | + | return templ_7745c5c3_Err | |
| 1286 | + | } | |
| 1287 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</a> ") | |
| 1288 | + | if templ_7745c5c3_Err != nil { | |
| 1289 | + | return templ_7745c5c3_Err | |
| 1290 | + | } | |
| 1291 | + | } else { | |
| 1292 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<span>") | |
| 1293 | + | if templ_7745c5c3_Err != nil { | |
| 1294 | + | return templ_7745c5c3_Err | |
| 1295 | + | } | |
| 1296 | + | var templ_7745c5c3_Var70 string | |
| 1297 | + | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1298 | + | if templ_7745c5c3_Err != nil { | |
| 1299 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 177, Col: 19} | |
| 1300 | + | } | |
| 1301 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1302 | + | if templ_7745c5c3_Err != nil { | |
| 1303 | + | return templ_7745c5c3_Err | |
| 1304 | + | } | |
| 1305 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> ") | |
| 1306 | + | if templ_7745c5c3_Err != nil { | |
| 1307 | + | return templ_7745c5c3_Err | |
| 1308 | + | } | |
| 1309 | + | } | |
| 1310 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<span class=\"dim\">") | |
| 1311 | + | if templ_7745c5c3_Err != nil { | |
| 1312 | + | return templ_7745c5c3_Err | |
| 1313 | + | } | |
| 1314 | + | var templ_7745c5c3_Var71 string | |
| 1315 | + | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 1316 | + | if templ_7745c5c3_Err != nil { | |
| 1317 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 179, Col: 34} | |
| 1318 | + | } | |
| 1319 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) | |
| 1320 | + | if templ_7745c5c3_Err != nil { | |
| 1321 | + | return templ_7745c5c3_Err | |
| 1322 | + | } | |
| 1323 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></li>") | |
| 1324 | + | if templ_7745c5c3_Err != nil { | |
| 1325 | + | return templ_7745c5c3_Err | |
| 1326 | + | } | |
| 1327 | + | } | |
| 1328 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</ol>") | |
| 1329 | + | if templ_7745c5c3_Err != nil { | |
| 1330 | + | return templ_7745c5c3_Err | |
| 1331 | + | } | |
| 1332 | + | return nil | |
| 1333 | + | }) | |
| 1334 | + | } | |
| 1335 | + | ||
| 1336 | + | func Job(l Layout, p JobPage) templ.Component { | |
| 1337 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1338 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1339 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1340 | + | return templ_7745c5c3_CtxErr | |
| 1341 | + | } | |
| 1342 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1343 | + | if !templ_7745c5c3_IsBuffer { | |
| 1344 | + | defer func() { | |
| 1345 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1346 | + | if templ_7745c5c3_Err == nil { | |
| 1347 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1348 | + | } | |
| 1349 | + | }() | |
| 1350 | + | } | |
| 1351 | + | ctx = templ.InitializeContext(ctx) | |
| 1352 | + | templ_7745c5c3_Var72 := templ.GetChildren(ctx) | |
| 1353 | + | if templ_7745c5c3_Var72 == nil { | |
| 1354 | + | templ_7745c5c3_Var72 = templ.NopComponent | |
| 1355 | + | } | |
| 1356 | + | ctx = templ.ClearChildren(ctx) | |
| 1357 | + | templ_7745c5c3_Var73 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1358 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1359 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1360 | + | if !templ_7745c5c3_IsBuffer { | |
| 1361 | + | defer func() { | |
| 1362 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1363 | + | if templ_7745c5c3_Err == nil { | |
| 1364 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1365 | + | } | |
| 1366 | + | }() | |
| 1367 | + | } | |
| 1368 | + | ctx = templ.InitializeContext(ctx) | |
| 1369 | + | templ_7745c5c3_Err = LiveSlot("job-notice").Render(ctx, templ_7745c5c3_Buffer) | |
| 1370 | + | if templ_7745c5c3_Err != nil { | |
| 1371 | + | return templ_7745c5c3_Err | |
| 1372 | + | } | |
| 1373 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ") | |
| 1374 | + | if templ_7745c5c3_Err != nil { | |
| 1375 | + | return templ_7745c5c3_Err | |
| 1376 | + | } | |
| 1377 | + | templ_7745c5c3_Err = JobHead(p.JobRow, p.Total).Render(ctx, templ_7745c5c3_Buffer) | |
| 1378 | + | if templ_7745c5c3_Err != nil { | |
| 1379 | + | return templ_7745c5c3_Err | |
| 1380 | + | } | |
| 1381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " <div id=\"job-log\" class=\"joblog\" data-total=\"") | |
| 1382 | + | if templ_7745c5c3_Err != nil { | |
| 1383 | + | return templ_7745c5c3_Err | |
| 1384 | + | } | |
| 1385 | + | var templ_7745c5c3_Var74 string | |
| 1386 | + | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total)) | |
| 1387 | + | if templ_7745c5c3_Err != nil { | |
| 1388 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 63} | |
| 1389 | + | } | |
| 1390 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74) | |
| 1391 | + | if templ_7745c5c3_Err != nil { | |
| 1392 | + | return templ_7745c5c3_Err | |
| 1393 | + | } | |
| 1394 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"") | |
| 1395 | + | if templ_7745c5c3_Err != nil { | |
| 1396 | + | return templ_7745c5c3_Err | |
| 1397 | + | } | |
| 1398 | + | if p.Follow { | |
| 1399 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " data-follow") | |
| 1400 | + | if templ_7745c5c3_Err != nil { | |
| 1401 | + | return templ_7745c5c3_Err | |
| 1402 | + | } | |
| 1403 | + | } | |
| 1404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " data-stream=\"") | |
| 1405 | + | if templ_7745c5c3_Err != nil { | |
| 1406 | + | return templ_7745c5c3_Err | |
| 1407 | + | } | |
| 1408 | + | var templ_7745c5c3_Var75 string | |
| 1409 | + | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref) | |
| 1410 | + | if templ_7745c5c3_Err != nil { | |
| 1411 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 118} | |
| 1412 | + | } | |
| 1413 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75) | |
| 1414 | + | if templ_7745c5c3_Err != nil { | |
| 1415 | + | return templ_7745c5c3_Err | |
| 1416 | + | } | |
| 1417 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" data-next=\"") | |
| 1418 | + | if templ_7745c5c3_Err != nil { | |
| 1419 | + | return templ_7745c5c3_Err | |
| 1420 | + | } | |
| 1421 | + | var templ_7745c5c3_Var76 string | |
| 1422 | + | templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines)))) | |
| 1423 | + | if templ_7745c5c3_Err != nil { | |
| 1424 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 169} | |
| 1425 | + | } | |
| 1426 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76) | |
| 1427 | + | if templ_7745c5c3_Err != nil { | |
| 1428 | + | return templ_7745c5c3_Err | |
| 1429 | + | } | |
| 1430 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\">") | |
| 1431 | + | if templ_7745c5c3_Err != nil { | |
| 1432 | + | return templ_7745c5c3_Err | |
| 1433 | + | } | |
| 1434 | + | if p.Note != "" { | |
| 1435 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">") | |
| 1436 | + | if templ_7745c5c3_Err != nil { | |
| 1437 | + | return templ_7745c5c3_Err | |
| 1438 | + | } | |
| 1439 | + | var templ_7745c5c3_Var77 string | |
| 1440 | + | templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 1441 | + | if templ_7745c5c3_Err != nil { | |
| 1442 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 191, Col: 27} | |
| 1443 | + | } | |
| 1444 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) | |
| 1445 | + | if templ_7745c5c3_Err != nil { | |
| 1446 | + | return templ_7745c5c3_Err | |
| 1447 | + | } | |
| 1448 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>") | |
| 1449 | + | if templ_7745c5c3_Err != nil { | |
| 1450 | + | return templ_7745c5c3_Err | |
| 1451 | + | } | |
| 1452 | + | } | |
| 1453 | + | if p.Prev != "" { | |
| 1454 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"more\"><a href=\"") | |
| 1455 | + | if templ_7745c5c3_Err != nil { | |
| 1456 | + | return templ_7745c5c3_Err | |
| 1457 | + | } | |
| 1458 | + | var templ_7745c5c3_Var78 templ.SafeURL | |
| 1459 | + | templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev)) | |
| 1460 | + | if templ_7745c5c3_Err != nil { | |
| 1461 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 194, Col: 51} | |
| 1462 | + | } | |
| 1463 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) | |
| 1464 | + | if templ_7745c5c3_Err != nil { | |
| 1465 | + | return templ_7745c5c3_Err | |
| 1466 | + | } | |
| 1467 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" rel=\"prev\">earlier lines</a></p>") | |
| 1468 | + | if templ_7745c5c3_Err != nil { | |
| 1469 | + | return templ_7745c5c3_Err | |
| 1470 | + | } | |
| 1471 | + | } | |
| 1472 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<ol class=\"log\" start=\"") | |
| 1473 | + | if templ_7745c5c3_Err != nil { | |
| 1474 | + | return templ_7745c5c3_Err | |
| 1475 | + | } | |
| 1476 | + | var templ_7745c5c3_Var79 string | |
| 1477 | + | templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1)) | |
| 1478 | + | if templ_7745c5c3_Err != nil { | |
| 1479 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 196, Col: 45} | |
| 1480 | + | } | |
| 1481 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79) | |
| 1482 | + | if templ_7745c5c3_Err != nil { | |
| 1483 | + | return templ_7745c5c3_Err | |
| 1484 | + | } | |
| 1485 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">") | |
| 1486 | + | if templ_7745c5c3_Err != nil { | |
| 1487 | + | return templ_7745c5c3_Err | |
| 1488 | + | } | |
| 1489 | + | for _, ln := range p.Lines { | |
| 1490 | + | templ_7745c5c3_Err = LogLine(ln).Render(ctx, templ_7745c5c3_Buffer) | |
| 1491 | + | if templ_7745c5c3_Err != nil { | |
| 1492 | + | return templ_7745c5c3_Err | |
| 1493 | + | } | |
| 1494 | + | } | |
| 1495 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</ol>") | |
| 1496 | + | if templ_7745c5c3_Err != nil { | |
| 1497 | + | return templ_7745c5c3_Err | |
| 1498 | + | } | |
| 1499 | + | if p.Next != "" { | |
| 1500 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<p class=\"more\"><a href=\"") | |
| 1501 | + | if templ_7745c5c3_Err != nil { | |
| 1502 | + | return templ_7745c5c3_Err | |
| 1503 | + | } | |
| 1504 | + | var templ_7745c5c3_Var80 templ.SafeURL | |
| 1505 | + | templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next)) | |
| 1506 | + | if templ_7745c5c3_Err != nil { | |
| 1507 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 202, Col: 51} | |
| 1508 | + | } | |
| 1509 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) | |
| 1510 | + | if templ_7745c5c3_Err != nil { | |
| 1511 | + | return templ_7745c5c3_Err | |
| 1512 | + | } | |
| 1513 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" rel=\"next\">more lines</a></p>") | |
| 1514 | + | if templ_7745c5c3_Err != nil { | |
| 1515 | + | return templ_7745c5c3_Err | |
| 1516 | + | } | |
| 1517 | + | } | |
| 1518 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</div>") | |
| 1519 | + | if templ_7745c5c3_Err != nil { | |
| 1520 | + | return templ_7745c5c3_Err | |
| 1521 | + | } | |
| 1522 | + | return nil | |
| 1523 | + | }) | |
| 1524 | + | templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var73), templ_7745c5c3_Buffer) | |
| 1525 | + | if templ_7745c5c3_Err != nil { | |
| 1526 | + | return templ_7745c5c3_Err | |
| 1527 | + | } | |
| 1528 | + | return nil | |
| 1529 | + | }) | |
| 1530 | + | } | |
| 1531 | + | ||
| 1532 | + | // LogLine is a line of the log. Group and error markers of the runner show as | |
| 1533 | + | // headings and errors, never as raw text. | |
| 1534 | + | func LogLine(ln LogLineView) templ.Component { | |
| 1535 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1536 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1537 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1538 | + | return templ_7745c5c3_CtxErr | |
| 1539 | + | } | |
| 1540 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1541 | + | if !templ_7745c5c3_IsBuffer { | |
| 1542 | + | defer func() { | |
| 1543 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1544 | + | if templ_7745c5c3_Err == nil { | |
| 1545 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1546 | + | } | |
| 1547 | + | }() | |
| 1548 | + | } | |
| 1549 | + | ctx = templ.InitializeContext(ctx) | |
| 1550 | + | templ_7745c5c3_Var81 := templ.GetChildren(ctx) | |
| 1551 | + | if templ_7745c5c3_Var81 == nil { | |
| 1552 | + | templ_7745c5c3_Var81 = templ.NopComponent | |
| 1553 | + | } | |
| 1554 | + | ctx = templ.ClearChildren(ctx) | |
| 1555 | + | var templ_7745c5c3_Var82 = []any{logClass(ln)} | |
| 1556 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...) | |
| 1557 | + | if templ_7745c5c3_Err != nil { | |
| 1558 | + | return templ_7745c5c3_Err | |
| 1559 | + | } | |
| 1560 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<li id=\"") | |
| 1561 | + | if templ_7745c5c3_Err != nil { | |
| 1562 | + | return templ_7745c5c3_Err | |
| 1563 | + | } | |
| 1564 | + | var templ_7745c5c3_Var83 string | |
| 1565 | + | templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N)) | |
| 1566 | + | if templ_7745c5c3_Err != nil { | |
| 1567 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 28} | |
| 1568 | + | } | |
| 1569 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83) | |
| 1570 | + | if templ_7745c5c3_Err != nil { | |
| 1571 | + | return templ_7745c5c3_Err | |
| 1572 | + | } | |
| 1573 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" class=\"") | |
| 1574 | + | if templ_7745c5c3_Err != nil { | |
| 1575 | + | return templ_7745c5c3_Err | |
| 1576 | + | } | |
| 1577 | + | var templ_7745c5c3_Var84 string | |
| 1578 | + | templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String()) | |
| 1579 | + | if templ_7745c5c3_Err != nil { | |
| 1580 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0} | |
| 1581 | + | } | |
| 1582 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84) | |
| 1583 | + | if templ_7745c5c3_Err != nil { | |
| 1584 | + | return templ_7745c5c3_Err | |
| 1585 | + | } | |
| 1586 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" data-step=\"") | |
| 1587 | + | if templ_7745c5c3_Err != nil { | |
| 1588 | + | return templ_7745c5c3_Err | |
| 1589 | + | } | |
| 1590 | + | var templ_7745c5c3_Var85 string | |
| 1591 | + | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step))) | |
| 1592 | + | if templ_7745c5c3_Err != nil { | |
| 1593 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 84} | |
| 1594 | + | } | |
| 1595 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85) | |
| 1596 | + | if templ_7745c5c3_Err != nil { | |
| 1597 | + | return templ_7745c5c3_Err | |
| 1598 | + | } | |
| 1599 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"") | |
| 1600 | + | if templ_7745c5c3_Err != nil { | |
| 1601 | + | return templ_7745c5c3_Err | |
| 1602 | + | } | |
| 1603 | + | if ln.Continued { | |
| 1604 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, " data-cont") | |
| 1605 | + | if templ_7745c5c3_Err != nil { | |
| 1606 | + | return templ_7745c5c3_Err | |
| 1607 | + | } | |
| 1608 | + | } | |
| 1609 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "><time>") | |
| 1610 | + | if templ_7745c5c3_Err != nil { | |
| 1611 | + | return templ_7745c5c3_Err | |
| 1612 | + | } | |
| 1613 | + | var templ_7745c5c3_Var86 string | |
| 1614 | + | templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time) | |
| 1615 | + | if templ_7745c5c3_Err != nil { | |
| 1616 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 130} | |
| 1617 | + | } | |
| 1618 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) | |
| 1619 | + | if templ_7745c5c3_Err != nil { | |
| 1620 | + | return templ_7745c5c3_Err | |
| 1621 | + | } | |
| 1622 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</time><span class=\"t\">") | |
| 1623 | + | if templ_7745c5c3_Err != nil { | |
| 1624 | + | return templ_7745c5c3_Err | |
| 1625 | + | } | |
| 1626 | + | var templ_7745c5c3_Var87 string | |
| 1627 | + | templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text) | |
| 1628 | + | if templ_7745c5c3_Err != nil { | |
| 1629 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 164} | |
| 1630 | + | } | |
| 1631 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) | |
| 1632 | + | if templ_7745c5c3_Err != nil { | |
| 1633 | + | return templ_7745c5c3_Err | |
| 1634 | + | } | |
| 1635 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span></li>") | |
| 1636 | + | if templ_7745c5c3_Err != nil { | |
| 1637 | + | return templ_7745c5c3_Err | |
| 1638 | + | } | |
| 1639 | + | return nil | |
| 1640 | + | }) | |
| 1641 | + | } | |
| 1642 | + | ||
| 1643 | + | func jobRail(p JobPage) templ.Component { | |
| 1644 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1645 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1646 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1647 | + | return templ_7745c5c3_CtxErr | |
| 1648 | + | } | |
| 1649 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1650 | + | if !templ_7745c5c3_IsBuffer { | |
| 1651 | + | defer func() { | |
| 1652 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1653 | + | if templ_7745c5c3_Err == nil { | |
| 1654 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1655 | + | } | |
| 1656 | + | }() | |
| 1657 | + | } | |
| 1658 | + | ctx = templ.InitializeContext(ctx) | |
| 1659 | + | templ_7745c5c3_Var88 := templ.GetChildren(ctx) | |
| 1660 | + | if templ_7745c5c3_Var88 == nil { | |
| 1661 | + | templ_7745c5c3_Var88 = templ.NopComponent | |
| 1662 | + | } | |
| 1663 | + | ctx = templ.ClearChildren(ctx) | |
| 1664 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>") | |
| 1665 | + | if templ_7745c5c3_Err != nil { | |
| 1666 | + | return templ_7745c5c3_Err | |
| 1667 | + | } | |
| 1668 | + | templ_7745c5c3_Err = JobSteps(p.Steps).Render(ctx, templ_7745c5c3_Buffer) | |
| 1669 | + | if templ_7745c5c3_Err != nil { | |
| 1670 | + | return templ_7745c5c3_Err | |
| 1671 | + | } | |
| 1672 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"") | |
| 1673 | + | if templ_7745c5c3_Err != nil { | |
| 1674 | + | return templ_7745c5c3_Err | |
| 1675 | + | } | |
| 1676 | + | var templ_7745c5c3_Var89 templ.SafeURL | |
| 1677 | + | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref)) | |
| 1678 | + | if templ_7745c5c3_Err != nil { | |
| 1679 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 41} | |
| 1680 | + | } | |
| 1681 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) | |
| 1682 | + | if templ_7745c5c3_Err != nil { | |
| 1683 | + | return templ_7745c5c3_Err | |
| 1684 | + | } | |
| 1685 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">") | |
| 1686 | + | if templ_7745c5c3_Err != nil { | |
| 1687 | + | return templ_7745c5c3_Err | |
| 1688 | + | } | |
| 1689 | + | var templ_7745c5c3_Var90 string | |
| 1690 | + | templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName) | |
| 1691 | + | if templ_7745c5c3_Err != nil { | |
| 1692 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 55} | |
| 1693 | + | } | |
| 1694 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) | |
| 1695 | + | if templ_7745c5c3_Err != nil { | |
| 1696 | + | return templ_7745c5c3_Err | |
| 1697 | + | } | |
| 1698 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</a></dd>") | |
| 1699 | + | if templ_7745c5c3_Err != nil { | |
| 1700 | + | return templ_7745c5c3_Err | |
| 1701 | + | } | |
| 1702 | + | if p.Runner != "" { | |
| 1703 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>runner</dt><dd>") | |
| 1704 | + | if templ_7745c5c3_Err != nil { | |
| 1705 | + | return templ_7745c5c3_Err | |
| 1706 | + | } | |
| 1707 | + | var templ_7745c5c3_Var91 string | |
| 1708 | + | templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner) | |
| 1709 | + | if templ_7745c5c3_Err != nil { | |
| 1710 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 226, Col: 18} | |
| 1711 | + | } | |
| 1712 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) | |
| 1713 | + | if templ_7745c5c3_Err != nil { | |
| 1714 | + | return templ_7745c5c3_Err | |
| 1715 | + | } | |
| 1716 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>") | |
| 1717 | + | if templ_7745c5c3_Err != nil { | |
| 1718 | + | return templ_7745c5c3_Err | |
| 1719 | + | } | |
| 1720 | + | } | |
| 1721 | + | if p.Isolation != "" { | |
| 1722 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>isolation</dt><dd>") | |
| 1723 | + | if templ_7745c5c3_Err != nil { | |
| 1724 | + | return templ_7745c5c3_Err | |
| 1725 | + | } | |
| 1726 | + | var templ_7745c5c3_Var92 string | |
| 1727 | + | templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation) | |
| 1728 | + | if templ_7745c5c3_Err != nil { | |
| 1729 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 230, Col: 21} | |
| 1730 | + | } | |
| 1731 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) | |
| 1732 | + | if templ_7745c5c3_Err != nil { | |
| 1733 | + | return templ_7745c5c3_Err | |
| 1734 | + | } | |
| 1735 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd>") | |
| 1736 | + | if templ_7745c5c3_Err != nil { | |
| 1737 | + | return templ_7745c5c3_Err | |
| 1738 | + | } | |
| 1739 | + | } | |
| 1740 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<dt>lines</dt><dd>") | |
| 1741 | + | if templ_7745c5c3_Err != nil { | |
| 1742 | + | return templ_7745c5c3_Err | |
| 1743 | + | } | |
| 1744 | + | var templ_7745c5c3_Var93 string | |
| 1745 | + | templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total)) | |
| 1746 | + | if templ_7745c5c3_Err != nil { | |
| 1747 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 233, Col: 24} | |
| 1748 | + | } | |
| 1749 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) | |
| 1750 | + | if templ_7745c5c3_Err != nil { | |
| 1751 | + | return templ_7745c5c3_Err | |
| 1752 | + | } | |
| 1753 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</dd></dl><p><a href=\"") | |
| 1754 | + | if templ_7745c5c3_Err != nil { | |
| 1755 | + | return templ_7745c5c3_Err | |
| 1756 | + | } | |
| 1757 | + | var templ_7745c5c3_Var94 templ.SafeURL | |
| 1758 | + | templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref)) | |
| 1759 | + | if templ_7745c5c3_Err != nil { | |
| 1760 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 235, Col: 39} | |
| 1761 | + | } | |
| 1762 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) | |
| 1763 | + | if templ_7745c5c3_Err != nil { | |
| 1764 | + | return templ_7745c5c3_Err | |
| 1765 | + | } | |
| 1766 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">raw log</a></p></section>") | |
| 1767 | + | if templ_7745c5c3_Err != nil { | |
| 1768 | + | return templ_7745c5c3_Err | |
| 1769 | + | } | |
| 1770 | + | return nil | |
| 1771 | + | }) | |
| 1772 | + | } | |
| 1773 | + | ||
| 1774 | + | // CommitChecks is the combined state of the checks of a commit, as a mark | |
| 1775 | + | // with a count, linking to the page that explains it. | |
| 1776 | + | func CommitChecks(c ChecksSummary) templ.Component { | |
| 1777 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1778 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1779 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1780 | + | return templ_7745c5c3_CtxErr | |
| 1781 | + | } | |
| 1782 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1783 | + | if !templ_7745c5c3_IsBuffer { | |
| 1784 | + | defer func() { | |
| 1785 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1786 | + | if templ_7745c5c3_Err == nil { | |
| 1787 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1788 | + | } | |
| 1789 | + | }() | |
| 1790 | + | } | |
| 1791 | + | ctx = templ.InitializeContext(ctx) | |
| 1792 | + | templ_7745c5c3_Var95 := templ.GetChildren(ctx) | |
| 1793 | + | if templ_7745c5c3_Var95 == nil { | |
| 1794 | + | templ_7745c5c3_Var95 = templ.NopComponent | |
| 1795 | + | } | |
| 1796 | + | ctx = templ.ClearChildren(ctx) | |
| 1797 | + | if c.Total > 0 { | |
| 1798 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<a class=\"ck-sum\" href=\"") | |
| 1799 | + | if templ_7745c5c3_Err != nil { | |
| 1800 | + | return templ_7745c5c3_Err | |
| 1801 | + | } | |
| 1802 | + | var templ_7745c5c3_Var96 templ.SafeURL | |
| 1803 | + | templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 1804 | + | if templ_7745c5c3_Err != nil { | |
| 1805 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 48} | |
| 1806 | + | } | |
| 1807 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) | |
| 1808 | + | if templ_7745c5c3_Err != nil { | |
| 1809 | + | return templ_7745c5c3_Err | |
| 1810 | + | } | |
| 1811 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" title=\"") | |
| 1812 | + | if templ_7745c5c3_Err != nil { | |
| 1813 | + | return templ_7745c5c3_Err | |
| 1814 | + | } | |
| 1815 | + | var templ_7745c5c3_Var97 string | |
| 1816 | + | templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title) | |
| 1817 | + | if templ_7745c5c3_Err != nil { | |
| 1818 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 66} | |
| 1819 | + | } | |
| 1820 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var97) | |
| 1821 | + | if templ_7745c5c3_Err != nil { | |
| 1822 | + | return templ_7745c5c3_Err | |
| 1823 | + | } | |
| 1824 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">") | |
| 1825 | + | if templ_7745c5c3_Err != nil { | |
| 1826 | + | return templ_7745c5c3_Err | |
| 1827 | + | } | |
| 1828 | + | templ_7745c5c3_Err = CheckIcon(c.Kind, c.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 1829 | + | if templ_7745c5c3_Err != nil { | |
| 1830 | + | return templ_7745c5c3_Err | |
| 1831 | + | } | |
| 1832 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span class=\"mute\">") | |
| 1833 | + | if templ_7745c5c3_Err != nil { | |
| 1834 | + | return templ_7745c5c3_Err | |
| 1835 | + | } | |
| 1836 | + | var templ_7745c5c3_Var98 string | |
| 1837 | + | templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 1838 | + | if templ_7745c5c3_Err != nil { | |
| 1839 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 245, Col: 31} | |
| 1840 | + | } | |
| 1841 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) | |
| 1842 | + | if templ_7745c5c3_Err != nil { | |
| 1843 | + | return templ_7745c5c3_Err | |
| 1844 | + | } | |
| 1845 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></a>") | |
| 1846 | + | if templ_7745c5c3_Err != nil { | |
| 1847 | + | return templ_7745c5c3_Err | |
| 1848 | + | } | |
| 1849 | + | } | |
| 1850 | + | return nil | |
| 1851 | + | }) | |
| 1852 | + | } | |
| 1853 | + | ||
| 1854 | + | // MRChecks is the checks section of the rail of a merge request. | |
| 1855 | + | func MRChecks(c ChecksSection) templ.Component { | |
| 1856 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1857 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1858 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1859 | + | return templ_7745c5c3_CtxErr | |
| 1860 | + | } | |
| 1861 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1862 | + | if !templ_7745c5c3_IsBuffer { | |
| 1863 | + | defer func() { | |
| 1864 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1865 | + | if templ_7745c5c3_Err == nil { | |
| 1866 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1867 | + | } | |
| 1868 | + | }() | |
| 1869 | + | } | |
| 1870 | + | ctx = templ.InitializeContext(ctx) | |
| 1871 | + | templ_7745c5c3_Var99 := templ.GetChildren(ctx) | |
| 1872 | + | if templ_7745c5c3_Var99 == nil { | |
| 1873 | + | templ_7745c5c3_Var99 = templ.NopComponent | |
| 1874 | + | } | |
| 1875 | + | ctx = templ.ClearChildren(ctx) | |
| 1876 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |
| 1877 | + | if templ_7745c5c3_Err != nil { | |
| 1878 | + | return templ_7745c5c3_Err | |
| 1879 | + | } | |
| 1880 | + | var templ_7745c5c3_Var100 string | |
| 1881 | + | templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |
| 1882 | + | if templ_7745c5c3_Err != nil { | |
| 1883 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 253, Col: 66} | |
| 1884 | + | } | |
| 1885 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) | |
| 1886 | + | if templ_7745c5c3_Err != nil { | |
| 1887 | + | return templ_7745c5c3_Err | |
| 1888 | + | } | |
| 1889 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span></h2>") | |
| 1890 | + | if templ_7745c5c3_Err != nil { | |
| 1891 | + | return templ_7745c5c3_Err | |
| 1892 | + | } | |
| 1893 | + | if len(c.Rows) == 0 { | |
| 1894 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<p class=\"dim\">no checks</p>") | |
| 1895 | + | if templ_7745c5c3_Err != nil { | |
| 1896 | + | return templ_7745c5c3_Err | |
| 1897 | + | } | |
| 1898 | + | } else { | |
| 1899 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<ul class=\"checks\">") | |
| 1900 | + | if templ_7745c5c3_Err != nil { | |
| 1901 | + | return templ_7745c5c3_Err | |
| 1902 | + | } | |
| 1903 | + | for _, r := range c.Rows { | |
| 1904 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li>") | |
| 1905 | + | if templ_7745c5c3_Err != nil { | |
| 1906 | + | return templ_7745c5c3_Err | |
| 1907 | + | } | |
| 1908 | + | templ_7745c5c3_Err = CheckIcon(r.Kind, r.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 1909 | + | if templ_7745c5c3_Err != nil { | |
| 1910 | + | return templ_7745c5c3_Err | |
| 1911 | + | } | |
| 1912 | + | if r.Href != "" { | |
| 1913 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<a href=\"") | |
| 1914 | + | if templ_7745c5c3_Err != nil { | |
| 1915 | + | return templ_7745c5c3_Err | |
| 1916 | + | } | |
| 1917 | + | var templ_7745c5c3_Var101 templ.SafeURL | |
| 1918 | + | templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 1919 | + | if templ_7745c5c3_Err != nil { | |
| 1920 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 38} | |
| 1921 | + | } | |
| 1922 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) | |
| 1923 | + | if templ_7745c5c3_Err != nil { | |
| 1924 | + | return templ_7745c5c3_Err | |
| 1925 | + | } | |
| 1926 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\">") | |
| 1927 | + | if templ_7745c5c3_Err != nil { | |
| 1928 | + | return templ_7745c5c3_Err | |
| 1929 | + | } | |
| 1930 | + | var templ_7745c5c3_Var102 string | |
| 1931 | + | templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1932 | + | if templ_7745c5c3_Err != nil { | |
| 1933 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 49} | |
| 1934 | + | } | |
| 1935 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) | |
| 1936 | + | if templ_7745c5c3_Err != nil { | |
| 1937 | + | return templ_7745c5c3_Err | |
| 1938 | + | } | |
| 1939 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</a> ") | |
| 1940 | + | if templ_7745c5c3_Err != nil { | |
| 1941 | + | return templ_7745c5c3_Err | |
| 1942 | + | } | |
| 1943 | + | } else { | |
| 1944 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>") | |
| 1945 | + | if templ_7745c5c3_Err != nil { | |
| 1946 | + | return templ_7745c5c3_Err | |
| 1947 | + | } | |
| 1948 | + | var templ_7745c5c3_Var103 string | |
| 1949 | + | templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1950 | + | if templ_7745c5c3_Err != nil { | |
| 1951 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 264, Col: 21} | |
| 1952 | + | } | |
| 1953 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) | |
| 1954 | + | if templ_7745c5c3_Err != nil { | |
| 1955 | + | return templ_7745c5c3_Err | |
| 1956 | + | } | |
| 1957 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ") | |
| 1958 | + | if templ_7745c5c3_Err != nil { | |
| 1959 | + | return templ_7745c5c3_Err | |
| 1960 | + | } | |
| 1961 | + | } | |
| 1962 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"dim\">") | |
| 1963 | + | if templ_7745c5c3_Err != nil { | |
| 1964 | + | return templ_7745c5c3_Err | |
| 1965 | + | } | |
| 1966 | + | var templ_7745c5c3_Var104 string | |
| 1967 | + | templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 1968 | + | if templ_7745c5c3_Err != nil { | |
| 1969 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 33} | |
| 1970 | + | } | |
| 1971 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) | |
| 1972 | + | if templ_7745c5c3_Err != nil { | |
| 1973 | + | return templ_7745c5c3_Err | |
| 1974 | + | } | |
| 1975 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</span> ") | |
| 1976 | + | if templ_7745c5c3_Err != nil { | |
| 1977 | + | return templ_7745c5c3_Err | |
| 1978 | + | } | |
| 1979 | + | if r.Waiting { | |
| 1980 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<span class=\"mute\">waiting for a runner</span>") | |
| 1981 | + | if templ_7745c5c3_Err != nil { | |
| 1982 | + | return templ_7745c5c3_Err | |
| 1983 | + | } | |
| 1984 | + | } | |
| 1985 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</li>") | |
| 1986 | + | if templ_7745c5c3_Err != nil { | |
| 1987 | + | return templ_7745c5c3_Err | |
| 1988 | + | } | |
| 1989 | + | } | |
| 1990 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</ul>") | |
| 1991 | + | if templ_7745c5c3_Err != nil { | |
| 1992 | + | return templ_7745c5c3_Err | |
| 1993 | + | } | |
| 1994 | + | } | |
| 1995 | + | if c.Note != "" { | |
| 1996 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<p class=\"mute\">") | |
| 1997 | + | if templ_7745c5c3_Err != nil { | |
| 1998 | + | return templ_7745c5c3_Err | |
| 1999 | + | } | |
| 2000 | + | var templ_7745c5c3_Var105 string | |
| 2001 | + | templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | |
| 2002 | + | if templ_7745c5c3_Err != nil { | |
| 2003 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 275, Col: 27} | |
| 2004 | + | } | |
| 2005 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) | |
| 2006 | + | if templ_7745c5c3_Err != nil { | |
| 2007 | + | return templ_7745c5c3_Err | |
| 2008 | + | } | |
| 2009 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>") | |
| 2010 | + | if templ_7745c5c3_Err != nil { | |
| 2011 | + | return templ_7745c5c3_Err | |
| 2012 | + | } | |
| 2013 | + | } | |
| 2014 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</section>") | |
| 2015 | + | if templ_7745c5c3_Err != nil { | |
| 2016 | + | return templ_7745c5c3_Err | |
| 2017 | + | } | |
| 2018 | + | return nil | |
| 2019 | + | }) | |
| 2020 | + | } | |
| 2021 | + | ||
| 2022 | + | var _ = templruntime.GeneratedTemplate | |
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, Passkey, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, 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. | |
| @@ -57,6 +57,7 @@ var scripts = []struct { | |||
| 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 | 59 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 60 | + | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 62 | 63 | // 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 +33 -29 generated file: collapsed
A internal/adapters/web/passkeys_actions_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Passkeys add an anonymous way to start a session, and the Actions pages add | |
| 19 | + | // POSTs that cancel and re-run runs. The two must not meet: the only POSTs an | |
| 20 | + | // anonymous browser may make are the two passkey login endpoints (and a login | |
| 21 | + | // link), so the run actions still need the Origin of the public URL and a | |
| 22 | + | // session, whether that session came from a link or from a passkey. | |
| 23 | + | func TestPasskeysDoNotOpenTheActionsPosts(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 25 | + | repo := repoName(t, "proj") | |
| 26 | + | runs := mocks.NewRunService(t) // a call the gate should have stopped fails the test | |
| 27 | + | sessions := newSessions(t, "tok") | |
| 28 | + | srv := newTestServer(t, Config{ | |
| 29 | + | Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs, | |
| 30 | + | PublicURL: forgeOrigin, Sessions: sessions, Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{}, | |
| 31 | + | Now: func() time.Time { return fixedNow }, | |
| 32 | + | }) | |
| 33 | + | f := &passkeyFixture{srv: srv} | |
| 34 | + | actions := []string{ | |
| 35 | + | "/proj/actions/runs/1/cancel", "/proj/actions/runs/1/rerun", "/proj/actions/runs/1/rerun-failed", | |
| 36 | + | } | |
| 37 | + | do := func(target, origin, cookie string) *httptest.ResponseRecorder { | |
| 38 | + | return f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/x-www-form-urlencoded"}) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, target := range actions { | |
| 42 | + | // Anonymous, with or without the right Origin, and with a cookie the | |
| 43 | + | // sessions do not know: forbidden, before any use case runs. | |
| 44 | + | for _, tc := range []struct{ origin, cookie string }{ | |
| 45 | + | {forgeOrigin, ""}, {"", ""}, {forgeOrigin, "stale"}, | |
| 46 | + | } { | |
| 47 | + | rec := do(target, tc.origin, tc.cookie) | |
| 48 | + | assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, tc.origin, tc.cookie) | |
| 49 | + | } | |
| 50 | + | // A signed-in browser from another origin: forbidden as well. | |
| 51 | + | for _, origin := range []string{"", "null", "https://evil.example"} { | |
| 52 | + | assert.Equal(t, http.StatusForbidden, do(target, origin, "tok").Code, "%s origin=%q", target, origin) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // The same paths are not passkey endpoints: lookalikes of the anonymous | |
| 57 | + | // ones do not pass either. | |
| 58 | + | for _, target := range []string{"/proj/actions/runs/1/cancel/-/passkeys/login/begin", "/-/passkeys/login/begin/../../proj/actions/runs/1/cancel"} { | |
| 59 | + | assert.NotEqual(t, http.StatusSeeOther, do(target, forgeOrigin, "").Code, target) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // A browser signed in (by a link or by a passkey, the cookie is the same) | |
| 63 | + | // reaches the use case as the owner, once per action. | |
| 64 | + | owner := mock.MatchedBy(func(p domain.Principal) bool { return p.IsOwner() }) | |
| 65 | + | runs.EXPECT().CancelRun(mock.Anything, owner, repo, int64(1)).Return(domain.CIRun{}, nil).Once() | |
| 66 | + | runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), false).Return(domain.CIRun{}, nil).Once() | |
| 67 | + | runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), true).Return(domain.CIRun{}, nil).Once() | |
| 68 | + | for _, target := range actions { | |
| 69 | + | rec := do(target, forgeOrigin, "tok") | |
| 70 | + | assert.Equal(t, http.StatusSeeOther, rec.Code, target) | |
| 71 | + | assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"), target) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // The refusal of the use case still shows as the page of a forbidden action | |
| 76 | + | // for a signed-in browser. | |
| 77 | + | func TestPasskeySessionRefusedByTheRunUseCase(t *testing.T) { | |
| 78 | + | t.Parallel() | |
| 79 | + | repo := repoName(t, "proj") | |
| 80 | + | runs := mocks.NewRunService(t) | |
| 81 | + | runs.EXPECT().CancelRun(mock.Anything, mock.Anything, repo, int64(1)).Return(domain.CIRun{}, domain.ErrForbidden).Once() | |
| 82 | + | srv := newTestServer(t, Config{ | |
| 83 | + | Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs, | |
| 84 | + | PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{}, | |
| 85 | + | }) | |
| 86 | + | f := &passkeyFixture{srv: srv} | |
| 87 | + | rec := f.do(t, req{method: "POST", target: "/proj/actions/runs/1/cancel", origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"}) | |
| 88 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 89 | + | } | |
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
| @@ -23,6 +23,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | 24 | <script src={ l.Assets.Files } defer></script> | |
| 25 | 25 | <script src={ l.Assets.Passkey } defer></script> | |
| 26 | + | <script src={ l.Assets.Log } defer></script> | |
| 26 | 27 | </head> | |
| 27 | 28 | <body | |
| 28 | 29 | data-ref={ l.RefPath } | |
M internal/adapters/web/shell_templ.go +158 -145 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
| @@ -138,6 +138,9 @@ type CommitRow struct { | |||
| 138 | 138 | Branches []string | |
| 139 | 139 | Tags []string | |
| 140 | 140 | Cursor bool | |
| 141 | + | // Checks is the combined state of the CI checks of the commit; empty when | |
| 142 | + | // it has none. | |
| 143 | + | Checks ChecksSummary | |
| 141 | 144 | } | |
| 142 | 145 | ||
| 143 | 146 | // SummaryPage is the repository summary. | |
| @@ -165,6 +168,8 @@ type LatestTag struct { | |||
| 165 | 168 | type RefRow struct { | |
| 166 | 169 | Name, Href, Short, CHref, Subject, When, WhenAt string | |
| 167 | 170 | Default, Cursor bool | |
| 171 | + | // Checks is the combined state of the CI checks of the commit. | |
| 172 | + | Checks ChecksSummary | |
| 168 | 173 | } | |
| 169 | 174 | ||
| 170 | 175 | // 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. | |
| @@ -169,6 +173,17 @@ func (s *Server) routes() *http.ServeMux { | |||
| 169 | 173 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | |
| 170 | 174 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead)) | |
| 171 | 175 | } | |
| 176 | + | if s.cfg.Runs != nil { | |
| 177 | + | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 178 | + | mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions)) | |
| 179 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run)) | |
| 180 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job)) | |
| 181 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog) | |
| 182 | + | mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream) | |
| 183 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel)) | |
| 184 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false))) | |
| 185 | + | mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true))) | |
| 186 | + | } | |
| 172 | 187 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 173 | 188 | return mux | |
| 174 | 189 | } | |
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", "token.branch_prefixes", "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_branch_token_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const everyBranchWorkflow = `name: all | |
| 17 | + | on: | |
| 18 | + | push: | |
| 19 | + | branches: ['**'] | |
| 20 | + | tags: ['v*'] | |
| 21 | + | jobs: | |
| 22 | + | j: | |
| 23 | + | runs-on: ubuntu-latest | |
| 24 | + | steps: | |
| 25 | + | - run: echo hi | |
| 26 | + | ` | |
| 27 | + | ||
| 28 | + | // A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and | |
| 29 | + | // logs is not a write and stays open to whoever can read the repository. | |
| 30 | + | // Cancelling and re-running a run is a write on the ref of the run (it stops or | |
| 31 | + | // repeats the workflow of that ref), so it follows the prefixes: allowed on a | |
| 32 | + | // branch under one, refused on any other branch and on a tag. Runners serve | |
| 33 | + | // every repository and branch, so a limited token never manages them, even with | |
| 34 | + | // runner:register. | |
| 35 | + | func TestCIBranchLimitedToken(t *testing.T) { | |
| 36 | + | c := newCIHarness(t, nil) | |
| 37 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 38 | + | wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow} | |
| 39 | + | ||
| 40 | + | c.push("main", wf, "") | |
| 41 | + | c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 }) | |
| 42 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main") | |
| 43 | + | c.push("agent/claude/x", nil, "") | |
| 44 | + | c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 }) | |
| 45 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch") | |
| 46 | + | c.mustGit(c.wt, "tag", "v1") | |
| 47 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |
| 48 | + | c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 }) | |
| 49 | + | c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 50 | + | ||
| 51 | + | var mainRun, agentRun, tagRun *pb.Run | |
| 52 | + | for _, r := range c.runs() { | |
| 53 | + | switch r.GetRef() { | |
| 54 | + | case "refs/heads/main": | |
| 55 | + | mainRun = r | |
| 56 | + | case "refs/heads/agent/claude/x": | |
| 57 | + | agentRun = r | |
| 58 | + | case "refs/tags/v1": | |
| 59 | + | tagRun = r | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | require.NotNil(t, mainRun) | |
| 63 | + | require.NotNil(t, agentRun) | |
| 64 | + | require.NotNil(t, tagRun) | |
| 65 | + | ||
| 66 | + | all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister) | |
| 67 | + | secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj") | |
| 68 | + | limited := c.api(secret) | |
| 69 | + | // The same token without the limit, to prove the refusals come from it. | |
| 70 | + | unlimited := c.api(c.token(all, "proj")) | |
| 71 | + | cancel := func(api *clients, r *pb.Run) error { | |
| 72 | + | _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()})) | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | rerun := func(api *clients, r *pb.Run) error { | |
| 76 | + | _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()})) | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | t.Run("reads", func(t *testing.T) { | |
| 81 | + | list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"})) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes") | |
| 84 | + | got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()})) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | require.NotEmpty(t, got.Msg.GetJobs()) | |
| 87 | + | log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()})) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | var text string | |
| 90 | + | for log.Receive() { | |
| 91 | + | for _, l := range log.Msg().GetLines() { | |
| 92 | + | text += l.GetText() | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | require.NoError(t, log.Err()) | |
| 96 | + | assert.Contains(t, text, "log of main") | |
| 97 | + | _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()})) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("writes outside the prefixes are refused", func(t *testing.T) { | |
| 102 | + | for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} { | |
| 103 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name) | |
| 104 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name) | |
| 105 | + | } | |
| 106 | + | // Still there: the refused rerun started no new attempt. | |
| 107 | + | assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt()) | |
| 108 | + | // Without the limit the same token is allowed (rerun) or reaches the | |
| 109 | + | // state rule of the run (cancel of a completed run). | |
| 110 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun))) | |
| 111 | + | require.NoError(t, rerun(unlimited, mainRun)) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("writes under the prefixes are allowed", func(t *testing.T) { | |
| 115 | + | require.NoError(t, rerun(limited, agentRun)) | |
| 116 | + | assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt()) | |
| 117 | + | require.NoError(t, cancel(limited, agentRun)) | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | t.Run("runners are out of reach", func(t *testing.T) { | |
| 121 | + | // Not restricted to a repository, so only the branch limit can refuse it. | |
| 122 | + | secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"}) | |
| 123 | + | limited := c.api(secret) | |
| 124 | + | _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{ | |
| 125 | + | Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true, | |
| 126 | + | })) | |
| 127 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register") | |
| 128 | + | _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 129 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list") | |
| 130 | + | _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id})) | |
| 131 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove") | |
| 132 | + | ||
| 133 | + | // The same scopes without the limit manage runners (not restricted to a | |
| 134 | + | // repository, which the runner rules already demand). | |
| 135 | + | open := c.api(c.token(scopes(domain.ScopeRunnerRegister))) | |
| 136 | + | list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered") | |
| 139 | + | }) | |
| 140 | + | } | |
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.go +69 -10
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "os" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "text/tabwriter" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -30,10 +31,28 @@ type cliEnv struct { | |||
| 30 | 31 | // defaults. | |
| 31 | 32 | backoff client.Backoff | |
| 32 | 33 | sleep func(context.Context, time.Duration) error | |
| 34 | + | ||
| 35 | + | // configPath returns the CLI config file path; nil means the default | |
| 36 | + | // (ERMITE_CONFIG, else ~/.config/ermite/config). | |
| 37 | + | configPath func() (string, error) | |
| 38 | + | // runTokenCmd runs the token.cmd command line and returns its raw standard | |
| 39 | + | // output. Tests replace it. | |
| 40 | + | runTokenCmd func(ctx context.Context, cmd string) (string, error) | |
| 41 | + | ||
| 42 | + | // The config file is read at most once and token.cmd run at most once per | |
| 43 | + | // process, and only when a command needs them (see cli_configfile.go). | |
| 44 | + | fileOnce sync.Once | |
| 45 | + | file *cliFile | |
| 46 | + | filePath string | |
| 47 | + | fileTrust error // why token.cmd must not run from this file, if so | |
| 48 | + | fileErr error | |
| 49 | + | tokenOnce sync.Once | |
| 50 | + | token string | |
| 51 | + | tokenErr error | |
| 33 | 52 | } | |
| 34 | 53 | ||
| 35 | 54 | func defaultCLIEnv() *cliEnv { | |
| 36 | - | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()} | |
| 55 | + | return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell} | |
| 37 | 56 | } | |
| 38 | 57 | ||
| 39 | 58 | // userAgent identifies this CLI to the server. | |
| @@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context { | |||
| 54 | 73 | return ctx | |
| 55 | 74 | } | |
| 56 | 75 | ||
| 57 | - | // config reads and validates ERMITE_URL and ERMITE_TOKEN. | |
| 58 | - | func (e *cliEnv) config() (client.Config, error) { | |
| 59 | - | cfg, err := client.ConfigFromEnv(e.getenv) | |
| 76 | + | // buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN | |
| 77 | + | // win, then the config file (url, and the output of token.cmd for the token). | |
| 78 | + | // runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for | |
| 79 | + | // the calls that need no authentication. | |
| 80 | + | func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) { | |
| 81 | + | rawURL, err := e.resolveURL(cmd, warn) | |
| 60 | 82 | if err != nil { | |
| 61 | - | return cfg, err | |
| 83 | + | return client.Config{}, err | |
| 84 | + | } | |
| 85 | + | if strings.TrimSpace(rawURL) != "" { | |
| 86 | + | if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil { | |
| 87 | + | return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | // A missing or invalid URL is reported before token.cmd gets to prompt. | |
| 91 | + | cfg, err := client.NewConfig(rawURL, "") | |
| 92 | + | if err != nil { | |
| 93 | + | return client.Config{}, err | |
| 94 | + | } | |
| 95 | + | cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken)) | |
| 96 | + | if cfg.Token == "" && runTokenCmd { | |
| 97 | + | if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil { | |
| 98 | + | return client.Config{}, err | |
| 99 | + | } | |
| 62 | 100 | } | |
| 63 | 101 | cfg.UserAgent = e.userAgent() | |
| 64 | 102 | return cfg, nil | |
| 65 | 103 | } | |
| 66 | 104 | ||
| 67 | - | // client builds the API client. Commands warn when the token would travel in | |
| 68 | - | // the clear; completion stays silent. | |
| 69 | - | func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 70 | - | cfg, err := e.config() | |
| 105 | + | // client builds the API client. It warns when the token would travel in the | |
| 106 | + | // clear. | |
| 107 | + | func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) { | |
| 108 | + | cfg, err := e.buildConfig(cmd, true, true) | |
| 71 | 109 | if err != nil { | |
| 72 | 110 | return nil, err | |
| 73 | 111 | } | |
| 112 | + | return newClient(cmd, cfg, true) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still | |
| 116 | + | // used), for GetVersion, which is anonymous, and for completions. | |
| 117 | + | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 118 | + | cfg, err := e.buildConfig(cmd, warn, false) | |
| 119 | + | if err != nil { | |
| 120 | + | return nil, err | |
| 121 | + | } | |
| 122 | + | return newClient(cmd, cfg, warn) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) { | |
| 74 | 126 | if w := cfg.InsecureTokenWarning(); w != "" && warn { | |
| 75 | 127 | warnf(cmd, "%s", w) | |
| 76 | 128 | } | |
| @@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string, | |||
| 128 | 180 | } | |
| 129 | 181 | } | |
| 130 | 182 | ||
| 183 | + | // completionClient is the client of the shell completions: silent, and | |
| 184 | + | // anonymous unless ERMITE_TOKEN is set, because completing must never run | |
| 185 | + | // token.cmd (it may prompt, and a completion script cannot answer). | |
| 186 | + | func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) { | |
| 187 | + | return e.clientNoTokenCmd(cmd, false) | |
| 188 | + | } | |
| 189 | + | ||
| 131 | 190 | // complete builds a shell completion: it opens a client (silently giving up | |
| 132 | 191 | // on any failure), lists with a 3 s timeout and keeps the entries whose | |
| 133 | 192 | // first element starts with the prefix. | |
| 134 | 193 | func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string, | |
| 135 | 194 | list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string), | |
| 136 | 195 | ) []string { | |
| 137 | - | c, err := env.client(cmd, false) | |
| 196 | + | c, err := env.completionClient(cmd) | |
| 138 | 197 | if err != nil { | |
| 139 | 198 | return nil | |
| 140 | 199 | } | |
A internal/app/cli_config.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io/fs" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/kvconf" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // notSet is what `config show` prints for a key without a value. | |
| 19 | + | const notSet = "(not set)" | |
| 20 | + | ||
| 21 | + | // configKeys are the keys of the CLI config file. | |
| 22 | + | var configKeys = []string{keyURL, keyTokenCmd} | |
| 23 | + | ||
| 24 | + | func newConfigCmd(env *cliEnv) *cobra.Command { | |
| 25 | + | cmd := &cobra.Command{ | |
| 26 | + | Use: "config", | |
| 27 | + | Short: "Show and edit the config file of the remote commands", | |
| 28 | + | Long: "The remote commands read the server address and a way to get a token from a config file,\n" + | |
| 29 | + | "~/.config/ermite/config (the config directory of the platform on macOS and Windows; or the file\n" + | |
| 30 | + | "named by ERMITE_CONFIG; `ermite config show` prints the path in use):\n\n" + | |
| 31 | + | " url = https://git.example.com\n" + | |
| 32 | + | " token.cmd = pass show ermite/token\n\n" + | |
| 33 | + | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |
| 34 | + | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |
| 35 | + | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |
| 36 | + | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + | |
| 37 | + | "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" + | |
| 38 | + | "notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" + | |
| 39 | + | "(completion stays anonymous unless ERMITE_TOKEN is set).\n\n" + | |
| 40 | + | "token.cmd is also not run when the file belongs to another user, or when the file or its directory\n" + | |
| 41 | + | "can be written by other users (Unix); `ermite config show` and `set` warn about it. On Windows\n" + | |
| 42 | + | "this check does not exist: keep the file in your own profile (the default place).", | |
| 43 | + | } | |
| 44 | + | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |
| 45 | + | return cmd | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // jsonConfig is the stable JSON shape of `ermite config show --json`. | |
| 49 | + | type jsonConfig struct { | |
| 50 | + | File string `json:"file"` | |
| 51 | + | URL *string `json:"url"` | |
| 52 | + | TokenCmd *string `json:"token_cmd"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func newConfigShowCmd(env *cliEnv) *cobra.Command { | |
| 56 | + | cmd := &cobra.Command{ | |
| 57 | + | Use: "show", | |
| 58 | + | Short: "Print the config file path and its settings", | |
| 59 | + | Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" + | |
| 60 | + | "absent). It never runs token.cmd, and warns when token.cmd would be refused (file permissions).\n\n" + | |
| 61 | + | "JSON fields (--json): file; url and token_cmd, null when not set.", | |
| 62 | + | Args: cobra.NoArgs, | |
| 63 | + | } | |
| 64 | + | asJSON := addJSONFlag(cmd) | |
| 65 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 66 | + | f, path, err := env.readFile(cmd, true) | |
| 67 | + | if path == "" { | |
| 68 | + | p, perr := env.path() | |
| 69 | + | if perr != nil { | |
| 70 | + | return perr | |
| 71 | + | } | |
| 72 | + | path = p | |
| 73 | + | } | |
| 74 | + | if err != nil { | |
| 75 | + | // Say which file is unreadable. | |
| 76 | + | _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}}) | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" { | |
| 80 | + | noticef(cmd, "%s is set and takes precedence over url", client.EnvURL) | |
| 81 | + | } | |
| 82 | + | if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" { | |
| 83 | + | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |
| 84 | + | } | |
| 85 | + | if f.TokenCmd != "" && env.fileTrust != nil { | |
| 86 | + | warnf(cmd, "%s", env.fileTrust) | |
| 87 | + | } | |
| 88 | + | if *asJSON { | |
| 89 | + | return writeJSON(cmd.OutOrStdout(), jsonConfig{File: path, URL: nilIfBlank(f.URL), TokenCmd: nilIfBlank(f.TokenCmd)}) | |
| 90 | + | } | |
| 91 | + | return fields(cmd.OutOrStdout(), [][2]string{ | |
| 92 | + | {"file", path}, | |
| 93 | + | {keyURL, orNotSet(f.URL)}, | |
| 94 | + | {keyTokenCmd, orNotSet(f.TokenCmd)}, | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | return cmd | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func nilIfBlank(s string) *string { | |
| 101 | + | if strings.TrimSpace(s) == "" { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | return &s | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func orNotSet(s string) string { | |
| 108 | + | if strings.TrimSpace(s) == "" { | |
| 109 | + | return notSet | |
| 110 | + | } | |
| 111 | + | return s | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func newConfigSetCmd(env *cliEnv) *cobra.Command { | |
| 115 | + | return &cobra.Command{ | |
| 116 | + | Use: "set <url|token.cmd> <value>", | |
| 117 | + | Short: "Set the server address or the token command in the config file", | |
| 118 | + | Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" + | |
| 119 | + | "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" + | |
| 120 | + | "token, for example `pass show ermite/token`; it is stored as is and not run.", | |
| 121 | + | Args: cobra.ExactArgs(2), | |
| 122 | + | ValidArgsFunction: completeConfigKeys, | |
| 123 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 124 | + | key, value := args[0], strings.TrimSpace(args[1]) | |
| 125 | + | if err := checkConfigKey(key); err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | switch key { | |
| 129 | + | case keyURL: | |
| 130 | + | u, err := client.ParseURL(value) | |
| 131 | + | if err != nil { | |
| 132 | + | return fmt.Errorf("invalid url: %w", err) | |
| 133 | + | } | |
| 134 | + | value = u.String() | |
| 135 | + | case keyTokenCmd: | |
| 136 | + | if value == "" { | |
| 137 | + | return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it") | |
| 138 | + | } | |
| 139 | + | if strings.ContainsAny(value, "\r\n") { | |
| 140 | + | return errors.New("token.cmd must be a single line") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | path, err := env.path() | |
| 144 | + | if err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil { | |
| 148 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 149 | + | } | |
| 150 | + | noticef(cmd, "%s set in %s", key, path) | |
| 151 | + | // Say now, not at the first command, that token.cmd would be refused. | |
| 152 | + | if f, _, trust, err := loadConfigFile(path); err == nil && trust != nil && f.TokenCmd != "" { | |
| 153 | + | warnf(cmd, "%s", trust) | |
| 154 | + | } | |
| 155 | + | return nil | |
| 156 | + | }, | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func newConfigUnsetCmd(env *cliEnv) *cobra.Command { | |
| 161 | + | return &cobra.Command{ | |
| 162 | + | Use: "unset <url|token.cmd>", | |
| 163 | + | Short: "Remove a setting from the config file", | |
| 164 | + | Args: cobra.ExactArgs(1), | |
| 165 | + | ValidArgsFunction: completeConfigKeys, | |
| 166 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 167 | + | key := args[0] | |
| 168 | + | if err := checkConfigKey(key); err != nil { | |
| 169 | + | return err | |
| 170 | + | } | |
| 171 | + | path, err := env.path() | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | data, err := kvconf.ReadFile(path, kvconf.Options{}) | |
| 176 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 177 | + | noticef(cmd, "%s is not set: %s does not exist", key, path) | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | if err != nil { | |
| 181 | + | return fmt.Errorf("reading %s: %w", path, err) | |
| 182 | + | } | |
| 183 | + | entries, _, err := kvconf.ParseBytes(data, kvconf.Options{}) | |
| 184 | + | if err != nil { | |
| 185 | + | return fmt.Errorf("reading %s: %w", path, err) | |
| 186 | + | } | |
| 187 | + | if !slices.ContainsFunc(entries, func(e kvconf.Entry) bool { return e.Key == key }) { | |
| 188 | + | noticef(cmd, "%s is not set", key) | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil { | |
| 192 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 193 | + | } | |
| 194 | + | noticef(cmd, "%s removed from %s", key, path) | |
| 195 | + | return nil | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func checkConfigKey(key string) error { | |
| 201 | + | for _, k := range configKeys { | |
| 202 | + | if key == k { | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and ")) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { | |
| 210 | + | if len(args) > 0 { | |
| 211 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 212 | + | } | |
| 213 | + | return configKeys, cobra.ShellCompDirectiveNoFileComp | |
| 214 | + | } | |
A internal/app/cli_config_ci_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // The RunService calls of the `run` and `runner` commands answer with empty | |
| 16 | + | // results: these tests look at which address and token reach the server, not | |
| 17 | + | // at the output. | |
| 18 | + | func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) { | |
| 19 | + | return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) { | |
| 20 | + | return &ermite.ListRunsResponse{}, nil | |
| 21 | + | }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) { | |
| 25 | + | return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) { | |
| 26 | + | return &ermite.CancelRunResponse{}, nil | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) { | |
| 31 | + | return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) { | |
| 32 | + | return &ermite.RerunRunResponse{}, nil | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | |
| 37 | + | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | |
| 38 | + | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) { | |
| 43 | + | return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) { | |
| 44 | + | return &ermite.RemoveRunnerResponse{}, nil | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // The `run` and `runner` commands read the server address and the token like | |
| 49 | + | // every other remote command: the config file (url, token.cmd), with | |
| 50 | + | // ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the | |
| 51 | + | // file's own url. | |
| 52 | + | func TestCIcommandsUseTheConfigFile(t *testing.T) { | |
| 53 | + | commands := map[string][]string{ | |
| 54 | + | "run list": {"run", "list", "proj"}, | |
| 55 | + | "run cancel": {"run", "cancel", "proj", "1"}, | |
| 56 | + | "run rerun": {"run", "rerun", "proj", "1"}, | |
| 57 | + | "runner list": {"runner", "list"}, | |
| 58 | + | "runner remove": {"runner", "remove", "r1"}, | |
| 59 | + | } | |
| 60 | + | for name, args := range commands { | |
| 61 | + | t.Run(name, func(t *testing.T) { | |
| 62 | + | f := &fakeAPI{} | |
| 63 | + | url := f.serve(t) | |
| 64 | + | ||
| 65 | + | // url and token.cmd from the file | |
| 66 | + | env := testEnv(t, nil) | |
| 67 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 68 | + | ran := tokenCmdStub(env, "ermite_cmd\n", nil) | |
| 69 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 70 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 71 | + | assert.Len(t, *ran, 1) | |
| 72 | + | ||
| 73 | + | // ERMITE_TOKEN wins and token.cmd stays idle | |
| 74 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 75 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 76 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |
| 77 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 78 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 79 | + | assert.Empty(t, *ran) | |
| 80 | + | ||
| 81 | + | // another ERMITE_URL never receives the file's token | |
| 82 | + | other := &fakeAPI{} | |
| 83 | + | env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)}) | |
| 84 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 85 | + | ran = tokenCmdStub(env, "ermite_cmd", nil) | |
| 86 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 87 | + | assert.Empty(t, lastAuth(other)) | |
| 88 | + | assert.Empty(t, *ran) | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Completing a repository name for `run` or a runner id for `runner remove` | |
| 94 | + | // must not run token.cmd: it may prompt, and a completion script cannot answer. | |
| 95 | + | func TestCICompletionNeverRunsTokenCmd(t *testing.T) { | |
| 96 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 97 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |
| 98 | + | }} | |
| 99 | + | url := f.serve(t) | |
| 100 | + | for name, args := range map[string][]string{ | |
| 101 | + | "run list": {"__complete", "run", "list", ""}, | |
| 102 | + | "run cancel": {"__complete", "run", "cancel", ""}, | |
| 103 | + | "runner remove": {"__complete", "runner", "remove", ""}, | |
| 104 | + | } { | |
| 105 | + | t.Run(name, func(t *testing.T) { | |
| 106 | + | env := testEnv(t, nil) | |
| 107 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 108 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 109 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 110 | + | assert.Empty(t, *ran) | |
| 111 | + | assert.Empty(t, lastAuth(f)) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
A internal/app/cli_config_test.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestConfigShowWithoutFile(t *testing.T) { | |
| 20 | + | env := testEnv(t, nil) | |
| 21 | + | path, _ := env.configPath() | |
| 22 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |
| 23 | + | assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout) | |
| 24 | + | _, err := os.Stat(path) | |
| 25 | + | assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestConfigSetUnsetShowRoundTrip(t *testing.T) { | |
| 29 | + | env := testEnv(t, nil) | |
| 30 | + | path, _ := env.configPath() | |
| 31 | + | writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600) | |
| 32 | + | ||
| 33 | + | runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t) | |
| 34 | + | runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t) | |
| 35 | + | // a fresh process sees the new content | |
| 36 | + | res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 37 | + | assert.Contains(t, res.stdout, "url https://git.example.com\n") | |
| 38 | + | assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n") | |
| 39 | + | ||
| 40 | + | data, err := os.ReadFile(path) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | assert.Contains(t, string(data), "# my forge\n") | |
| 43 | + | assert.Contains(t, string(data), "# secrets stay in pass\n") | |
| 44 | + | ||
| 45 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t) | |
| 46 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t) | |
| 47 | + | data, err = os.ReadFile(path) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.NotContains(t, string(data), "token.cmd") | |
| 50 | + | assert.NotContains(t, string(data), "url") | |
| 51 | + | assert.Contains(t, string(data), "# my forge\n", "comments survive") | |
| 52 | + | ||
| 53 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 54 | + | assert.Contains(t, res.stdout, "url (not set)\n") | |
| 55 | + | assert.Contains(t, res.stdout, "token.cmd (not set)\n") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // testEnvAt is testEnv with the config file at path: a new process. | |
| 59 | + | func testEnvAt(t *testing.T, path string) *cliEnv { | |
| 60 | + | t.Helper() | |
| 61 | + | env := testEnv(t, nil) | |
| 62 | + | env.configPath = func() (string, error) { return path, nil } | |
| 63 | + | return env | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestConfigSetCreatesPrivateFile(t *testing.T) { | |
| 67 | + | env := testEnv(t, nil) | |
| 68 | + | path, _ := env.configPath() | |
| 69 | + | res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t) | |
| 70 | + | assert.Contains(t, res.stderr, path) | |
| 71 | + | info, err := os.Stat(path) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | if runtime.GOOS != "windows" { | |
| 74 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 75 | + | } | |
| 76 | + | data, _ := os.ReadFile(path) | |
| 77 | + | assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data)) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestConfigSetRefusesBadInput(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | args []string | |
| 84 | + | want string | |
| 85 | + | }{ | |
| 86 | + | {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`}, | |
| 87 | + | {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`}, | |
| 88 | + | {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"}, | |
| 89 | + | {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"}, | |
| 90 | + | {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"}, | |
| 91 | + | {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"}, | |
| 92 | + | {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"}, | |
| 93 | + | {"missing value", []string{"set", "url"}, "accepts 2 arg"}, | |
| 94 | + | } | |
| 95 | + | for _, tt := range tests { | |
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |
| 97 | + | env := testEnv(t, nil) | |
| 98 | + | path, _ := env.configPath() | |
| 99 | + | res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...) | |
| 100 | + | require.Error(t, res.err) | |
| 101 | + | assert.Contains(t, res.err.Error(), tt.want) | |
| 102 | + | assert.NotContains(t, res.err.Error(), "s3cret") | |
| 103 | + | _, err := os.Stat(path) | |
| 104 | + | assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written") | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestConfigUnsetWithoutFile(t *testing.T) { | |
| 110 | + | env := testEnv(t, nil) | |
| 111 | + | path, _ := env.configPath() | |
| 112 | + | res := runCLIWith(t, env, "", "config", "unset", "url").ok(t) | |
| 113 | + | assert.Contains(t, res.stderr, "does not exist") | |
| 114 | + | _, err := os.Stat(path) | |
| 115 | + | assert.ErrorIs(t, err, os.ErrNotExist) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) { | |
| 119 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"}) | |
| 120 | + | writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666) | |
| 121 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs | |
| 122 | + | assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored") | |
| 123 | + | assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence") | |
| 124 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used") | |
| 125 | + | assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestHelpDocumentsTheConfigFileContract(t *testing.T) { | |
| 129 | + | root := runCLI(t, nil, "", "--help").ok(t).stdout | |
| 130 | + | cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout | |
| 131 | + | for name, help := range map[string]string{"root": root, "config": cfg} { | |
| 132 | + | assert.Contains(t, help, "ermite/config", "%s help names the file", name) | |
| 133 | + | assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name) | |
| 134 | + | assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name) | |
| 135 | + | assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name) | |
| 136 | + | } | |
| 137 | + | assert.Contains(t, cfg, "token.cmd = ", "the config help has an example") | |
| 138 | + | assert.Contains(t, cfg, "Windows", "the config help states the Windows limit") | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestConfigShowNamesTheFileItCannotRead(t *testing.T) { | |
| 142 | + | env := testEnv(t, nil) | |
| 143 | + | path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600) | |
| 144 | + | require.NoError(t, os.Remove(path)) | |
| 145 | + | require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file | |
| 146 | + | res := runCLIWith(t, env, "", "config", "show") | |
| 147 | + | require.Error(t, res.err) | |
| 148 | + | assert.Contains(t, res.stdout, path) | |
| 149 | + | assert.Contains(t, res.err.Error(), "config:") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestConfigUnsetOfAnAbsentKey(t *testing.T) { | |
| 153 | + | env := testEnv(t, nil) | |
| 154 | + | path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600) | |
| 155 | + | res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t) | |
| 156 | + | assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr) | |
| 157 | + | data, err := os.ReadFile(path) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestConfigShowJSON(t *testing.T) { | |
| 163 | + | env := testEnv(t, nil) | |
| 164 | + | path, _ := env.configPath() | |
| 165 | + | res := runCLIWith(t, env, "", "config", "show", "--json").ok(t) | |
| 166 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout) | |
| 167 | + | ||
| 168 | + | env = testEnv(t, nil) | |
| 169 | + | path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600) | |
| 170 | + | res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs | |
| 171 | + | assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) { | |
| 175 | + | if runtime.GOOS == "windows" { | |
| 176 | + | t.Skip("no Unix permission bits") | |
| 177 | + | } | |
| 178 | + | env := testEnv(t, nil) | |
| 179 | + | path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666) | |
| 180 | + | ||
| 181 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |
| 182 | + | assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run") | |
| 183 | + | ||
| 184 | + | // a fresh process; set keeps the mode of an existing file | |
| 185 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t) | |
| 186 | + | assert.Contains(t, res.stderr, "writable by others") | |
| 187 | + | ||
| 188 | + | // no warning without a token.cmd, or with a private file | |
| 189 | + | env = testEnv(t, nil) | |
| 190 | + | writeConfigFile(t, env, "url = https://git.example.com\n", 0o666) | |
| 191 | + | assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning") | |
| 192 | + | res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t) | |
| 193 | + | assert.NotContains(t, res.stderr, "warning") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) { | |
| 197 | + | xdg := t.TempDir() | |
| 198 | + | t.Setenv("XDG_CONFIG_HOME", xdg) | |
| 199 | + | t.Setenv("ERMITE_CONFIG", "") | |
| 200 | + | f := listFake() | |
| 201 | + | ||
| 202 | + | env := defaultCLIEnv() // the real path resolution, the real environment | |
| 203 | + | res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t) | |
| 204 | + | want := filepath.Join(xdg, "ermite", "config") | |
| 205 | + | assert.Contains(t, res.stderr, want) | |
| 206 | + | info, err := os.Stat(want) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | if runtime.GOOS != "windows" { | |
| 209 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | env = defaultCLIEnv() | |
| 213 | + | env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") } | |
| 214 | + | t.Setenv("ERMITE_URL", "") | |
| 215 | + | t.Setenv("ERMITE_TOKEN", "") | |
| 216 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 217 | + | assert.Equal(t, 1, callCount(f, "ListRepos")) | |
| 218 | + | ||
| 219 | + | // ERMITE_CONFIG wins over the default | |
| 220 | + | other := filepath.Join(t.TempDir(), "other") | |
| 221 | + | t.Setenv("ERMITE_CONFIG", other) | |
| 222 | + | res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t) | |
| 223 | + | assert.Contains(t, res.stdout, other) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func callCount(f *fakeAPI, name string) int { | |
| 227 | + | calls, _ := f.snapshot() | |
| 228 | + | n := 0 | |
| 229 | + | for _, c := range calls { | |
| 230 | + | if c == name { | |
| 231 | + | n++ | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | return n | |
| 235 | + | } | |
A internal/app/cli_configfile.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "io/fs" | |
| 12 | + | "os" | |
| 13 | + | "os/exec" | |
| 14 | + | "os/signal" | |
| 15 | + | "strings" | |
| 16 | + | "syscall" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/kvconf" | |
| 20 | + | "github.com/spf13/cobra" | |
| 21 | + | "golang.org/x/term" | |
| 22 | + | ||
| 23 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | // envConfig selects the CLI config file instead of ~/.config/ermite/config. | |
| 28 | + | envConfig = "ERMITE_CONFIG" | |
| 29 | + | // Keys of the CLI config file. | |
| 30 | + | keyURL = "url" | |
| 31 | + | keyTokenCmd = "token.cmd" | |
| 32 | + | ||
| 33 | + | // tokenCmdTimeout bounds one run of token.cmd, prompts included. | |
| 34 | + | tokenCmdTimeout = 60 * time.Second | |
| 35 | + | // tokenCmdMaxOutput bounds what is read from its standard output. | |
| 36 | + | tokenCmdMaxOutput = 4 << 10 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // cliFile is the CLI config file. It holds no secret: the token is fetched | |
| 40 | + | // from a secret manager by the token.cmd command line. | |
| 41 | + | type cliFile struct { | |
| 42 | + | URL string `kv:"url"` | |
| 43 | + | TokenCmd string `kv:"token.cmd"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config. | |
| 47 | + | func (e *cliEnv) path() (string, error) { | |
| 48 | + | if e.configPath != nil { | |
| 49 | + | return e.configPath() | |
| 50 | + | } | |
| 51 | + | if p := strings.TrimSpace(e.getenv(envConfig)); p != "" { | |
| 52 | + | return p, nil | |
| 53 | + | } | |
| 54 | + | p, err := kvconf.DefaultPath("ermite", "config") | |
| 55 | + | if err != nil { | |
| 56 | + | return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig) | |
| 57 | + | } | |
| 58 | + | return p, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // readFile reads the config file, once per process. A missing file, or no | |
| 62 | + | // place to look for it, gives an empty one. Problems in the file are warnings | |
| 63 | + | // on stderr, printed once, and only when warn is set. The path is returned | |
| 64 | + | // even when reading fails. | |
| 65 | + | // | |
| 66 | + | // The file is opened once: what is parsed and what checkPrivate vets (see | |
| 67 | + | // fetchToken) are the same file, so token.cmd cannot come from a file that was | |
| 68 | + | // swapped after the check. | |
| 69 | + | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |
| 70 | + | e.fileOnce.Do(func() { | |
| 71 | + | p, err := e.path() | |
| 72 | + | if err != nil { | |
| 73 | + | // Reading is best effort when the location is unknown; the | |
| 74 | + | // `ermite config` commands report this error. | |
| 75 | + | e.file = &cliFile{} | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | e.filePath = p | |
| 79 | + | f, warnings, trust, err := loadConfigFile(p) | |
| 80 | + | if err != nil { | |
| 81 | + | e.fileErr = fmt.Errorf("config: %w", err) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | e.fileTrust = trust | |
| 85 | + | if warn { | |
| 86 | + | for _, w := range warnings { | |
| 87 | + | warnf(cmd, "config: %s", w) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | e.file = f | |
| 91 | + | }) | |
| 92 | + | return e.file, e.filePath, e.fileErr | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // loadConfigFile parses the file at path (missing: empty). trust is nil when | |
| 96 | + | // token.cmd may be run from this file, else the reason why not. | |
| 97 | + | func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) { | |
| 98 | + | h, err := openConfig(path) | |
| 99 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 100 | + | return &cliFile{}, nil, nil, nil | |
| 101 | + | } | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, nil, nil, err | |
| 104 | + | } | |
| 105 | + | defer h.Close() //nolint:errcheck // read only | |
| 106 | + | info, err := h.Stat() | |
| 107 | + | if err != nil { | |
| 108 | + | return nil, nil, nil, err | |
| 109 | + | } | |
| 110 | + | if !info.Mode().IsRegular() { | |
| 111 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular) | |
| 112 | + | } | |
| 113 | + | trust = checkPrivate(path, info, os.Geteuid()) | |
| 114 | + | data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1)) | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, nil, nil, err | |
| 117 | + | } | |
| 118 | + | if len(data) > kvconf.DefaultMaxBytes { | |
| 119 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge) | |
| 120 | + | } | |
| 121 | + | f = &cliFile{} | |
| 122 | + | warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{}) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, err) | |
| 125 | + | } | |
| 126 | + | return f, warnings, trust, nil | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // urlSource says where the server address in use comes from, for error messages. | |
| 130 | + | func (e *cliEnv) urlSource() string { | |
| 131 | + | if strings.TrimSpace(e.getenv(client.EnvURL)) != "" { | |
| 132 | + | return client.EnvURL | |
| 133 | + | } | |
| 134 | + | return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |
| 138 | + | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |
| 139 | + | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |
| 140 | + | return v, nil | |
| 141 | + | } | |
| 142 | + | f, _, err := e.readFile(cmd, warn) | |
| 143 | + | if err != nil { | |
| 144 | + | return "", err | |
| 145 | + | } | |
| 146 | + | return strings.TrimSpace(f.URL), nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none or | |
| 150 | + | // when ERMITE_URL points somewhere else than the url of the file: the token | |
| 151 | + | // the command fetches belongs to the server of the file, and must not be sent | |
| 152 | + | // to another one. | |
| 153 | + | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |
| 154 | + | f, _, err := e.readFile(cmd, warn) | |
| 155 | + | if err != nil { | |
| 156 | + | return "", err | |
| 157 | + | } | |
| 158 | + | line := strings.TrimSpace(f.TokenCmd) | |
| 159 | + | if line == "" { | |
| 160 | + | return "", nil | |
| 161 | + | } | |
| 162 | + | e.tokenOnce.Do(func() { | |
| 163 | + | if !e.urlIsFromFile(f) { | |
| 164 | + | if warn { | |
| 165 | + | noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken) | |
| 166 | + | } | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line) | |
| 170 | + | }) | |
| 171 | + | return e.token, e.tokenErr | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // urlIsFromFile reports whether the server in use is the one of the config | |
| 175 | + | // file: ERMITE_URL is unset, or names the same address as the file's url. | |
| 176 | + | func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |
| 177 | + | envURL := strings.TrimSpace(e.getenv(client.EnvURL)) | |
| 178 | + | if envURL == "" { | |
| 179 | + | return true | |
| 180 | + | } | |
| 181 | + | a, err := client.ParseURL(envURL) | |
| 182 | + | if err != nil { | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | b, err := client.ParseURL(strings.TrimSpace(f.URL)) | |
| 186 | + | return err == nil && a.String() == b.String() | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |
| 190 | + | // caller's messages: the output is the secret. | |
| 191 | + | func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) { | |
| 192 | + | if e.fileTrust != nil { | |
| 193 | + | return "", e.fileTrust | |
| 194 | + | } | |
| 195 | + | out, err := e.runTokenCmd(ctx, line) | |
| 196 | + | if err != nil { | |
| 197 | + | return "", fmt.Errorf("token.cmd failed: %w", err) | |
| 198 | + | } | |
| 199 | + | out = strings.TrimSpace(out) | |
| 200 | + | switch { | |
| 201 | + | case out == "": | |
| 202 | + | return "", errors.New("token.cmd printed nothing") | |
| 203 | + | case strings.ContainsAny(out, "\r\n"): | |
| 204 | + | return "", errors.New("token.cmd printed more than one line") | |
| 205 | + | } | |
| 206 | + | return out, nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // runTokenShell is the default runTokenCmd: the system shell, for at most | |
| 210 | + | // tokenCmdTimeout. | |
| 211 | + | func runTokenShell(ctx context.Context, line string) (string, error) { | |
| 212 | + | return runShell(ctx, line, tokenCmdTimeout) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // runShell runs line with the system shell. Standard error is the terminal's, | |
| 216 | + | // and so is standard input when it is a terminal, so a pinentry or a biometric | |
| 217 | + | // prompt works; a script or an agent gets no input. Standard output is | |
| 218 | + | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |
| 219 | + | // An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command | |
| 220 | + | // like a timeout does (see shellCommand for what is killed). | |
| 221 | + | func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) { | |
| 222 | + | sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 223 | + | defer stop() | |
| 224 | + | runCtx, cancel := context.WithTimeout(sigCtx, timeout) | |
| 225 | + | defer cancel() | |
| 226 | + | ||
| 227 | + | foreground := term.IsTerminal(int(os.Stdin.Fd())) | |
| 228 | + | c := shellCommand(runCtx, line, foreground) | |
| 229 | + | out := &cappedBuffer{max: tokenCmdMaxOutput} | |
| 230 | + | c.Stdout, c.Stderr = out, os.Stderr | |
| 231 | + | if foreground { | |
| 232 | + | c.Stdin = os.Stdin | |
| 233 | + | } // else nil: the command reads os.DevNull | |
| 234 | + | // Do not wait forever for a background process that kept the pipe open. | |
| 235 | + | c.WaitDelay = 2 * time.Second | |
| 236 | + | err := c.Run() | |
| 237 | + | if err != nil { | |
| 238 | + | switch { | |
| 239 | + | case sigCtx.Err() != nil: | |
| 240 | + | return "", errors.New("interrupted") | |
| 241 | + | case errors.Is(runCtx.Err(), context.DeadlineExceeded): | |
| 242 | + | return "", fmt.Errorf("timed out after %s", timeout) | |
| 243 | + | } | |
| 244 | + | if exit := (*exec.ExitError)(nil); errors.As(err, &exit) { | |
| 245 | + | return "", errors.New(exit.String()) | |
| 246 | + | } | |
| 247 | + | return "", err | |
| 248 | + | } | |
| 249 | + | if out.over { | |
| 250 | + | return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput) | |
| 251 | + | } | |
| 252 | + | return out.buf.String(), nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // cappedBuffer keeps the first max bytes written and notes an overflow. | |
| 256 | + | type cappedBuffer struct { | |
| 257 | + | buf bytes.Buffer | |
| 258 | + | max int | |
| 259 | + | over bool | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (b *cappedBuffer) Write(p []byte) (int, error) { | |
| 263 | + | n := len(p) // report everything as written: a short write would abort the copy | |
| 264 | + | if room := b.max - b.buf.Len(); len(p) > room { | |
| 265 | + | b.over = true | |
| 266 | + | p = p[:room] | |
| 267 | + | } | |
| 268 | + | b.buf.Write(p) | |
| 269 | + | return n, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | var _ io.Writer = (*cappedBuffer)(nil) | |
A internal/app/cli_configfile_test.go +450 -0
| @@ -0,0 +1,450 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | "golang.org/x/term" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // writeConfigFile writes the config file of env with the given mode. | |
| 23 | + | func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string { | |
| 24 | + | t.Helper() | |
| 25 | + | path, err := env.configPath() | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 28 | + | require.NoError(t, os.WriteFile(path, []byte(content), mode)) | |
| 29 | + | require.NoError(t, os.Chmod(path, mode)) // umask | |
| 30 | + | return path | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // tokenCmdStub makes runTokenCmd return out and err, and counts the runs. | |
| 34 | + | func tokenCmdStub(env *cliEnv, out string, err error) *[]string { | |
| 35 | + | var ran []string | |
| 36 | + | env.runTokenCmd = func(_ context.Context, line string) (string, error) { | |
| 37 | + | ran = append(ran, line) | |
| 38 | + | return out, err | |
| 39 | + | } | |
| 40 | + | return &ran | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func listFake() *fakeAPI { | |
| 44 | + | return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 45 | + | return &ermite.ListReposResponse{}, nil | |
| 46 | + | }} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func lastAuth(f *fakeAPI) string { | |
| 50 | + | f.mu.Lock() | |
| 51 | + | defer f.mu.Unlock() | |
| 52 | + | if len(f.header) == 0 { | |
| 53 | + | return "<no call>" | |
| 54 | + | } | |
| 55 | + | return f.header[len(f.header)-1].Get("Authorization") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestConfigFileSuppliesURLAndToken(t *testing.T) { | |
| 59 | + | f := listFake() | |
| 60 | + | url := f.serve(t) | |
| 61 | + | env := testEnv(t, nil) | |
| 62 | + | writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600) | |
| 63 | + | ran := tokenCmdStub(env, " ermite_fromcmd\n", nil) | |
| 64 | + | ||
| 65 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 66 | + | assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f)) | |
| 67 | + | assert.Equal(t, []string{"pass show ermite/token"}, *ran) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) { | |
| 71 | + | f := listFake() | |
| 72 | + | env := testEnv(t, nil) | |
| 73 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600) | |
| 74 | + | ||
| 75 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 76 | + | assert.Empty(t, lastAuth(f)) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |
| 80 | + | fileSrv, envSrv := listFake(), listFake() | |
| 81 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"}) | |
| 82 | + | writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600) | |
| 83 | + | ||
| 84 | + | runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called | |
| 85 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv)) | |
| 86 | + | calls, _ := fileSrv.snapshot() | |
| 87 | + | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) { | |
| 91 | + | f := listFake() | |
| 92 | + | tests := []struct { | |
| 93 | + | name string | |
| 94 | + | envURL string // "" means the fake server | |
| 95 | + | file string // the content of the config file | |
| 96 | + | wantErr bool | |
| 97 | + | }{ | |
| 98 | + | {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"}, | |
| 99 | + | {name: "file without url", file: "token.cmd = get-token\n"}, | |
| 100 | + | {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true}, | |
| 101 | + | } | |
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | envURL := tt.envURL | |
| 105 | + | if envURL == "" { | |
| 106 | + | envURL = f.serve(t) | |
| 107 | + | } | |
| 108 | + | env := testEnv(t, map[string]string{"ERMITE_URL": envURL}) | |
| 109 | + | writeConfigFile(t, env, tt.file, 0o600) | |
| 110 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 111 | + | ||
| 112 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 113 | + | assert.Empty(t, *ran, "the token of the file's server must not go to another server") | |
| 114 | + | if tt.wantErr { | |
| 115 | + | require.Error(t, res.err) | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | require.NoError(t, res.err) | |
| 119 | + | assert.Empty(t, lastAuth(f)) | |
| 120 | + | assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr) | |
| 121 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN") | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) { | |
| 127 | + | f := listFake() | |
| 128 | + | url := f.serve(t) | |
| 129 | + | // the same address, written differently | |
| 130 | + | env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "}) | |
| 131 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 132 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 133 | + | ||
| 134 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 135 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 136 | + | assert.Len(t, *ran, 1) | |
| 137 | + | assert.NotContains(t, res.stderr, "token.cmd") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |
| 141 | + | f := listFake() | |
| 142 | + | env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 143 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue | |
| 144 | + | ran := tokenCmdStub(env, "other", nil) | |
| 145 | + | ||
| 146 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 147 | + | assert.Empty(t, *ran) | |
| 148 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) { | |
| 152 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 153 | + | return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil | |
| 154 | + | }} | |
| 155 | + | url := f.serve(t) | |
| 156 | + | ||
| 157 | + | for name, args := range map[string][]string{ | |
| 158 | + | "version": {"version"}, | |
| 159 | + | "version --json": {"version", "--json"}, | |
| 160 | + | "completion": {"completion", "bash"}, | |
| 161 | + | } { | |
| 162 | + | t.Run(name, func(t *testing.T) { | |
| 163 | + | env := testEnv(t, nil) | |
| 164 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 165 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 166 | + | runCLIWith(t, env, "", args...).ok(t) | |
| 167 | + | assert.Empty(t, *ran) | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | t.Run("version without any url", func(t *testing.T) { | |
| 172 | + | env := testEnv(t, nil) | |
| 173 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 174 | + | ran := tokenCmdStub(env, "", errors.New("must not run")) | |
| 175 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 176 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |
| 177 | + | assert.Empty(t, *ran) | |
| 178 | + | }) | |
| 179 | + | ||
| 180 | + | t.Run("version uses the url of the file and sends no token", func(t *testing.T) { | |
| 181 | + | env := testEnv(t, nil) | |
| 182 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 183 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 184 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 185 | + | assert.Contains(t, res.stdout, "v0.4.0 at "+url) | |
| 186 | + | assert.Empty(t, lastAuth(f)) | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestTokenCmdRunsOncePerProcess(t *testing.T) { | |
| 191 | + | f := listFake() | |
| 192 | + | env := testEnv(t, nil) | |
| 193 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 194 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 195 | + | root := newRootCmdWith(env) | |
| 196 | + | for range 2 { | |
| 197 | + | c, _, err := root.Find([]string{"repo", "list"}) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | _, err = env.client(c) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | } | |
| 202 | + | assert.Len(t, *ran, 1) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) { | |
| 206 | + | if runtime.GOOS == "windows" { | |
| 207 | + | t.Skip("no Unix permission bits") | |
| 208 | + | } | |
| 209 | + | for _, mode := range []os.FileMode{0o666, 0o662, 0o620} { | |
| 210 | + | t.Run(mode.String(), func(t *testing.T) { | |
| 211 | + | env := testEnv(t, nil) | |
| 212 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode) | |
| 213 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 214 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 215 | + | require.Error(t, res.err) | |
| 216 | + | assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error()) | |
| 217 | + | assert.Empty(t, *ran) | |
| 218 | + | }) | |
| 219 | + | } | |
| 220 | + | t.Run("group readable is fine", func(t *testing.T) { | |
| 221 | + | f := listFake() | |
| 222 | + | env := testEnv(t, nil) | |
| 223 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644) | |
| 224 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 225 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestTokenCmdFailuresNeverShowOutput(t *testing.T) { | |
| 230 | + | const secret = "s3cret-token-value" | |
| 231 | + | tests := []struct { | |
| 232 | + | name string | |
| 233 | + | out string | |
| 234 | + | err error | |
| 235 | + | want string | |
| 236 | + | }{ | |
| 237 | + | {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"}, | |
| 238 | + | {"empty", " \n", nil, "token.cmd printed nothing"}, | |
| 239 | + | {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"}, | |
| 240 | + | {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"}, | |
| 241 | + | } | |
| 242 | + | for _, tt := range tests { | |
| 243 | + | t.Run(tt.name, func(t *testing.T) { | |
| 244 | + | env := testEnv(t, nil) | |
| 245 | + | writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 246 | + | tokenCmdStub(env, tt.out, tt.err) | |
| 247 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 248 | + | require.Error(t, res.err) | |
| 249 | + | assert.Equal(t, tt.want, res.err.Error()) | |
| 250 | + | assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret) | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) { | |
| 256 | + | env := testEnv(t, nil) | |
| 257 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 258 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 259 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 260 | + | require.Error(t, res.err) | |
| 261 | + | assert.Contains(t, res.err.Error(), "ERMITE_URL is not set") | |
| 262 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |
| 263 | + | assert.Empty(t, *ran) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestInvalidFileURLIsReported(t *testing.T) { | |
| 267 | + | env := testEnv(t, nil) | |
| 268 | + | path := writeConfigFile(t, env, "url = git.example.com\n", 0o600) | |
| 269 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 270 | + | require.Error(t, res.err) | |
| 271 | + | assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named | |
| 272 | + | assert.Contains(t, res.err.Error(), "ermite config set url") | |
| 273 | + | assert.Contains(t, res.err.Error(), "http") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestConfigFileWarnings(t *testing.T) { | |
| 277 | + | f := listFake() | |
| 278 | + | env := testEnv(t, nil) | |
| 279 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600) | |
| 280 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 281 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr) | |
| 282 | + | assert.Contains(t, res.stderr, "colour") | |
| 283 | + | assert.NotContains(t, res.stderr, "blue", "values are never echoed") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) { | |
| 287 | + | env := testEnv(t, nil) | |
| 288 | + | writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600) | |
| 289 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 290 | + | assert.NotContains(t, res.stderr, "warning") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestERMITE_CONFIGSelectsTheFile(t *testing.T) { | |
| 294 | + | f := listFake() | |
| 295 | + | path := filepath.Join(t.TempDir(), "other", "conf") | |
| 296 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 297 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600)) | |
| 298 | + | ||
| 299 | + | env := testEnv(t, map[string]string{"ERMITE_CONFIG": path}) | |
| 300 | + | env.configPath = nil // the default resolution, which reads ERMITE_CONFIG | |
| 301 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestTokenCmdRunsThroughTheShell(t *testing.T) { | |
| 305 | + | if runtime.GOOS == "windows" { | |
| 306 | + | t.Skip("uses sh") | |
| 307 | + | } | |
| 308 | + | f := listFake() | |
| 309 | + | env := testEnv(t, nil) | |
| 310 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600) | |
| 311 | + | env.runTokenCmd = runTokenShell // the real thing | |
| 312 | + | ||
| 313 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 314 | + | assert.Equal(t, "Bearer secret", lastAuth(f)) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestRunShell(t *testing.T) { | |
| 318 | + | if runtime.GOOS == "windows" { | |
| 319 | + | t.Skip("uses sh") | |
| 320 | + | } | |
| 321 | + | tests := []struct { | |
| 322 | + | name string | |
| 323 | + | line string | |
| 324 | + | want string // the output on success | |
| 325 | + | errHas string // the error on failure | |
| 326 | + | errNot string // what the error must not show | |
| 327 | + | wantErr bool | |
| 328 | + | }{ | |
| 329 | + | {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"}, | |
| 330 | + | {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"}, | |
| 331 | + | {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"}, | |
| 332 | + | } | |
| 333 | + | for _, tt := range tests { | |
| 334 | + | t.Run(tt.name, func(t *testing.T) { | |
| 335 | + | out, err := runShell(t.Context(), tt.line, time.Minute) | |
| 336 | + | if !tt.wantErr { | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | assert.Equal(t, tt.want, out) | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | require.Error(t, err) | |
| 342 | + | assert.Contains(t, err.Error(), tt.errHas) | |
| 343 | + | assert.NotContains(t, err.Error(), tt.errNot) | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // requireNoTerminal skips a test of the non-terminal path when the tests run | |
| 349 | + | // with a terminal as standard input. | |
| 350 | + | func requireNoTerminal(t *testing.T) { | |
| 351 | + | t.Helper() | |
| 352 | + | if runtime.GOOS == "windows" { | |
| 353 | + | t.Skip("uses sh") | |
| 354 | + | } | |
| 355 | + | if term.IsTerminal(int(os.Stdin.Fd())) { | |
| 356 | + | t.Skip("standard input is a terminal") | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func TestTokenCmdTimesOut(t *testing.T) { | |
| 361 | + | requireNoTerminal(t) | |
| 362 | + | f := listFake() | |
| 363 | + | env := testEnv(t, nil) | |
| 364 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600) | |
| 365 | + | env.runTokenCmd = func(ctx context.Context, line string) (string, error) { | |
| 366 | + | return runShell(ctx, line, 100*time.Millisecond) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | start := time.Now() | |
| 370 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 371 | + | require.Error(t, res.err) | |
| 372 | + | assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error()) | |
| 373 | + | assert.Less(t, time.Since(start), 10*time.Second) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) { | |
| 377 | + | requireNoTerminal(t) | |
| 378 | + | out, err := runShell(t.Context(), "cat; echo done", time.Minute) | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | assert.Equal(t, "done\n", out, "the command read nothing") | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) { | |
| 384 | + | if runtime.GOOS == "windows" { | |
| 385 | + | t.Skip("no Unix permission bits") | |
| 386 | + | } | |
| 387 | + | env := testEnv(t, nil) | |
| 388 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 389 | + | dir := filepath.Dir(path) | |
| 390 | + | require.NoError(t, os.Chmod(dir, 0o777)) | |
| 391 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 392 | + | ||
| 393 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 394 | + | require.Error(t, res.err) | |
| 395 | + | assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error()) | |
| 396 | + | assert.Empty(t, *ran) | |
| 397 | + | ||
| 398 | + | t.Run("sticky directory is fine", func(t *testing.T) { | |
| 399 | + | require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky)) | |
| 400 | + | f := listFake() | |
| 401 | + | env := testEnvAt(t, path) | |
| 402 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600)) | |
| 403 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 404 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 405 | + | }) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |
| 409 | + | if runtime.GOOS == "windows" { | |
| 410 | + | t.Skip("symlinks") | |
| 411 | + | } | |
| 412 | + | f := listFake() | |
| 413 | + | env := testEnv(t, nil) | |
| 414 | + | real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 415 | + | link := filepath.Join(t.TempDir(), "dotfile-link") | |
| 416 | + | require.NoError(t, os.Symlink(real, link)) | |
| 417 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 418 | + | env.configPath = func() (string, error) { return link, nil } | |
| 419 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 420 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) { | |
| 424 | + | env := testEnv(t, nil) | |
| 425 | + | path := writeConfigFile(t, env, "", 0o600) | |
| 426 | + | require.NoError(t, os.Remove(path)) | |
| 427 | + | require.NoError(t, os.Mkdir(path, 0o700)) | |
| 428 | + | res := runCLIWith(t, env, "", "version").ok(t) | |
| 429 | + | assert.Equal(t, "ermite v0.0.1\n", res.stdout) | |
| 430 | + | assert.Contains(t, res.stderr, "ermite: warning: config:") | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestCompletionNeverRunsTokenCmd(t *testing.T) { | |
| 434 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 435 | + | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil | |
| 436 | + | }} | |
| 437 | + | env := testEnv(t, nil) | |
| 438 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 439 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 440 | + | res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 441 | + | assert.Empty(t, *ran) | |
| 442 | + | assert.Contains(t, res.stdout, "alpha") | |
| 443 | + | assert.Empty(t, lastAuth(f)) | |
| 444 | + | ||
| 445 | + | // with ERMITE_TOKEN set the completion is authenticated | |
| 446 | + | env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"}) | |
| 447 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 448 | + | runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t) | |
| 449 | + | assert.Equal(t, "Bearer ermite_env", lastAuth(f)) | |
| 450 | + | } | |
M internal/app/cli_e2e_test.go +3 -3
| @@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 326 | 326 | ver := decode[versionOut](t, res.stdout) | |
| 327 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 328 | 328 | assert.NotEmpty(t, ver.Server.Version) | |
| 329 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) | |
| 329 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | |
| 330 | 330 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 331 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 332 | 332 | require.Equal(t, 0, code, stderr) | |
| @@ -339,7 +339,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 339 | 339 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | |
| 340 | 340 | require.Equal(t, 0, code, stderr) | |
| 341 | 341 | res = result{stdout: stdout} | |
| 342 | - | assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 342 | + | assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 343 | 343 | tmp := decode[tokenOut](t, res.stdout) | |
| 344 | 344 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | |
| 345 | 345 | assert.Equal(t, []string{"proj"}, tmp.Repos) | |
| @@ -369,7 +369,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 369 | 369 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | |
| 370 | 370 | assert.NotContains(t, res.stdout, `"secret"`) | |
| 371 | 371 | res = c.cli(t, secret, "token", "list").ok(t) | |
| 372 | - | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 372 | + | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 373 | 373 | assert.Contains(t, res.stdout, tmp.ID) | |
| 374 | 374 | ||
| 375 | 375 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | |
M internal/app/cli_fake_test.go +31 -6
| @@ -5,8 +5,10 @@ package app | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "errors" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 11 | + | "path/filepath" | |
| 10 | 12 | "strings" | |
| 11 | 13 | "sync" | |
| 12 | 14 | "testing" | |
| @@ -30,6 +32,7 @@ type fakeAPI struct { | |||
| 30 | 32 | ermiteconnect.UnimplementedEventServiceHandler | |
| 31 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 32 | 34 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | + | ermiteconnect.UnimplementedRunServiceHandler | |
| 33 | 36 | ||
| 34 | 37 | mu sync.Mutex | |
| 35 | 38 | calls []string // procedure names, in order | |
| @@ -172,6 +175,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 172 | 175 | mount(p, h) | |
| 173 | 176 | p, h = ermiteconnect.NewSessionServiceHandler(f) | |
| 174 | 177 | mount(p, h) | |
| 178 | + | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| 179 | + | mount(p, h) | |
| 175 | 180 | srv := httptest.NewServer(mux) | |
| 176 | 181 | t.Cleanup(srv.Close) | |
| 177 | 182 | return srv.URL | |
| @@ -185,15 +190,35 @@ type result struct { | |||
| 185 | 190 | err error | |
| 186 | 191 | } | |
| 187 | 192 | ||
| 193 | + | // testEnv is a cliEnv with the given variables, the fixed clock, and a config | |
| 194 | + | // file path inside a fresh temp dir (no file yet) so that a test never reads | |
| 195 | + | // the developer's own ~/.config/ermite/config. token.cmd must not run unless | |
| 196 | + | // the test sets runTokenCmd. | |
| 197 | + | func testEnv(t *testing.T, vars map[string]string) *cliEnv { | |
| 198 | + | t.Helper() | |
| 199 | + | path := filepath.Join(t.TempDir(), "ermite", "config") | |
| 200 | + | return &cliEnv{ | |
| 201 | + | getenv: func(k string) string { return vars[k] }, | |
| 202 | + | now: func() time.Time { return testNow }, | |
| 203 | + | version: "v0.0.1", | |
| 204 | + | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |
| 205 | + | configPath: func() (string, error) { return path, nil }, | |
| 206 | + | runTokenCmd: func(context.Context, string) (string, error) { | |
| 207 | + | t.Error("token.cmd must not run") | |
| 208 | + | return "", errors.New("token.cmd must not run") | |
| 209 | + | }, | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 188 | 213 | // runCLI runs the command tree in process with the given environment. | |
| 189 | 214 | func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result { | |
| 190 | 215 | t.Helper() | |
| 191 | - | env := &cliEnv{ | |
| 192 | - | getenv: func(k string) string { return vars[k] }, | |
| 193 | - | now: func() time.Time { return testNow }, | |
| 194 | - | version: "v0.0.1", | |
| 195 | - | sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, | |
| 196 | - | } | |
| 216 | + | return runCLIWith(t, testEnv(t, vars), stdin, args...) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // runCLIWith runs the command tree in process with env. | |
| 220 | + | func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result { | |
| 221 | + | t.Helper() | |
| 197 | 222 | root := newRootCmdWith(env) | |
| 198 | 223 | var out, errb bytes.Buffer | |
| 199 | 224 | root.SetOut(&out) | |
M internal/app/cli_mr.go +8 -8
| @@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string | |||
| 204 | 204 | case 0: | |
| 205 | 205 | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |
| 206 | 206 | case 1: | |
| 207 | - | c, err := env.client(cmd, false) | |
| 207 | + | c, err := env.completionClient(cmd) | |
| 208 | 208 | if err != nil { | |
| 209 | 209 | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 210 | 210 | } | |
| @@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 265 | 265 | Repo: args[0], Title: title, Description: desc, | |
| 266 | 266 | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | |
| 267 | 267 | } | |
| 268 | - | c, err := env.client(cmd, true) | |
| 268 | + | c, err := env.client(cmd) | |
| 269 | 269 | if err != nil { | |
| 270 | 270 | return err | |
| 271 | 271 | } | |
| @@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command { | |||
| 310 | 310 | if err != nil { | |
| 311 | 311 | return err | |
| 312 | 312 | } | |
| 313 | - | c, err := env.client(cmd, true) | |
| 313 | + | c, err := env.client(cmd) | |
| 314 | 314 | if err != nil { | |
| 315 | 315 | return err | |
| 316 | 316 | } | |
| @@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 474 | 474 | if err != nil { | |
| 475 | 475 | return err | |
| 476 | 476 | } | |
| 477 | - | c, err := env.client(cmd, true) | |
| 477 | + | c, err := env.client(cmd) | |
| 478 | 478 | if err != nil { | |
| 479 | 479 | return err | |
| 480 | 480 | } | |
| @@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 687 | 687 | case line != 0 || cmd.Flags().Changed("side"): | |
| 688 | 688 | return errors.New("--line and --side need --path") | |
| 689 | 689 | } | |
| 690 | - | c, err := env.client(cmd, true) | |
| 690 | + | c, err := env.client(cmd) | |
| 691 | 691 | if err != nil { | |
| 692 | 692 | return err | |
| 693 | 693 | } | |
| @@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||
| 722 | 722 | return err | |
| 723 | 723 | } | |
| 724 | 724 | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | |
| 725 | - | c, err := env.client(cmd, true) | |
| 725 | + | c, err := env.client(cmd) | |
| 726 | 726 | if err != nil { | |
| 727 | 727 | return err | |
| 728 | 728 | } | |
| @@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 781 | 781 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| 782 | 782 | "so that only the commit you reviewed is merged", args[0], n) | |
| 783 | 783 | } | |
| 784 | - | c, err := env.client(cmd, true) | |
| 784 | + | c, err := env.client(cmd) | |
| 785 | 785 | if err != nil { | |
| 786 | 786 | return err | |
| 787 | 787 | } | |
| @@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 830 | 830 | if err != nil { | |
| 831 | 831 | return err | |
| 832 | 832 | } | |
| 833 | - | c, err := env.client(cmd, true) | |
| 833 | + | c, err := env.client(cmd) | |
| 834 | 834 | if err != nil { | |
| 835 | 835 | return err | |
| 836 | 836 | } | |
M internal/app/cli_repo.go +7 -7
| @@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command { | |||
| 86 | 86 | repo := &cobra.Command{ | |
| 87 | 87 | Use: "repo", | |
| 88 | 88 | Short: "Manage repositories on a remote server", | |
| 89 | - | Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.", | |
| 89 | + | Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.", | |
| 90 | 90 | } | |
| 91 | 91 | repo.AddCommand( | |
| 92 | 92 | newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env), | |
| @@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command { | |||
| 146 | 146 | } | |
| 147 | 147 | asJSON := addJSONFlag(cmd) | |
| 148 | 148 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 149 | - | c, err := env.client(cmd, true) | |
| 149 | + | c, err := env.client(cmd) | |
| 150 | 150 | if err != nil { | |
| 151 | 151 | return err | |
| 152 | 152 | } | |
| @@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command { | |||
| 205 | 205 | } | |
| 206 | 206 | asJSON := addJSONFlag(cmd) | |
| 207 | 207 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 208 | - | c, err := env.client(cmd, true) | |
| 208 | + | c, err := env.client(cmd) | |
| 209 | 209 | if err != nil { | |
| 210 | 210 | return err | |
| 211 | 211 | } | |
| @@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command { | |||
| 239 | 239 | if err != nil { | |
| 240 | 240 | return err | |
| 241 | 241 | } | |
| 242 | - | c, err := env.client(cmd, true) | |
| 242 | + | c, err := env.client(cmd) | |
| 243 | 243 | if err != nil { | |
| 244 | 244 | return err | |
| 245 | 245 | } | |
| @@ -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), | |
| @@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 295 | 295 | if err != nil { | |
| 296 | 296 | return err | |
| 297 | 297 | } | |
| 298 | - | c, err := env.client(cmd, true) | |
| 298 | + | c, err := env.client(cmd) | |
| 299 | 299 | if err != nil { | |
| 300 | 300 | return err | |
| 301 | 301 | } | |
| @@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command { | |||
| 377 | 377 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 378 | 378 | name := args[0] | |
| 379 | 379 | // Check the configuration before asking anything. | |
| 380 | - | c, err := env.client(cmd, true) | |
| 380 | + | c, err := env.client(cmd) | |
| 381 | 381 | if err != nil { | |
| 382 | 382 | return err | |
| 383 | 383 | } | |
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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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 | + | } | |
A internal/app/cli_shell_unix.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build !windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "fmt" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "syscall" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // shellCommand runs line with `sh -c`. | |
| 18 | + | // | |
| 19 | + | // When foreground is false (no terminal: a script or an agent), the command | |
| 20 | + | // gets its own process group and a cancellation, a timeout or a signal kills | |
| 21 | + | // the whole group, so a grandchild such as `sleep` or a secret manager helper | |
| 22 | + | // cannot outlive ermite or keep the output pipe open. | |
| 23 | + | // | |
| 24 | + | // When foreground is true (standard input is a terminal) the command stays in | |
| 25 | + | // the foreground process group of the terminal: a pinentry or a password | |
| 26 | + | // prompt then works, and a background group would be stopped by SIGTTIN | |
| 27 | + | // when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a | |
| 28 | + | // timeout or a SIGTERM only sh is killed, not what it started. | |
| 29 | + | func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd { | |
| 30 | + | c := exec.CommandContext(ctx, "sh", "-c", line) | |
| 31 | + | if !foreground { | |
| 32 | + | c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 33 | + | c.Cancel = func() error { | |
| 34 | + | // A negative pid is the process group, led by sh. | |
| 35 | + | return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return c | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // openConfig opens the config file without blocking on a FIFO swapped in for it. | |
| 42 | + | func openConfig(path string) (*os.File, error) { | |
| 43 | + | return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // checkPrivate says whether token.cmd may be run from the config file at | |
| 47 | + | // path, whose open handle reported info: whoever can change the file can make | |
| 48 | + | // token.cmd any command. It returns nil when the file belongs to euid (the | |
| 49 | + | // effective user id of the process) and cannot be written by group or others, | |
| 50 | + | // and when its directory belongs to euid or root and cannot be written by | |
| 51 | + | // group or others (a sticky directory excepted), as they could replace the | |
| 52 | + | // file. | |
| 53 | + | func checkPrivate(path string, info fs.FileInfo, euid int) error { | |
| 54 | + | if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid { | |
| 55 | + | return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path) | |
| 56 | + | } | |
| 57 | + | if info.Mode().Perm()&0o022 != 0 { | |
| 58 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |
| 59 | + | } | |
| 60 | + | dir := filepath.Dir(path) | |
| 61 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |
| 62 | + | dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target | |
| 63 | + | } | |
| 64 | + | return checkPrivateDir(dir, euid) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // checkPrivateDir is the directory half of checkPrivate. | |
| 68 | + | func checkPrivateDir(dir string, euid int) error { | |
| 69 | + | di, err := os.Stat(dir) | |
| 70 | + | if err != nil { | |
| 71 | + | return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err) | |
| 72 | + | } | |
| 73 | + | if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 { | |
| 74 | + | return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir) | |
| 75 | + | } | |
| 76 | + | if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 { | |
| 77 | + | return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir) | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
A internal/app/cli_shell_unix_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build !windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "syscall" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/assert" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // processGone reports whether pid is gone (a zombie that nobody reaped counts). | |
| 24 | + | func processGone(pid int) bool { | |
| 25 | + | if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { | |
| 26 | + | return true | |
| 27 | + | } | |
| 28 | + | stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") | |
| 29 | + | if err != nil { | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | _, after, _ := strings.Cut(string(stat), ") ") | |
| 33 | + | return strings.HasPrefix(after, "Z") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // readPid waits for the pid file written by a grandchild. | |
| 37 | + | func readPid(t *testing.T, path string) int { | |
| 38 | + | t.Helper() | |
| 39 | + | var pid int | |
| 40 | + | require.Eventually(t, func() bool { | |
| 41 | + | data, err := os.ReadFile(path) | |
| 42 | + | if err != nil { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | pid, err = strconv.Atoi(strings.TrimSpace(string(data))) | |
| 46 | + | return err == nil && pid > 0 | |
| 47 | + | }, 5*time.Second, 10*time.Millisecond) | |
| 48 | + | return pid | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) { | |
| 52 | + | requireNoTerminal(t) | |
| 53 | + | for name, tc := range map[string]struct { | |
| 54 | + | ctx func(t *testing.T) context.Context | |
| 55 | + | timeout time.Duration | |
| 56 | + | want string | |
| 57 | + | }{ | |
| 58 | + | "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"}, | |
| 59 | + | "cancel": {func(t *testing.T) context.Context { | |
| 60 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 61 | + | time.AfterFunc(700*time.Millisecond, cancel) | |
| 62 | + | return ctx | |
| 63 | + | }, time.Minute, "interrupted"}, | |
| 64 | + | } { | |
| 65 | + | t.Run(name, func(t *testing.T) { | |
| 66 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |
| 67 | + | // sh starts a grandchild, records its pid and waits for it | |
| 68 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |
| 69 | + | _, err := runShell(tc.ctx(t), line, tc.timeout) | |
| 70 | + | require.Error(t, err) | |
| 71 | + | assert.Equal(t, tc.want, err.Error()) | |
| 72 | + | ||
| 73 | + | pid := readPid(t, pidFile) | |
| 74 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond, | |
| 75 | + | "the grandchild %d must not outlive the command", pid) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRunShellStopsOnSIGTERM(t *testing.T) { | |
| 81 | + | requireNoTerminal(t) | |
| 82 | + | pidFile := filepath.Join(t.TempDir(), "pid") | |
| 83 | + | line := "sleep 30 & echo $! > '" + pidFile + "'; wait" | |
| 84 | + | errc := make(chan error, 1) | |
| 85 | + | go func() { | |
| 86 | + | _, err := runShell(t.Context(), line, time.Minute) | |
| 87 | + | errc <- err | |
| 88 | + | }() | |
| 89 | + | pid := readPid(t, pidFile) | |
| 90 | + | // the signal is delivered to the test process itself: runShell catches it | |
| 91 | + | require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM)) | |
| 92 | + | select { | |
| 93 | + | case err := <-errc: | |
| 94 | + | require.Error(t, err) | |
| 95 | + | assert.Equal(t, "interrupted", err.Error()) | |
| 96 | + | case <-time.After(10 * time.Second): | |
| 97 | + | t.Fatal("runShell did not stop on SIGTERM") | |
| 98 | + | } | |
| 99 | + | require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // fakeInfo is a file whose owner and mode are chosen by the test. | |
| 103 | + | type fakeInfo struct { | |
| 104 | + | fs.FileInfo | |
| 105 | + | mode fs.FileMode | |
| 106 | + | uid uint32 | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (f fakeInfo) Mode() fs.FileMode { return f.mode } | |
| 110 | + | func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } | |
| 111 | + | ||
| 112 | + | func TestCheckPrivate(t *testing.T) { | |
| 113 | + | dir := t.TempDir() // owned by the test user, mode 0700 | |
| 114 | + | path := filepath.Join(dir, "config") | |
| 115 | + | require.NoError(t, os.WriteFile(path, nil, 0o600)) | |
| 116 | + | real, err := os.Stat(path) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | me := os.Geteuid() | |
| 119 | + | ||
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | mode fs.FileMode | |
| 123 | + | uid int // owner of the file | |
| 124 | + | euid int | |
| 125 | + | want string // error text, "" for accepted | |
| 126 | + | }{ | |
| 127 | + | {"own private file", 0o600, me, me, ""}, | |
| 128 | + | {"file of another user", 0o600, me + 1, me, "is owned by another user"}, | |
| 129 | + | {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"}, | |
| 130 | + | {"group writable", 0o620, me, me, "is writable by others"}, | |
| 131 | + | {"world writable", 0o602, me, me, "is writable by others"}, | |
| 132 | + | {"group readable", 0o640, me, me, ""}, | |
| 133 | + | // the directory is the real one: owned by me | |
| 134 | + | {"directory of another user", 0o600, me + 1, me + 1, "config directory"}, | |
| 135 | + | } | |
| 136 | + | for _, tt := range tests { | |
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |
| 138 | + | if tt.want == "config directory" && me == 0 { | |
| 139 | + | t.Skip("root owns every directory it creates") | |
| 140 | + | } | |
| 141 | + | err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid) | |
| 142 | + | if tt.want == "" { | |
| 143 | + | assert.NoError(t, err) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | require.Error(t, err) | |
| 147 | + | assert.Contains(t, err.Error(), tt.want) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | t.Run("directory owned by root is fine", func(t *testing.T) { | |
| 152 | + | // /tmp and friends are root's; use a root-owned directory when there is one | |
| 153 | + | fi, err := os.Stat("/") | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 { | |
| 156 | + | t.Skip("/ is not a private root directory here") | |
| 157 | + | } | |
| 158 | + | assert.NoError(t, checkPrivateDir("/", me+1)) | |
| 159 | + | }) | |
| 160 | + | } | |
A internal/app/cli_shell_windows.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build windows | |
| 4 | + | ||
| 5 | + | package app | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io/fs" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "syscall" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // shellCommand runs line with `cmd /C`. The command line is passed as is: | |
| 16 | + | // Go's argument quoting would mangle quotes cmd.exe means to read itself. | |
| 17 | + | // | |
| 18 | + | // The command is killed with exec.CommandContext on a timeout or a signal: | |
| 19 | + | // only cmd.exe itself, not what it started (there is no process group kill | |
| 20 | + | // here); foreground is ignored. | |
| 21 | + | func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd { | |
| 22 | + | c := exec.CommandContext(ctx, "cmd") | |
| 23 | + | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |
| 24 | + | return c | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // openConfig opens the config file. | |
| 28 | + | func openConfig(path string) (*os.File, error) { return os.Open(path) } | |
| 29 | + | ||
| 30 | + | // checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to | |
| 31 | + | // check, so token.cmd is run from a config file whoever can write it. The | |
| 32 | + | // per-user config directory (%AppData%) is private to the user by default; | |
| 33 | + | // keep ERMITE_CONFIG out of shared folders. | |
| 34 | + | func checkPrivate(string, fs.FileInfo, int) error { return nil } | |
M internal/app/cli_signing.go +1 -1
| @@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 55 | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | |
| 56 | 56 | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | |
| 57 | 57 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 58 | - | c, err := env.client(cmd, true) | |
| 58 | + | c, err := env.client(cmd) | |
| 59 | 59 | if err != nil { | |
| 60 | 60 | return err | |
| 61 | 61 | } | |
M internal/app/cli_token.go +85 -22
| @@ -21,13 +21,15 @@ import ( | |||
| 21 | 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | |
| 22 | 22 | // present in the output of `token create`. | |
| 23 | 23 | type jsonToken struct { | |
| 24 | - | ID string `json:"id"` | |
| 25 | - | Label string `json:"label"` | |
| 26 | - | Scopes []string `json:"scopes"` | |
| 27 | - | Repos []string `json:"repos"` | |
| 28 | - | ExpiresAt *string `json:"expires_at"` | |
| 29 | - | CreatedAt *string `json:"created_at"` | |
| 30 | - | LastUsedAt *string `json:"last_used_at"` | |
| 24 | + | ID string `json:"id"` | |
| 25 | + | Label string `json:"label"` | |
| 26 | + | Scopes []string `json:"scopes"` | |
| 27 | + | Repos []string `json:"repos"` | |
| 28 | + | // BranchPrefixes limits the branches the token may write; empty means all. | |
| 29 | + | BranchPrefixes []string `json:"branch_prefixes"` | |
| 30 | + | ExpiresAt *string `json:"expires_at"` | |
| 31 | + | CreatedAt *string `json:"created_at"` | |
| 32 | + | LastUsedAt *string `json:"last_used_at"` | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | type jsonCreatedToken struct { | |
| @@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken { | |||
| 44 | 46 | if repos == nil { | |
| 45 | 47 | repos = []string{} | |
| 46 | 48 | } | |
| 49 | + | prefixes := t.GetBranchPrefixes() | |
| 50 | + | if prefixes == nil { | |
| 51 | + | prefixes = []string{} | |
| 52 | + | } | |
| 47 | 53 | return jsonToken{ | |
| 48 | - | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, | |
| 54 | + | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes, | |
| 49 | 55 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | |
| 50 | 56 | } | |
| 51 | 57 | } | |
| 52 | 58 | ||
| 53 | 59 | const jsonTokenHelp = ` | |
| 54 | 60 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | |
| 55 | - | repos (array; empty means every repository), expires_at, created_at and last_used_at | |
| 56 | - | (RFC 3339 UTC, null when unset or never used).` | |
| 61 | + | repos (array; empty means every repository), branch_prefixes (array; empty means every branch), | |
| 62 | + | expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).` | |
| 57 | 63 | ||
| 58 | 64 | func newTokenCmd(env *cliEnv) *cobra.Command { | |
| 59 | 65 | token := &cobra.Command{ | |
| @@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command { | |||
| 67 | 73 | ||
| 68 | 74 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |
| 69 | 75 | var ( | |
| 70 | - | preset string | |
| 71 | - | scopes []string | |
| 72 | - | repos []string | |
| 73 | - | expires string | |
| 76 | + | preset string | |
| 77 | + | scopes []string | |
| 78 | + | repos []string | |
| 79 | + | prefixes []string | |
| 80 | + | expires string | |
| 74 | 81 | ) | |
| 75 | 82 | cmd := &cobra.Command{ | |
| 76 | 83 | Use: "create <label>", | |
| @@ -81,9 +88,16 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 81 | 88 | "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" + | |
| 82 | 89 | " cli every scope, for your own daily use\n" + | |
| 83 | 90 | " agent repo:write, mr:write\n" + | |
| 91 | + | " runner runner:register (a CI runner host)\n" + | |
| 84 | 92 | " ci repo:read\n" + | |
| 85 | 93 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 86 | 94 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
| 95 | + | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |
| 96 | + | "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" + | |
| 97 | + | "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" + | |
| 98 | + | "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" + | |
| 99 | + | "To give an AI agent its own space:\n" + | |
| 100 | + | " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" + | |
| 87 | 101 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | |
| 88 | 102 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | |
| 89 | 103 | Args: cobra.ExactArgs(1), | |
| @@ -91,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 91 | 105 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | |
| 92 | 106 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | |
| 93 | 107 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | |
| 108 | + | cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)") | |
| 94 | 109 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | |
| 95 | 110 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 96 | 111 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | |
| @@ -103,25 +118,59 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 103 | 118 | }) | |
| 104 | 119 | asJSON := addJSONFlag(cmd) | |
| 105 | 120 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 106 | - | req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now()) | |
| 121 | + | req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now()) | |
| 107 | 122 | if err != nil { | |
| 108 | 123 | return err | |
| 109 | 124 | } | |
| 110 | - | c, err := env.client(cmd, true) | |
| 125 | + | c, err := env.client(cmd) | |
| 111 | 126 | if err != nil { | |
| 112 | 127 | return err | |
| 113 | 128 | } | |
| 129 | + | if len(req.GetBranchPrefixes()) > 0 { | |
| 130 | + | if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | } | |
| 114 | 134 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | |
| 115 | 135 | if err != nil { | |
| 116 | 136 | return c.Describe(err) | |
| 117 | 137 | } | |
| 138 | + | if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 118 | 141 | return printCreatedToken(cmd, res.Msg, *asJSON) | |
| 119 | 142 | } | |
| 120 | 143 | return cmd | |
| 121 | 144 | } | |
| 122 | 145 | ||
| 146 | + | // requireBranchPrefixes refuses, before anything is created, to ask a server | |
| 147 | + | // for a branch-limited token when it does not list token.branch_prefixes: such | |
| 148 | + | // a server ignores the field. A server whose features cannot be read is refused | |
| 149 | + | // too (fail closed). | |
| 150 | + | func requireBranchPrefixes(ctx context.Context, c *client.Client) error { | |
| 151 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 152 | + | if err != nil { | |
| 153 | + | return c.Describe(err) | |
| 154 | + | } | |
| 155 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) { | |
| 156 | + | return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes) | |
| 157 | + | } | |
| 158 | + | return nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than | |
| 162 | + | // asked for. A server that predates branch prefixes ignores the field and | |
| 163 | + | // creates an unlimited token: the secret is then not printed. | |
| 164 | + | func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error { | |
| 165 | + | if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) { | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+ | |
| 169 | + | "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId()) | |
| 170 | + | } | |
| 171 | + | ||
| 123 | 172 | // buildCreateToken maps the flags of `token create` to the request. | |
| 124 | - | func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 173 | + | func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 125 | 174 | label = strings.TrimSpace(label) | |
| 126 | 175 | if label == "" { | |
| 127 | 176 | return nil, errors.New("the label must not be empty") | |
| @@ -136,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi | |||
| 136 | 185 | req.Repos = append(req.Repos, r) | |
| 137 | 186 | } | |
| 138 | 187 | } | |
| 188 | + | for _, bp := range prefixFlags { | |
| 189 | + | if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) { | |
| 190 | + | req.BranchPrefixes = append(req.BranchPrefixes, bp) | |
| 191 | + | } | |
| 192 | + | } | |
| 139 | 193 | if expires != "" { | |
| 140 | 194 | at, err := client.ParseExpiry(expires, now) | |
| 141 | 195 | if err != nil { | |
| @@ -155,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS | |||
| 155 | 209 | if len(t.GetRepos()) > 0 { | |
| 156 | 210 | repos = strings.Join(t.GetRepos(), ", ") | |
| 157 | 211 | } | |
| 212 | + | branches := "all" | |
| 213 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 214 | + | branches = strings.Join(t.GetBranchPrefixes(), ", ") | |
| 215 | + | } | |
| 158 | 216 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | |
| 159 | 217 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | |
| 160 | 218 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | |
| 161 | 219 | {" repos", repos}, | |
| 220 | + | {" branches", branches}, | |
| 162 | 221 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | |
| 163 | 222 | }) | |
| 164 | 223 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | |
| @@ -185,7 +244,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||
| 185 | 244 | } | |
| 186 | 245 | asJSON := addJSONFlag(cmd) | |
| 187 | 246 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 188 | - | c, err := env.client(cmd, true) | |
| 247 | + | c, err := env.client(cmd) | |
| 189 | 248 | if err != nil { | |
| 190 | 249 | return err | |
| 191 | 250 | } | |
| @@ -210,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||
| 210 | 269 | if len(t.GetRepos()) > 0 { | |
| 211 | 270 | repos = strings.Join(t.GetRepos(), ",") | |
| 212 | 271 | } | |
| 272 | + | branches := "all" | |
| 273 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 274 | + | branches = strings.Join(t.GetBranchPrefixes(), ",") | |
| 275 | + | } | |
| 213 | 276 | rows = append(rows, []string{ | |
| 214 | - | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, | |
| 277 | + | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches, | |
| 215 | 278 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | |
| 216 | 279 | }) | |
| 217 | 280 | } | |
| 218 | - | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows) | |
| 281 | + | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows) | |
| 219 | 282 | } | |
| 220 | 283 | return cmd | |
| 221 | 284 | } | |
| @@ -225,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||
| 225 | 288 | Use: "revoke <id>", | |
| 226 | 289 | Short: "Revoke a token", | |
| 227 | 290 | Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" + | |
| 228 | - | "A token with every scope and no --repo list has the rights of the owner (it can open web\n" + | |
| 291 | + | "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" + | |
| 229 | 292 | "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" + | |
| 230 | 293 | "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.", | |
| 231 | 294 | Args: cobra.ExactArgs(1), | |
| @@ -239,7 +302,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||
| 239 | 302 | return ids, cobra.ShellCompDirectiveNoFileComp | |
| 240 | 303 | }, | |
| 241 | 304 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 242 | - | c, err := env.client(cmd, true) | |
| 305 | + | c, err := env.client(cmd) | |
| 243 | 306 | if err != nil { | |
| 244 | 307 | return err | |
| 245 | 308 | } | |
M internal/app/cli_token_test.go +131 -6
| @@ -3,9 +3,12 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 6 | 8 | "testing" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| 11 | + | "connectrpc.com/connect" | |
| 9 | 12 | "github.com/stretchr/testify/assert" | |
| 10 | 13 | "github.com/stretchr/testify/require" | |
| 11 | 14 | ||
| @@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) { | |||
| 42 | 45 | assert.NotContains(t, res.stderr, "SECRETVALUE") | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 48 | + | // lastCreateToken returns the last CreateToken request the fake received. | |
| 49 | + | func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest { | |
| 50 | + | t.Helper() | |
| 51 | + | calls, reqs := f.snapshot() | |
| 52 | + | for i := len(calls) - 1; i >= 0; i-- { | |
| 53 | + | if calls[i] == "CreateToken" { | |
| 54 | + | return reqs[i].(*ermite.CreateTokenRequest) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | t.Fatal("no CreateToken call") | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // withBranchPrefixes is a GetVersion that lists token.branch_prefixes. | |
| 62 | + | func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 63 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestTokenCreateBranchPrefixes(t *testing.T) { | |
| 67 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 68 | + | return &ermite.CreateTokenResponse{ | |
| 69 | + | Token: &ermite.Token{ | |
| 70 | + | Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(), | |
| 71 | + | CreatedAt: ts("2026-10-10T12:00:00Z"), | |
| 72 | + | }, | |
| 73 | + | Secret: "ermite_S", | |
| 74 | + | }, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) { | |
| 78 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |
| 79 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha", | |
| 80 | + | "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t) | |
| 81 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes()) | |
| 82 | + | assert.Equal(t, "ermite_S\n", res.stdout) | |
| 83 | + | assert.Contains(t, res.stderr, "branches") | |
| 84 | + | assert.Contains(t, res.stderr, "agent/claude/, bot/") | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | t.Run("without the flag the token shows every branch", func(t *testing.T) { | |
| 88 | + | f := &fakeAPI{createToken: echo} | |
| 89 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |
| 90 | + | _, reqs := f.snapshot() | |
| 91 | + | assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |
| 92 | + | assert.Regexp(t, `branches\s+all`, res.stderr) | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | t.Run("json", func(t *testing.T) { | |
| 96 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |
| 97 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t) | |
| 98 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("a server without the feature is asked nothing", func(t *testing.T) { | |
| 102 | + | for _, tt := range []struct { | |
| 103 | + | name string | |
| 104 | + | version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | |
| 105 | + | wantMsg string | |
| 106 | + | }{ | |
| 107 | + | {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 108 | + | return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil | |
| 109 | + | }, "does not support token.branch_prefixes"}, | |
| 110 | + | {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented | |
| 111 | + | {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 112 | + | return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused")) | |
| 113 | + | }, "cannot reach the server"}, | |
| 114 | + | } { | |
| 115 | + | f := &fakeAPI{createToken: echo, getVersion: tt.version} | |
| 116 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |
| 117 | + | require.Error(t, res.err, tt.name) | |
| 118 | + | assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name) | |
| 119 | + | calls, _ := f.snapshot() | |
| 120 | + | assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name) | |
| 121 | + | assert.Empty(t, res.stdout, tt.name) | |
| 122 | + | } | |
| 123 | + | }) | |
| 124 | + | ||
| 125 | + | t.Run("without --branch-prefix the features are not read", func(t *testing.T) { | |
| 126 | + | f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented | |
| 127 | + | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |
| 128 | + | calls, _ := f.snapshot() | |
| 129 | + | assert.NotContains(t, calls, "GetVersion") | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) { | |
| 133 | + | f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 134 | + | return &ermite.CreateTokenResponse{ | |
| 135 | + | Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")}, | |
| 136 | + | Secret: "ermite_UNLIMITED", | |
| 137 | + | }, nil | |
| 138 | + | }, getVersion: withBranchPrefixes} | |
| 139 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |
| 140 | + | require.Error(t, res.err) | |
| 141 | + | assert.Contains(t, res.err.Error(), "without the requested branch prefixes") | |
| 142 | + | assert.Contains(t, res.err.Error(), "ermite token revoke tok9") | |
| 143 | + | assert.NotContains(t, res.stdout, "UNLIMITED") | |
| 144 | + | assert.NotContains(t, res.stderr, "UNLIMITED") | |
| 145 | + | assert.NotContains(t, res.err.Error(), "UNLIMITED") | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestTokenListBranchPrefixes(t *testing.T) { | |
| 150 | + | f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) { | |
| 151 | + | return &ermite.ListTokensResponse{Tokens: []*ermite.Token{ | |
| 152 | + | {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")}, | |
| 153 | + | {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")}, | |
| 154 | + | }}, nil | |
| 155 | + | }} | |
| 156 | + | url := f.serve(t) | |
| 157 | + | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 158 | + | assert.Equal(t, ""+ | |
| 159 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 160 | + | "t2 claude repo:write all agent/claude/,bot/ never never\n"+ | |
| 161 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 162 | + | ||
| 163 | + | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 164 | + | assert.JSONEq(t, `[ | |
| 165 | + | {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null}, | |
| 166 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 167 | + | ]`, res.stdout) | |
| 168 | + | } | |
| 169 | + | ||
| 45 | 170 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | |
| 46 | 171 | for preset, want := range map[string][]ermite.Scope{ | |
| 47 | 172 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | |
| @@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) { | |||
| 70 | 195 | }, nil | |
| 71 | 196 | }} | |
| 72 | 197 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | |
| 73 | - | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 198 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 74 | 199 | assert.Empty(t, res.stderr) | |
| 75 | 200 | } | |
| 76 | 201 | ||
| @@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) { | |||
| 106 | 231 | url := f.serve(t) | |
| 107 | 232 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 108 | 233 | assert.Equal(t, ""+ | |
| 109 | - | "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+ | |
| 110 | - | "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 111 | - | "t1 ci repo:read all never never\n", res.stdout) | |
| 234 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 235 | + | "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 236 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 112 | 237 | ||
| 113 | 238 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 114 | 239 | assert.JSONEq(t, `[ | |
| 115 | - | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 116 | - | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 240 | + | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 241 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 117 | 242 | ]`, res.stdout) | |
| 118 | 243 | assert.NotContains(t, res.stdout, "secret") | |
| 119 | 244 | } | |
M internal/app/cli_version.go +24 -14
| @@ -33,11 +33,13 @@ type jsonServerVersion struct { | |||
| 33 | 33 | func newVersionCmd(env *cliEnv) *cobra.Command { | |
| 34 | 34 | cmd := &cobra.Command{ | |
| 35 | 35 | Use: "version", | |
| 36 | - | Short: "Print the client version and, when ERMITE_URL is set, the server's", | |
| 37 | - | Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" + | |
| 38 | - | "the optional features of the API it implements. A warning on standard error says which of the\n" + | |
| 39 | - | "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" + | |
| 40 | - | "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" + | |
| 36 | + | Short: "Print the client version and, when a server URL is configured, the server's", | |
| 37 | + | Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" + | |
| 38 | + | "of the config file, see `ermite config`), the version of the server and the optional features\n" + | |
| 39 | + | "of the API it implements. A warning on standard error says which of the features this client\n" + | |
| 40 | + | "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" + | |
| 41 | + | "file is not run.\n\n" + | |
| 42 | + | "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" + | |
| 41 | 43 | "features (array of identifiers) and missing_features (the ones this client uses that the\n" + | |
| 42 | 44 | "server lacks).", | |
| 43 | 45 | Args: cobra.NoArgs, | |
| @@ -46,7 +48,13 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 46 | 48 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 47 | 49 | out := cmd.OutOrStdout() | |
| 48 | 50 | clientLine := "ermite " + env.version | |
| 49 | - | url := strings.TrimSpace(env.getenv(client.EnvURL)) | |
| 51 | + | url, err := env.resolveURL(cmd, true) | |
| 52 | + | if err != nil { | |
| 53 | + | // The client version does not depend on the file: say what is wrong | |
| 54 | + | // and go on as if no URL were configured. | |
| 55 | + | warnf(cmd, "%s", err) | |
| 56 | + | url = "" | |
| 57 | + | } | |
| 50 | 58 | if url == "" { | |
| 51 | 59 | if *asJSON { | |
| 52 | 60 | return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}}) | |
| @@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 55 | 63 | return err | |
| 56 | 64 | } | |
| 57 | 65 | ||
| 58 | - | c, err := env.client(cmd, true) | |
| 66 | + | // GetVersion is anonymous: token.cmd is not run for it. | |
| 67 | + | c, err := env.clientNoTokenCmd(cmd, true) | |
| 59 | 68 | if err != nil { | |
| 60 | 69 | return err | |
| 61 | 70 | } | |
| @@ -100,11 +109,12 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 100 | 109 | ||
| 101 | 110 | // featureUse says what breaks without a feature, for the version warning. | |
| 102 | 111 | var featureUse = map[string]string{ | |
| 103 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 104 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 105 | - | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 106 | - | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 107 | - | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 108 | - | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 109 | - | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 112 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 113 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | + | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 116 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 118 | + | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
| 119 | + | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 110 | 120 | } | |
M internal/app/cli_version_test.go +5 -5
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // allFeatures are the features this client uses. | |
| 18 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"} | |
| 18 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys"} | |
| 19 | 19 | ||
| 20 | 20 | func versionFake(features ...string) *fakeAPI { | |
| 21 | 21 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -36,15 +36,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 36 | 36 | f := versionFake(allFeatures...) | |
| 37 | 37 | url := f.serve(t) | |
| 38 | 38 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 39 | - | 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, passkeys\n", res.stdout) | |
| 39 | + | 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, token.branch_prefixes, ci.runs, ci.runners, passkeys\n", res.stdout) | |
| 40 | 40 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 41 | 41 | ||
| 42 | 42 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 43 | - | 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","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 43 | + | 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","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 47 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys") | |
| 47 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys") | |
| 48 | 48 | url := f.serve(t) | |
| 49 | 49 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 50 | 50 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -52,7 +52,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 52 | 52 | assert.NotContains(t, res.stderr, "repo.update") | |
| 53 | 53 | ||
| 54 | 54 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 55 | - | 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","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | + | 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","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 56 | 56 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 57 | 57 | ||
| 58 | 58 | f = versionFake() | |
M internal/app/cli_watch.go +1 -1
| @@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 163 | 163 | if len(args) == 1 { | |
| 164 | 164 | topics = []string{"repo:" + args[0]} | |
| 165 | 165 | } | |
| 166 | - | c, err := env.client(cmd, true) | |
| 166 | + | c, err := env.client(cmd) | |
| 167 | 167 | if err != nil { | |
| 168 | 168 | return err | |
| 169 | 169 | } | |
M internal/app/cli_web.go +5 -5
| @@ -25,7 +25,7 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||
| 25 | 25 | Use: "web", | |
| 26 | 26 | Short: "Sign the owner in to the web interface of a remote server", | |
| 27 | 27 | Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" + | |
| 28 | - | "token, or a token that holds every scope and is not limited to repositories.", | |
| 28 | + | "token, or a token that holds every scope and is limited neither to repositories nor to branches.", | |
| 29 | 29 | } | |
| 30 | 30 | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env)) | |
| 31 | 31 | return web | |
| @@ -45,7 +45,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command { | |||
| 45 | 45 | } | |
| 46 | 46 | asJSON := addJSONFlag(cmd) | |
| 47 | 47 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 48 | - | c, err := env.client(cmd, true) | |
| 48 | + | c, err := env.client(cmd) | |
| 49 | 49 | if err != nil { | |
| 50 | 50 | return err | |
| 51 | 51 | } | |
| @@ -77,7 +77,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||
| 77 | 77 | if !all { | |
| 78 | 78 | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | |
| 79 | 79 | } | |
| 80 | - | c, err := env.client(cmd, true) | |
| 80 | + | c, err := env.client(cmd) | |
| 81 | 81 | if err != nil { | |
| 82 | 82 | return err | |
| 83 | 83 | } | |
| @@ -133,7 +133,7 @@ func newWebPasskeyListCmd(env *cliEnv) *cobra.Command { | |||
| 133 | 133 | } | |
| 134 | 134 | asJSON := addJSONFlag(cmd) | |
| 135 | 135 | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 136 | - | c, err := env.client(cmd, true) | |
| 136 | + | c, err := env.client(cmd) | |
| 137 | 137 | if err != nil { | |
| 138 | 138 | return err | |
| 139 | 139 | } | |
| @@ -188,7 +188,7 @@ func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command { | |||
| 188 | 188 | return ids, cobra.ShellCompDirectiveNoFileComp | |
| 189 | 189 | }, | |
| 190 | 190 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 191 | - | c, err := env.client(cmd, true) | |
| 191 | + | c, err := env.client(cmd) | |
| 192 | 192 | if err != nil { | |
| 193 | 193 | return err | |
| 194 | 194 | } | |
A internal/app/e2e_branches_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // branchToken creates a stored token limited to branch prefixes, as root. | |
| 21 | + | func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) { | |
| 22 | + | h.t.Helper() | |
| 23 | + | p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes} | |
| 24 | + | for _, r := range repos { | |
| 25 | + | p.Repos = append(p.Repos, h.name(r)) | |
| 26 | + | } | |
| 27 | + | got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p) | |
| 28 | + | require.NoError(h.t, err) | |
| 29 | + | return got.Secret, got.Token.ID | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // AC5: a token limited to branch prefixes, through real git pushes and the API. | |
| 33 | + | func TestAC5BranchLimitedToken(t *testing.T) { | |
| 34 | + | h := newHarness(t, harnessOpts{}) | |
| 35 | + | wt := h.workTree("main", "a.txt") | |
| 36 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 37 | + | require.NoError(t, err, out) | |
| 38 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |
| 39 | + | ||
| 40 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge), | |
| 41 | + | []string{"agent/claude/"}, "proj") | |
| 42 | + | remote := h.urlAuth(secret, "/proj.git") | |
| 43 | + | commit := func(file string) { | |
| 44 | + | require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644)) | |
| 45 | + | h.mustGit(wt, "add", file) | |
| 46 | + | h.mustGit(wt, "commit", "-q", "-m", file) | |
| 47 | + | } | |
| 48 | + | refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") } | |
| 49 | + | ||
| 50 | + | // It pushes, force-pushes and deletes under its prefix. | |
| 51 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x") | |
| 52 | + | commit("x1") | |
| 53 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/x") | |
| 54 | + | require.NoError(t, err, out) | |
| 55 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |
| 56 | + | ||
| 57 | + | h.mustGit(wt, "reset", "-q", "--hard", "main") | |
| 58 | + | commit("x2") | |
| 59 | + | out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x") | |
| 60 | + | require.NoError(t, err, out) | |
| 61 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |
| 62 | + | ||
| 63 | + | h.mustGit(wt, "branch", "agent/claude/deep/y") | |
| 64 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y") | |
| 65 | + | require.NoError(t, err, out) | |
| 66 | + | out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y") | |
| 67 | + | require.NoError(t, err, out) | |
| 68 | + | _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y") | |
| 69 | + | require.Error(t, err, "deleted") | |
| 70 | + | ||
| 71 | + | // Every other ref refuses the push, with a message naming it. | |
| 72 | + | before := refs() | |
| 73 | + | for _, tt := range []struct{ name, refspec, ref string }{ | |
| 74 | + | {"main", "HEAD:refs/heads/main", "refs/heads/main"}, | |
| 75 | + | {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"}, | |
| 76 | + | {"delete main", ":refs/heads/main", "refs/heads/main"}, | |
| 77 | + | {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"}, | |
| 78 | + | {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"}, | |
| 79 | + | {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"}, | |
| 80 | + | {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"}, | |
| 81 | + | {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"}, | |
| 82 | + | {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"}, | |
| 83 | + | {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"}, | |
| 84 | + | } { | |
| 85 | + | out, err = h.git(wt, "push", remote, tt.refspec) | |
| 86 | + | require.Error(t, err, tt.name) | |
| 87 | + | assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name) | |
| 88 | + | assert.Equal(t, before, refs(), "%s: nothing moved", tt.name) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // A mixed push is refused as a whole: the allowed ref does not move either. | |
| 92 | + | commit("x3") | |
| 93 | + | out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main") | |
| 94 | + | require.Error(t, err) | |
| 95 | + | assert.Contains(t, out, "refs/heads/main is outside") | |
| 96 | + | assert.Equal(t, before, refs()) | |
| 97 | + | out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2") | |
| 98 | + | require.Error(t, err) | |
| 99 | + | assert.Contains(t, out, "refs/tags/v2 is outside") | |
| 100 | + | assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both") | |
| 101 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 102 | + | ||
| 103 | + | // It cannot create a repository: not by push-to-create, not by the API. | |
| 104 | + | h.mustGit(wt, "checkout", "-q", "agent/claude/x") | |
| 105 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x") | |
| 106 | + | require.Error(t, err, out) | |
| 107 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |
| 108 | + | anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository | |
| 109 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x") | |
| 110 | + | require.Error(t, err, out) | |
| 111 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |
| 112 | + | assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo)) | |
| 113 | + | _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |
| 114 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 115 | + | assert.NoDirExists(t, h.repoDir("via-api")) | |
| 116 | + | // The same token without prefixes does both. | |
| 117 | + | free := h.token(scopes(domain.ScopeRepoWrite)) | |
| 118 | + | _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | ||
| 121 | + | // It cannot delete a repository, even with repo:admin. | |
| 122 | + | admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api") | |
| 123 | + | _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"})) | |
| 124 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 125 | + | assert.DirExists(t, h.repoDir("via-api")) | |
| 126 | + | // ... nor change its settings (default branch, visibility, ...). | |
| 127 | + | _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 128 | + | Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 129 | + | })) | |
| 130 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 131 | + | got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"})) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility()) | |
| 134 | + | ||
| 135 | + | // It never has owner rights, whatever its scopes. | |
| 136 | + | all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"}) | |
| 137 | + | _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 138 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 139 | + | _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 140 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // AC5: merge requests write branches: a limited token merges only into its prefixes. | |
| 144 | + | func TestAC5BranchLimitedMerge(t *testing.T) { | |
| 145 | + | h := newHarness(t, harnessOpts{}) | |
| 146 | + | wt := h.workTree("main", "a.txt") | |
| 147 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 148 | + | require.NoError(t, err, out) | |
| 149 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat") | |
| 150 | + | h.writeFile(wt, "f.txt", []byte("f\n")) | |
| 151 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat") | |
| 152 | + | feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat") | |
| 153 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |
| 154 | + | ||
| 155 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj") | |
| 156 | + | c := h.api(secret) | |
| 157 | + | created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |
| 158 | + | Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat", | |
| 159 | + | })) | |
| 160 | + | require.NoError(t, err, "opening a request writes no ref") | |
| 161 | + | n := created.Msg.GetMergeRequest().GetNumber() | |
| 162 | + | ||
| 163 | + | for _, del := range []bool{false, true} { | |
| 164 | + | _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | |
| 165 | + | Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del, | |
| 166 | + | })) | |
| 167 | + | assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del) | |
| 168 | + | } | |
| 169 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 170 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")) | |
| 171 | + | ||
| 172 | + | // The merge goes through for a token without prefixes. | |
| 173 | + | m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))) | |
| 174 | + | _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat})) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // AC5: a limited token only creates tokens within its prefixes, over the API. | |
| 180 | + | func TestAC5BranchLimitedTokenCreation(t *testing.T) { | |
| 181 | + | h := newHarness(t, harnessOpts{}) | |
| 182 | + | secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"}) | |
| 183 | + | c := h.api(secret) | |
| 184 | + | create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) { | |
| 185 | + | return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{ | |
| 186 | + | Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes, | |
| 187 | + | })) | |
| 188 | + | } | |
| 189 | + | _, err := create() | |
| 190 | + | assert.Equal(t, "permission_denied", codeName(err), "no prefixes") | |
| 191 | + | _, err = create("agent/") | |
| 192 | + | assert.Equal(t, "permission_denied", codeName(err), "wider") | |
| 193 | + | _, err = create("agent/claudex/") | |
| 194 | + | assert.Equal(t, "permission_denied", codeName(err), "sibling") | |
| 195 | + | _, err = create("agent") | |
| 196 | + | assert.Equal(t, "invalid_argument", codeName(err), "malformed") | |
| 197 | + | res, err := create("agent/claude/sub/") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes()) | |
| 200 | + | ||
| 201 | + | // The listing shows the prefixes. | |
| 202 | + | list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | var seen int | |
| 205 | + | for _, tk := range list.Msg.GetTokens() { | |
| 206 | + | if tk.GetLabel() == "child" { | |
| 207 | + | assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes()) | |
| 208 | + | seen++ | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | assert.Equal(t, 1, seen) | |
| 212 | + | } | |
M internal/app/e2e_helpers_test.go +11 -1
| @@ -55,6 +55,12 @@ type harnessOpts struct { | |||
| 55 | 55 | // newHarness starts a forge. The data directory is short: the hook socket | |
| 56 | 56 | // lives in it and Unix socket paths are limited to about 100 bytes. | |
| 57 | 57 | func newHarness(t *testing.T, o harnessOpts) *harness { | |
| 58 | + | t.Helper() | |
| 59 | + | return newHarnessWith(t, o, nil) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // newHarnessWith is newHarness with a change of the wiring options. | |
| 63 | + | func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness { | |
| 58 | 64 | t.Helper() | |
| 59 | 65 | gitBin := requireGit(t) | |
| 60 | 66 | data, err := os.MkdirTemp("", "ermite") | |
| @@ -90,7 +96,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness { | |||
| 90 | 96 | if hookExe == "" { | |
| 91 | 97 | hookExe = binary(t) | |
| 92 | 98 | } | |
| 93 | - | f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}) | |
| 99 | + | opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery} | |
| 100 | + | if tweak != nil { | |
| 101 | + | tweak(&opts) | |
| 102 | + | } | |
| 103 | + | f, err := newForge(ctx, cfg, opts) | |
| 94 | 104 | require.NoError(t, err) | |
| 95 | 105 | ||
| 96 | 106 | 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" | |
| @@ -49,6 +50,9 @@ type options struct { | |||
| 49 | 50 | eventBuffer int | |
| 50 | 51 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 51 | 52 | revalidateEvery time.Duration | |
| 53 | + | // ci overrides the settings of the CI engine (tests shorten its | |
| 54 | + | // durations). | |
| 55 | + | ci func(*usecase.CIConfig) | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | 58 | // forge is the wired server: every adapter and use case, ready to serve. | |
| @@ -77,8 +81,11 @@ type forge struct { | |||
| 77 | 81 | // Passkeys is nil when passkeys are not available (passkeysAvailable). | |
| 78 | 82 | Passkeys *usecase.Passkeys | |
| 79 | 83 | EventLog *usecase.Events | |
| 80 | - | API *rpc.API | |
| 81 | - | Bus ports.EventBus | |
| 84 | + | // CI is the CI engine: workflow trigger, planner, runner protocol and the | |
| 85 | + | // owner side. It starts with the server and stops with it. | |
| 86 | + | CI *usecase.CI | |
| 87 | + | API *rpc.API | |
| 88 | + | Bus ports.EventBus | |
| 82 | 89 | } | |
| 83 | 90 | ||
| 84 | 91 | // newSigningSeed makes the seed of a new ed25519 signing key. | |
| @@ -185,6 +192,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 185 | 192 | } | |
| 186 | 193 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | |
| 187 | 194 | f.Browse = usecase.NewBrowse(store, reader) | |
| 195 | + | ||
| 196 | + | // CI (ADR 0025): the engine reads workflows through the reader, and plans | |
| 197 | + | // them in the background; a push only queues its refs. | |
| 198 | + | ciCfg := usecase.CIConfig{ | |
| 199 | + | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | |
| 200 | + | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 201 | + | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 202 | + | } | |
| 203 | + | if opts.ci != nil { | |
| 204 | + | opts.ci(&ciCfg) | |
| 205 | + | } | |
| 206 | + | f.CI = usecase.NewCI(ctx, ciCfg) | |
| 207 | + | f.Pushes.WithWorkflowTrigger(f.CI) | |
| 208 | + | f.Repos.WithCIRunCleaner(f.CI) | |
| 209 | + | f.CI.Start() | |
| 188 | 210 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | |
| 189 | 211 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | |
| 190 | 212 | }) | |
| @@ -203,6 +225,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 203 | 225 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 204 | 226 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 205 | 227 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 228 | + | CI: f.CI, Checks: f.CI, | |
| 206 | 229 | }) | |
| 207 | 230 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 208 | 231 | ||
| @@ -243,6 +266,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 243 | 266 | SigningKey: f.SigningKey, | |
| 244 | 267 | Sessions: f.Sessions, | |
| 245 | 268 | Passkeys: apiPasskeys, | |
| 269 | + | Runner: f.CI, | |
| 270 | + | Runs: f.CI, | |
| 246 | 271 | Version: serverVersion(), | |
| 247 | 272 | TrustedProxies: cfg.TrustedProxies, | |
| 248 | 273 | Limiter: limiter, | |
| @@ -320,6 +345,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 320 | 345 | if f.hooks != nil { | |
| 321 | 346 | f.hooks.Close(ctx) | |
| 322 | 347 | } | |
| 348 | + | if f.CI != nil { | |
| 349 | + | f.CI.Close(ctx) | |
| 350 | + | } | |
| 323 | 351 | if f.EventLog != nil { | |
| 324 | 352 | f.EventLog.Close(ctx) | |
| 325 | 353 | } | |
M internal/app/root.go +9 -3
| @@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 18 | 18 | Short: "A single-user Git forge", | |
| 19 | 19 | Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" + | |
| 20 | 20 | "drive a running server over its API.\n\n" + | |
| 21 | - | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | |
| 22 | - | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | |
| 21 | + | "Remote commands need the server address (http:// or https://) and, except for public reads,\n" + | |
| 22 | + | "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" + | |
| 23 | + | " ERMITE_URL / ERMITE_TOKEN the environment\n" + | |
| 24 | + | " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" + | |
| 25 | + | "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" + | |
| 26 | + | "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" + | |
| 27 | + | "See `ermite config --help`.", | |
| 23 | 28 | SilenceUsage: true, | |
| 24 | 29 | SilenceErrors: true, // cmd/ermite prints the error once | |
| 25 | 30 | } | |
| 26 | 31 | root.AddCommand( | |
| 27 | 32 | newServeCmd(), newHookCmd(), | |
| 28 | 33 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), | |
| 34 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | + | newRunCmd(env), newRunnerCmd(env), | |
| 30 | 36 | ) | |
| 31 | 37 | return root | |
| 32 | 38 | } | |
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
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </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-code.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -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="/files/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="/files/tree/feature/x" tabindex="-1"> | |
| 385 | 397 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-empty.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </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-image.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -312,6 +318,12 @@ | |||
| 312 | 318 | <span class="k">section</span> | |
| 313 | 319 | </a> | |
| 314 | 320 | </li> | |
| 321 | + | <li data-k="section actions"> | |
| 322 | + | <a href="/files/actions" tabindex="-1"> | |
| 323 | + | <span class="lbl">actions</span> | |
| 324 | + | <span class="k">section</span> | |
| 325 | + | </a> | |
| 326 | + | </li> | |
| 315 | 327 | <li data-k="branch feature/x"> | |
| 316 | 328 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 317 | 329 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-markdown.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -88,6 +90,10 @@ | |||
| 88 | 90 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 89 | 91 | </a> | |
| 90 | 92 | </li> | |
| 93 | + | <li> | |
| 94 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 95 | + | </a> | |
| 96 | + | </li> | |
| 91 | 97 | </ul> | |
| 92 | 98 | </nav> | |
| 93 | 99 | </header> | |
| @@ -377,6 +383,12 @@ | |||
| 377 | 383 | <span class="k">section</span> | |
| 378 | 384 | </a> | |
| 379 | 385 | </li> | |
| 386 | + | <li data-k="section actions"> | |
| 387 | + | <a href="/files/actions" tabindex="-1"> | |
| 388 | + | <span class="lbl">actions</span> | |
| 389 | + | <span class="k">section</span> | |
| 390 | + | </a> | |
| 391 | + | </li> | |
| 380 | 392 | <li data-k="branch feature/x"> | |
| 381 | 393 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 382 | 394 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-svg.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -335,6 +341,12 @@ | |||
| 335 | 341 | <span class="k">section</span> | |
| 336 | 342 | </a> | |
| 337 | 343 | </li> | |
| 344 | + | <li data-k="section actions"> | |
| 345 | + | <a href="/files/actions" tabindex="-1"> | |
| 346 | + | <span class="lbl">actions</span> | |
| 347 | + | <span class="k">section</span> | |
| 348 | + | </a> | |
| 349 | + | </li> | |
| 338 | 350 | <li data-k="branch feature/x"> | |
| 339 | 351 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 340 | 352 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-text.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -330,6 +336,12 @@ | |||
| 330 | 336 | <span class="k">section</span> | |
| 331 | 337 | </a> | |
| 332 | 338 | </li> | |
| 339 | + | <li data-k="section actions"> | |
| 340 | + | <a href="/files/actions" tabindex="-1"> | |
| 341 | + | <span class="lbl">actions</span> | |
| 342 | + | <span class="k">section</span> | |
| 343 | + | </a> | |
| 344 | + | </li> | |
| 333 | 345 | <li data-k="branch feature/x"> | |
| 334 | 346 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 335 | 347 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/blob-too-large.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </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-trojan.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -377,6 +383,12 @@ | |||
| 377 | 383 | <span class="k">section</span> | |
| 378 | 384 | </a> | |
| 379 | 385 | </li> | |
| 386 | + | <li data-k="section actions"> | |
| 387 | + | <a href="/files/actions" tabindex="-1"> | |
| 388 | + | <span class="lbl">actions</span> | |
| 389 | + | <span class="k">section</span> | |
| 390 | + | </a> | |
| 391 | + | </li> | |
| 380 | 392 | <li data-k="branch feature/x"> | |
| 381 | 393 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 382 | 394 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/commit-big.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -347,6 +353,12 @@ | |||
| 347 | 353 | <span class="k">section</span> | |
| 348 | 354 | </a> | |
| 349 | 355 | </li> | |
| 356 | + | <li data-k="section actions"> | |
| 357 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 358 | + | <span class="lbl">actions</span> | |
| 359 | + | <span class="k">section</span> | |
| 360 | + | </a> | |
| 361 | + | </li> | |
| 350 | 362 | <li data-k="branch main"> | |
| 351 | 363 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 352 | 364 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-binary.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -342,6 +348,12 @@ | |||
| 342 | 348 | <span class="k">section</span> | |
| 343 | 349 | </a> | |
| 344 | 350 | </li> | |
| 351 | + | <li data-k="section actions"> | |
| 352 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 353 | + | <span class="lbl">actions</span> | |
| 354 | + | <span class="k">section</span> | |
| 355 | + | </a> | |
| 356 | + | </li> | |
| 345 | 357 | <li data-k="branch main"> | |
| 346 | 358 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 347 | 359 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-merge.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -381,6 +387,12 @@ | |||
| 381 | 387 | <span class="k">section</span> | |
| 382 | 388 | </a> | |
| 383 | 389 | </li> | |
| 390 | + | <li data-k="section actions"> | |
| 391 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 392 | + | <span class="lbl">actions</span> | |
| 393 | + | <span class="k">section</span> | |
| 394 | + | </a> | |
| 395 | + | </li> | |
| 384 | 396 | <li data-k="branch main"> | |
| 385 | 397 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 386 | 398 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-modify-split.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -502,6 +508,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 502 | 508 | <span class="k">section</span> | |
| 503 | 509 | </a> | |
| 504 | 510 | </li> | |
| 511 | + | <li data-k="section actions"> | |
| 512 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 513 | + | <span class="lbl">actions</span> | |
| 514 | + | <span class="k">section</span> | |
| 515 | + | </a> | |
| 516 | + | </li> | |
| 505 | 517 | <li data-k="branch main"> | |
| 506 | 518 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 507 | 519 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-modify.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -502,6 +508,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 502 | 508 | <span class="k">section</span> | |
| 503 | 509 | </a> | |
| 504 | 510 | </li> | |
| 511 | + | <li data-k="section actions"> | |
| 512 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 513 | + | <span class="lbl">actions</span> | |
| 514 | + | <span class="k">section</span> | |
| 515 | + | </a> | |
| 516 | + | </li> | |
| 505 | 517 | <li data-k="branch main"> | |
| 506 | 518 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 507 | 519 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-rename.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -407,6 +413,12 @@ | |||
| 407 | 413 | <span class="k">section</span> | |
| 408 | 414 | </a> | |
| 409 | 415 | </li> | |
| 416 | + | <li data-k="section actions"> | |
| 417 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 418 | + | <span class="lbl">actions</span> | |
| 419 | + | <span class="k">section</span> | |
| 420 | + | </a> | |
| 421 | + | </li> | |
| 410 | 422 | <li data-k="branch main"> | |
| 411 | 423 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 412 | 424 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-root.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -723,6 +729,12 @@ | |||
| 723 | 729 | <span class="k">section</span> | |
| 724 | 730 | </a> | |
| 725 | 731 | </li> | |
| 732 | + | <li data-k="section actions"> | |
| 733 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 734 | + | <span class="lbl">actions</span> | |
| 735 | + | <span class="k">section</span> | |
| 736 | + | </a> | |
| 737 | + | </li> | |
| 726 | 738 | <li data-k="branch main"> | |
| 727 | 739 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 728 | 740 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commit-trojan.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -516,6 +522,12 @@ | |||
| 516 | 522 | <span class="k">section</span> | |
| 517 | 523 | </a> | |
| 518 | 524 | </li> | |
| 525 | + | <li data-k="section actions"> | |
| 526 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 527 | + | <span class="lbl">actions</span> | |
| 528 | + | <span class="k">section</span> | |
| 529 | + | </a> | |
| 530 | + | </li> | |
| 519 | 531 | <li data-k="branch main"> | |
| 520 | 532 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 521 | 533 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-page2.html +16 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -105,6 +111,8 @@ | |||
| 105 | 111 | <td class="c-subject"> | |
| 106 | 112 | <a href="/long/commit/SHA40">commit number 1</a> | |
| 107 | 113 | </td> | |
| 114 | + | <td class="c-ck"> | |
| 115 | + | </td> | |
| 108 | 116 | <td class="c-who dim">T</td> | |
| 109 | 117 | <td class="c-when nowrap dim"> | |
| 110 | 118 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -117,6 +125,8 @@ | |||
| 117 | 125 | <td class="c-subject"> | |
| 118 | 126 | <a href="/long/commit/SHA40">commit number 0</a> | |
| 119 | 127 | </td> | |
| 128 | + | <td class="c-ck"> | |
| 129 | + | </td> | |
| 120 | 130 | <td class="c-who dim">T</td> | |
| 121 | 131 | <td class="c-when nowrap dim"> | |
| 122 | 132 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -329,6 +339,12 @@ | |||
| 329 | 339 | <span class="k">section</span> | |
| 330 | 340 | </a> | |
| 331 | 341 | </li> | |
| 342 | + | <li data-k="section actions"> | |
| 343 | + | <a href="/long/actions" tabindex="-1"> | |
| 344 | + | <span class="lbl">actions</span> | |
| 345 | + | <span class="k">section</span> | |
| 346 | + | </a> | |
| 347 | + | </li> | |
| 332 | 348 | <li data-k="branch main"> | |
| 333 | 349 | <a href="/long/tree/main" tabindex="-1"> | |
| 334 | 350 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-path.html +16 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -109,6 +115,8 @@ | |||
| 109 | 115 | <td class="c-subject"> | |
| 110 | 116 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 111 | 117 | </td> | |
| 118 | + | <td class="c-ck"> | |
| 119 | + | </td> | |
| 112 | 120 | <td class="c-who dim">T</td> | |
| 113 | 121 | <td class="c-when nowrap dim"> | |
| 114 | 122 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -130,6 +138,8 @@ | |||
| 130 | 138 | <td class="c-subject"> | |
| 131 | 139 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 132 | 140 | </td> | |
| 141 | + | <td class="c-ck"> | |
| 142 | + | </td> | |
| 133 | 143 | <td class="c-who dim">T</td> | |
| 134 | 144 | <td class="c-when nowrap dim"> | |
| 135 | 145 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -344,6 +354,12 @@ | |||
| 344 | 354 | <span class="k">section</span> | |
| 345 | 355 | </a> | |
| 346 | 356 | </li> | |
| 357 | + | <li data-k="section actions"> | |
| 358 | + | <a href="/long/actions" tabindex="-1"> | |
| 359 | + | <span class="lbl">actions</span> | |
| 360 | + | <span class="k">section</span> | |
| 361 | + | </a> | |
| 362 | + | </li> | |
| 347 | 363 | <li data-k="branch main"> | |
| 348 | 364 | <a href="/long/tree/main" tabindex="-1"> | |
| 349 | 365 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-root.html +113 -1
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -108,6 +114,8 @@ | |||
| 108 | 114 | <a href="/long/commit/SHA40">commit number 51</a> | |
| 109 | 115 | <span class="pill">main</span> | |
| 110 | 116 | </td> | |
| 117 | + | <td class="c-ck"> | |
| 118 | + | </td> | |
| 111 | 119 | <td class="c-who dim">T</td> | |
| 112 | 120 | <td class="c-when nowrap dim"> | |
| 113 | 121 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -120,6 +128,8 @@ | |||
| 120 | 128 | <td class="c-subject"> | |
| 121 | 129 | <a href="/long/commit/SHA40">commit number 50</a> | |
| 122 | 130 | </td> | |
| 131 | + | <td class="c-ck"> | |
| 132 | + | </td> | |
| 123 | 133 | <td class="c-who dim">T</td> | |
| 124 | 134 | <td class="c-when nowrap dim"> | |
| 125 | 135 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -141,6 +151,8 @@ | |||
| 141 | 151 | <td class="c-subject"> | |
| 142 | 152 | <a href="/long/commit/SHA40">commit number 49</a> | |
| 143 | 153 | </td> | |
| 154 | + | <td class="c-ck"> | |
| 155 | + | </td> | |
| 144 | 156 | <td class="c-who dim">T</td> | |
| 145 | 157 | <td class="c-when nowrap dim"> | |
| 146 | 158 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -153,6 +165,8 @@ | |||
| 153 | 165 | <td class="c-subject"> | |
| 154 | 166 | <a href="/long/commit/SHA40">commit number 48</a> | |
| 155 | 167 | </td> | |
| 168 | + | <td class="c-ck"> | |
| 169 | + | </td> | |
| 156 | 170 | <td class="c-who dim">T</td> | |
| 157 | 171 | <td class="c-when nowrap dim"> | |
| 158 | 172 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -174,6 +188,8 @@ | |||
| 174 | 188 | <td class="c-subject"> | |
| 175 | 189 | <a href="/long/commit/SHA40">commit number 47</a> | |
| 176 | 190 | </td> | |
| 191 | + | <td class="c-ck"> | |
| 192 | + | </td> | |
| 177 | 193 | <td class="c-who dim">T</td> | |
| 178 | 194 | <td class="c-when nowrap dim"> | |
| 179 | 195 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -186,6 +202,8 @@ | |||
| 186 | 202 | <td class="c-subject"> | |
| 187 | 203 | <a href="/long/commit/SHA40">commit number 46</a> | |
| 188 | 204 | </td> | |
| 205 | + | <td class="c-ck"> | |
| 206 | + | </td> | |
| 189 | 207 | <td class="c-who dim">T</td> | |
| 190 | 208 | <td class="c-when nowrap dim"> | |
| 191 | 209 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -207,6 +225,8 @@ | |||
| 207 | 225 | <td class="c-subject"> | |
| 208 | 226 | <a href="/long/commit/SHA40">commit number 45</a> | |
| 209 | 227 | </td> | |
| 228 | + | <td class="c-ck"> | |
| 229 | + | </td> | |
| 210 | 230 | <td class="c-who dim">T</td> | |
| 211 | 231 | <td class="c-when nowrap dim"> | |
| 212 | 232 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -219,6 +239,8 @@ | |||
| 219 | 239 | <td class="c-subject"> | |
| 220 | 240 | <a href="/long/commit/SHA40">commit number 44</a> | |
| 221 | 241 | </td> | |
| 242 | + | <td class="c-ck"> | |
| 243 | + | </td> | |
| 222 | 244 | <td class="c-who dim">T</td> | |
| 223 | 245 | <td class="c-when nowrap dim"> | |
| 224 | 246 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -240,6 +262,8 @@ | |||
| 240 | 262 | <td class="c-subject"> | |
| 241 | 263 | <a href="/long/commit/SHA40">commit number 43</a> | |
| 242 | 264 | </td> | |
| 265 | + | <td class="c-ck"> | |
| 266 | + | </td> | |
| 243 | 267 | <td class="c-who dim">T</td> | |
| 244 | 268 | <td class="c-when nowrap dim"> | |
| 245 | 269 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -252,6 +276,8 @@ | |||
| 252 | 276 | <td class="c-subject"> | |
| 253 | 277 | <a href="/long/commit/SHA40">commit number 42</a> | |
| 254 | 278 | </td> | |
| 279 | + | <td class="c-ck"> | |
| 280 | + | </td> | |
| 255 | 281 | <td class="c-who dim">T</td> | |
| 256 | 282 | <td class="c-when nowrap dim"> | |
| 257 | 283 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -273,6 +299,8 @@ | |||
| 273 | 299 | <td class="c-subject"> | |
| 274 | 300 | <a href="/long/commit/SHA40">commit number 41</a> | |
| 275 | 301 | </td> | |
| 302 | + | <td class="c-ck"> | |
| 303 | + | </td> | |
| 276 | 304 | <td class="c-who dim">T</td> | |
| 277 | 305 | <td class="c-when nowrap dim"> | |
| 278 | 306 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -285,6 +313,8 @@ | |||
| 285 | 313 | <td class="c-subject"> | |
| 286 | 314 | <a href="/long/commit/SHA40">commit number 40</a> | |
| 287 | 315 | </td> | |
| 316 | + | <td class="c-ck"> | |
| 317 | + | </td> | |
| 288 | 318 | <td class="c-who dim">T</td> | |
| 289 | 319 | <td class="c-when nowrap dim"> | |
| 290 | 320 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -306,6 +336,8 @@ | |||
| 306 | 336 | <td class="c-subject"> | |
| 307 | 337 | <a href="/long/commit/SHA40">commit number 39</a> | |
| 308 | 338 | </td> | |
| 339 | + | <td class="c-ck"> | |
| 340 | + | </td> | |
| 309 | 341 | <td class="c-who dim">T</td> | |
| 310 | 342 | <td class="c-when nowrap dim"> | |
| 311 | 343 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -318,6 +350,8 @@ | |||
| 318 | 350 | <td class="c-subject"> | |
| 319 | 351 | <a href="/long/commit/SHA40">commit number 38</a> | |
| 320 | 352 | </td> | |
| 353 | + | <td class="c-ck"> | |
| 354 | + | </td> | |
| 321 | 355 | <td class="c-who dim">T</td> | |
| 322 | 356 | <td class="c-when nowrap dim"> | |
| 323 | 357 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -339,6 +373,8 @@ | |||
| 339 | 373 | <td class="c-subject"> | |
| 340 | 374 | <a href="/long/commit/SHA40">commit number 37</a> | |
| 341 | 375 | </td> | |
| 376 | + | <td class="c-ck"> | |
| 377 | + | </td> | |
| 342 | 378 | <td class="c-who dim">T</td> | |
| 343 | 379 | <td class="c-when nowrap dim"> | |
| 344 | 380 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -351,6 +387,8 @@ | |||
| 351 | 387 | <td class="c-subject"> | |
| 352 | 388 | <a href="/long/commit/SHA40">commit number 36</a> | |
| 353 | 389 | </td> | |
| 390 | + | <td class="c-ck"> | |
| 391 | + | </td> | |
| 354 | 392 | <td class="c-who dim">T</td> | |
| 355 | 393 | <td class="c-when nowrap dim"> | |
| 356 | 394 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -372,6 +410,8 @@ | |||
| 372 | 410 | <td class="c-subject"> | |
| 373 | 411 | <a href="/long/commit/SHA40">commit number 35</a> | |
| 374 | 412 | </td> | |
| 413 | + | <td class="c-ck"> | |
| 414 | + | </td> | |
| 375 | 415 | <td class="c-who dim">T</td> | |
| 376 | 416 | <td class="c-when nowrap dim"> | |
| 377 | 417 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -384,6 +424,8 @@ | |||
| 384 | 424 | <td class="c-subject"> | |
| 385 | 425 | <a href="/long/commit/SHA40">commit number 34</a> | |
| 386 | 426 | </td> | |
| 427 | + | <td class="c-ck"> | |
| 428 | + | </td> | |
| 387 | 429 | <td class="c-who dim">T</td> | |
| 388 | 430 | <td class="c-when nowrap dim"> | |
| 389 | 431 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -405,6 +447,8 @@ | |||
| 405 | 447 | <td class="c-subject"> | |
| 406 | 448 | <a href="/long/commit/SHA40">commit number 33</a> | |
| 407 | 449 | </td> | |
| 450 | + | <td class="c-ck"> | |
| 451 | + | </td> | |
| 408 | 452 | <td class="c-who dim">T</td> | |
| 409 | 453 | <td class="c-when nowrap dim"> | |
| 410 | 454 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -417,6 +461,8 @@ | |||
| 417 | 461 | <td class="c-subject"> | |
| 418 | 462 | <a href="/long/commit/SHA40">commit number 32</a> | |
| 419 | 463 | </td> | |
| 464 | + | <td class="c-ck"> | |
| 465 | + | </td> | |
| 420 | 466 | <td class="c-who dim">T</td> | |
| 421 | 467 | <td class="c-when nowrap dim"> | |
| 422 | 468 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -438,6 +484,8 @@ | |||
| 438 | 484 | <td class="c-subject"> | |
| 439 | 485 | <a href="/long/commit/SHA40">commit number 31</a> | |
| 440 | 486 | </td> | |
| 487 | + | <td class="c-ck"> | |
| 488 | + | </td> | |
| 441 | 489 | <td class="c-who dim">T</td> | |
| 442 | 490 | <td class="c-when nowrap dim"> | |
| 443 | 491 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -450,6 +498,8 @@ | |||
| 450 | 498 | <td class="c-subject"> | |
| 451 | 499 | <a href="/long/commit/SHA40">commit number 30</a> | |
| 452 | 500 | </td> | |
| 501 | + | <td class="c-ck"> | |
| 502 | + | </td> | |
| 453 | 503 | <td class="c-who dim">T</td> | |
| 454 | 504 | <td class="c-when nowrap dim"> | |
| 455 | 505 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -471,6 +521,8 @@ | |||
| 471 | 521 | <td class="c-subject"> | |
| 472 | 522 | <a href="/long/commit/SHA40">commit number 29</a> | |
| 473 | 523 | </td> | |
| 524 | + | <td class="c-ck"> | |
| 525 | + | </td> | |
| 474 | 526 | <td class="c-who dim">T</td> | |
| 475 | 527 | <td class="c-when nowrap dim"> | |
| 476 | 528 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -483,6 +535,8 @@ | |||
| 483 | 535 | <td class="c-subject"> | |
| 484 | 536 | <a href="/long/commit/SHA40">commit number 28</a> | |
| 485 | 537 | </td> | |
| 538 | + | <td class="c-ck"> | |
| 539 | + | </td> | |
| 486 | 540 | <td class="c-who dim">T</td> | |
| 487 | 541 | <td class="c-when nowrap dim"> | |
| 488 | 542 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -504,6 +558,8 @@ | |||
| 504 | 558 | <td class="c-subject"> | |
| 505 | 559 | <a href="/long/commit/SHA40">commit number 27</a> | |
| 506 | 560 | </td> | |
| 561 | + | <td class="c-ck"> | |
| 562 | + | </td> | |
| 507 | 563 | <td class="c-who dim">T</td> | |
| 508 | 564 | <td class="c-when nowrap dim"> | |
| 509 | 565 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -516,6 +572,8 @@ | |||
| 516 | 572 | <td class="c-subject"> | |
| 517 | 573 | <a href="/long/commit/SHA40">commit number 26</a> | |
| 518 | 574 | </td> | |
| 575 | + | <td class="c-ck"> | |
| 576 | + | </td> | |
| 519 | 577 | <td class="c-who dim">T</td> | |
| 520 | 578 | <td class="c-when nowrap dim"> | |
| 521 | 579 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -537,6 +595,8 @@ | |||
| 537 | 595 | <td class="c-subject"> | |
| 538 | 596 | <a href="/long/commit/SHA40">commit number 25</a> | |
| 539 | 597 | </td> | |
| 598 | + | <td class="c-ck"> | |
| 599 | + | </td> | |
| 540 | 600 | <td class="c-who dim">T</td> | |
| 541 | 601 | <td class="c-when nowrap dim"> | |
| 542 | 602 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -549,6 +609,8 @@ | |||
| 549 | 609 | <td class="c-subject"> | |
| 550 | 610 | <a href="/long/commit/SHA40">commit number 24</a> | |
| 551 | 611 | </td> | |
| 612 | + | <td class="c-ck"> | |
| 613 | + | </td> | |
| 552 | 614 | <td class="c-who dim">T</td> | |
| 553 | 615 | <td class="c-when nowrap dim"> | |
| 554 | 616 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -570,6 +632,8 @@ | |||
| 570 | 632 | <td class="c-subject"> | |
| 571 | 633 | <a href="/long/commit/SHA40">commit number 23</a> | |
| 572 | 634 | </td> | |
| 635 | + | <td class="c-ck"> | |
| 636 | + | </td> | |
| 573 | 637 | <td class="c-who dim">T</td> | |
| 574 | 638 | <td class="c-when nowrap dim"> | |
| 575 | 639 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -582,6 +646,8 @@ | |||
| 582 | 646 | <td class="c-subject"> | |
| 583 | 647 | <a href="/long/commit/SHA40">commit number 22</a> | |
| 584 | 648 | </td> | |
| 649 | + | <td class="c-ck"> | |
| 650 | + | </td> | |
| 585 | 651 | <td class="c-who dim">T</td> | |
| 586 | 652 | <td class="c-when nowrap dim"> | |
| 587 | 653 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -603,6 +669,8 @@ | |||
| 603 | 669 | <td class="c-subject"> | |
| 604 | 670 | <a href="/long/commit/SHA40">commit number 21</a> | |
| 605 | 671 | </td> | |
| 672 | + | <td class="c-ck"> | |
| 673 | + | </td> | |
| 606 | 674 | <td class="c-who dim">T</td> | |
| 607 | 675 | <td class="c-when nowrap dim"> | |
| 608 | 676 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -615,6 +683,8 @@ | |||
| 615 | 683 | <td class="c-subject"> | |
| 616 | 684 | <a href="/long/commit/SHA40">commit number 20</a> | |
| 617 | 685 | </td> | |
| 686 | + | <td class="c-ck"> | |
| 687 | + | </td> | |
| 618 | 688 | <td class="c-who dim">T</td> | |
| 619 | 689 | <td class="c-when nowrap dim"> | |
| 620 | 690 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -636,6 +706,8 @@ | |||
| 636 | 706 | <td class="c-subject"> | |
| 637 | 707 | <a href="/long/commit/SHA40">commit number 19</a> | |
| 638 | 708 | </td> | |
| 709 | + | <td class="c-ck"> | |
| 710 | + | </td> | |
| 639 | 711 | <td class="c-who dim">T</td> | |
| 640 | 712 | <td class="c-when nowrap dim"> | |
| 641 | 713 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -648,6 +720,8 @@ | |||
| 648 | 720 | <td class="c-subject"> | |
| 649 | 721 | <a href="/long/commit/SHA40">commit number 18</a> | |
| 650 | 722 | </td> | |
| 723 | + | <td class="c-ck"> | |
| 724 | + | </td> | |
| 651 | 725 | <td class="c-who dim">T</td> | |
| 652 | 726 | <td class="c-when nowrap dim"> | |
| 653 | 727 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -669,6 +743,8 @@ | |||
| 669 | 743 | <td class="c-subject"> | |
| 670 | 744 | <a href="/long/commit/SHA40">commit number 17</a> | |
| 671 | 745 | </td> | |
| 746 | + | <td class="c-ck"> | |
| 747 | + | </td> | |
| 672 | 748 | <td class="c-who dim">T</td> | |
| 673 | 749 | <td class="c-when nowrap dim"> | |
| 674 | 750 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -681,6 +757,8 @@ | |||
| 681 | 757 | <td class="c-subject"> | |
| 682 | 758 | <a href="/long/commit/SHA40">commit number 16</a> | |
| 683 | 759 | </td> | |
| 760 | + | <td class="c-ck"> | |
| 761 | + | </td> | |
| 684 | 762 | <td class="c-who dim">T</td> | |
| 685 | 763 | <td class="c-when nowrap dim"> | |
| 686 | 764 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -702,6 +780,8 @@ | |||
| 702 | 780 | <td class="c-subject"> | |
| 703 | 781 | <a href="/long/commit/SHA40">commit number 15</a> | |
| 704 | 782 | </td> | |
| 783 | + | <td class="c-ck"> | |
| 784 | + | </td> | |
| 705 | 785 | <td class="c-who dim">T</td> | |
| 706 | 786 | <td class="c-when nowrap dim"> | |
| 707 | 787 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -714,6 +794,8 @@ | |||
| 714 | 794 | <td class="c-subject"> | |
| 715 | 795 | <a href="/long/commit/SHA40">commit number 14</a> | |
| 716 | 796 | </td> | |
| 797 | + | <td class="c-ck"> | |
| 798 | + | </td> | |
| 717 | 799 | <td class="c-who dim">T</td> | |
| 718 | 800 | <td class="c-when nowrap dim"> | |
| 719 | 801 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -735,6 +817,8 @@ | |||
| 735 | 817 | <td class="c-subject"> | |
| 736 | 818 | <a href="/long/commit/SHA40">commit number 13</a> | |
| 737 | 819 | </td> | |
| 820 | + | <td class="c-ck"> | |
| 821 | + | </td> | |
| 738 | 822 | <td class="c-who dim">T</td> | |
| 739 | 823 | <td class="c-when nowrap dim"> | |
| 740 | 824 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -747,6 +831,8 @@ | |||
| 747 | 831 | <td class="c-subject"> | |
| 748 | 832 | <a href="/long/commit/SHA40">commit number 12</a> | |
| 749 | 833 | </td> | |
| 834 | + | <td class="c-ck"> | |
| 835 | + | </td> | |
| 750 | 836 | <td class="c-who dim">T</td> | |
| 751 | 837 | <td class="c-when nowrap dim"> | |
| 752 | 838 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -768,6 +854,8 @@ | |||
| 768 | 854 | <td class="c-subject"> | |
| 769 | 855 | <a href="/long/commit/SHA40">commit number 11</a> | |
| 770 | 856 | </td> | |
| 857 | + | <td class="c-ck"> | |
| 858 | + | </td> | |
| 771 | 859 | <td class="c-who dim">T</td> | |
| 772 | 860 | <td class="c-when nowrap dim"> | |
| 773 | 861 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -780,6 +868,8 @@ | |||
| 780 | 868 | <td class="c-subject"> | |
| 781 | 869 | <a href="/long/commit/SHA40">commit number 10</a> | |
| 782 | 870 | </td> | |
| 871 | + | <td class="c-ck"> | |
| 872 | + | </td> | |
| 783 | 873 | <td class="c-who dim">T</td> | |
| 784 | 874 | <td class="c-when nowrap dim"> | |
| 785 | 875 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -801,6 +891,8 @@ | |||
| 801 | 891 | <td class="c-subject"> | |
| 802 | 892 | <a href="/long/commit/SHA40">commit number 9</a> | |
| 803 | 893 | </td> | |
| 894 | + | <td class="c-ck"> | |
| 895 | + | </td> | |
| 804 | 896 | <td class="c-who dim">T</td> | |
| 805 | 897 | <td class="c-when nowrap dim"> | |
| 806 | 898 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -813,6 +905,8 @@ | |||
| 813 | 905 | <td class="c-subject"> | |
| 814 | 906 | <a href="/long/commit/SHA40">commit number 8</a> | |
| 815 | 907 | </td> | |
| 908 | + | <td class="c-ck"> | |
| 909 | + | </td> | |
| 816 | 910 | <td class="c-who dim">T</td> | |
| 817 | 911 | <td class="c-when nowrap dim"> | |
| 818 | 912 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -834,6 +928,8 @@ | |||
| 834 | 928 | <td class="c-subject"> | |
| 835 | 929 | <a href="/long/commit/SHA40">commit number 7</a> | |
| 836 | 930 | </td> | |
| 931 | + | <td class="c-ck"> | |
| 932 | + | </td> | |
| 837 | 933 | <td class="c-who dim">T</td> | |
| 838 | 934 | <td class="c-when nowrap dim"> | |
| 839 | 935 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -846,6 +942,8 @@ | |||
| 846 | 942 | <td class="c-subject"> | |
| 847 | 943 | <a href="/long/commit/SHA40">commit number 6</a> | |
| 848 | 944 | </td> | |
| 945 | + | <td class="c-ck"> | |
| 946 | + | </td> | |
| 849 | 947 | <td class="c-who dim">T</td> | |
| 850 | 948 | <td class="c-when nowrap dim"> | |
| 851 | 949 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -867,6 +965,8 @@ | |||
| 867 | 965 | <td class="c-subject"> | |
| 868 | 966 | <a href="/long/commit/SHA40">commit number 5</a> | |
| 869 | 967 | </td> | |
| 968 | + | <td class="c-ck"> | |
| 969 | + | </td> | |
| 870 | 970 | <td class="c-who dim">T</td> | |
| 871 | 971 | <td class="c-when nowrap dim"> | |
| 872 | 972 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -879,6 +979,8 @@ | |||
| 879 | 979 | <td class="c-subject"> | |
| 880 | 980 | <a href="/long/commit/SHA40">commit number 4</a> | |
| 881 | 981 | </td> | |
| 982 | + | <td class="c-ck"> | |
| 983 | + | </td> | |
| 882 | 984 | <td class="c-who dim">T</td> | |
| 883 | 985 | <td class="c-when nowrap dim"> | |
| 884 | 986 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -900,6 +1002,8 @@ | |||
| 900 | 1002 | <td class="c-subject"> | |
| 901 | 1003 | <a href="/long/commit/SHA40">commit number 3</a> | |
| 902 | 1004 | </td> | |
| 1005 | + | <td class="c-ck"> | |
| 1006 | + | </td> | |
| 903 | 1007 | <td class="c-who dim">T</td> | |
| 904 | 1008 | <td class="c-when nowrap dim"> | |
| 905 | 1009 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -912,6 +1016,8 @@ | |||
| 912 | 1016 | <td class="c-subject"> | |
| 913 | 1017 | <a href="/long/commit/SHA40">commit number 2</a> | |
| 914 | 1018 | </td> | |
| 1019 | + | <td class="c-ck"> | |
| 1020 | + | </td> | |
| 915 | 1021 | <td class="c-who dim">T</td> | |
| 916 | 1022 | <td class="c-when nowrap dim"> | |
| 917 | 1023 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -961,7 +1067,7 @@ | |||
| 961 | 1067 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 962 | 1068 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 963 | 1069 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 964 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 1070 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 965 | 1071 | gap -> ermiteLive.reload()"> | |
| 966 | 1072 | </span> | |
| 967 | 1073 | </div> | |
| @@ -1133,6 +1239,12 @@ | |||
| 1133 | 1239 | <span class="k">section</span> | |
| 1134 | 1240 | </a> | |
| 1135 | 1241 | </li> | |
| 1242 | + | <li data-k="section actions"> | |
| 1243 | + | <a href="/long/actions" tabindex="-1"> | |
| 1244 | + | <span class="lbl">actions</span> | |
| 1245 | + | <span class="k">section</span> | |
| 1246 | + | </a> | |
| 1247 | + | </li> | |
| 1136 | 1248 | <li data-k="branch main"> | |
| 1137 | 1249 | <a href="/long/tree/main" tabindex="-1"> | |
| 1138 | 1250 | <span class="lbl">main</span> | |
M internal/app/testdata/web/commits-small.html +29 -1
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -108,6 +114,8 @@ | |||
| 108 | 114 | <a href="/diffs/commit/SHA40">add a very large file</a> | |
| 109 | 115 | <span class="pill">main</span> | |
| 110 | 116 | </td> | |
| 117 | + | <td class="c-ck"> | |
| 118 | + | </td> | |
| 111 | 119 | <td class="c-who dim">T</td> | |
| 112 | 120 | <td class="c-when nowrap dim"> | |
| 113 | 121 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -129,6 +137,8 @@ | |||
| 129 | 137 | <td class="c-subject"> | |
| 130 | 138 | <a href="/diffs/commit/SHA40">add evil.go</a> | |
| 131 | 139 | </td> | |
| 140 | + | <td class="c-ck"> | |
| 141 | + | </td> | |
| 132 | 142 | <td class="c-who dim">T</td> | |
| 133 | 143 | <td class="c-when nowrap dim"> | |
| 134 | 144 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -151,6 +161,8 @@ | |||
| 151 | 161 | <a href="/diffs/commit/SHA40">merge the side branch</a> | |
| 152 | 162 | <span class="pill">merge</span> | |
| 153 | 163 | </td> | |
| 164 | + | <td class="c-ck"> | |
| 165 | + | </td> | |
| 154 | 166 | <td class="c-who dim">T</td> | |
| 155 | 167 | <td class="c-when nowrap dim"> | |
| 156 | 168 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -173,6 +185,8 @@ | |||
| 173 | 185 | <a href="/diffs/commit/SHA40">work on the side</a> | |
| 174 | 186 | <span class="pill">side</span> | |
| 175 | 187 | </td> | |
| 188 | + | <td class="c-ck"> | |
| 189 | + | </td> | |
| 176 | 190 | <td class="c-who dim">T</td> | |
| 177 | 191 | <td class="c-when nowrap dim"> | |
| 178 | 192 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -194,6 +208,8 @@ | |||
| 194 | 208 | <td class="c-subject"> | |
| 195 | 209 | <a href="/diffs/commit/SHA40">change a binary file</a> | |
| 196 | 210 | </td> | |
| 211 | + | <td class="c-ck"> | |
| 212 | + | </td> | |
| 197 | 213 | <td class="c-who dim">T</td> | |
| 198 | 214 | <td class="c-when nowrap dim"> | |
| 199 | 215 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -215,6 +231,8 @@ | |||
| 215 | 231 | <td class="c-subject"> | |
| 216 | 232 | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |
| 217 | 233 | </td> | |
| 234 | + | <td class="c-ck"> | |
| 235 | + | </td> | |
| 218 | 236 | <td class="c-who dim">T</td> | |
| 219 | 237 | <td class="c-when nowrap dim"> | |
| 220 | 238 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -238,6 +256,8 @@ | |||
| 238 | 256 | <span class="pill tag"> | |
| 239 | 257 | <span class="sr">tag </span>v1.0.0</span> | |
| 240 | 258 | </td> | |
| 259 | + | <td class="c-ck"> | |
| 260 | + | </td> | |
| 241 | 261 | <td class="c-who dim">T</td> | |
| 242 | 262 | <td class="c-when nowrap dim"> | |
| 243 | 263 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -259,6 +279,8 @@ | |||
| 259 | 279 | <td class="c-subject"> | |
| 260 | 280 | <a href="/diffs/commit/SHA40">root: the first files</a> | |
| 261 | 281 | </td> | |
| 282 | + | <td class="c-ck"> | |
| 283 | + | </td> | |
| 262 | 284 | <td class="c-who dim">T</td> | |
| 263 | 285 | <td class="c-when nowrap dim"> | |
| 264 | 286 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -306,7 +328,7 @@ | |||
| 306 | 328 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 307 | 329 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 308 | 330 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 309 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 331 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 310 | 332 | gap -> ermiteLive.reload()"> | |
| 311 | 333 | </span> | |
| 312 | 334 | </div> | |
| @@ -486,6 +508,12 @@ | |||
| 486 | 508 | <span class="k">section</span> | |
| 487 | 509 | </a> | |
| 488 | 510 | </li> | |
| 511 | + | <li data-k="section actions"> | |
| 512 | + | <a href="/diffs/actions" tabindex="-1"> | |
| 513 | + | <span class="lbl">actions</span> | |
| 514 | + | <span class="k">section</span> | |
| 515 | + | </a> | |
| 516 | + | </li> | |
| 489 | 517 | <li data-k="branch main"> | |
| 490 | 518 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 491 | 519 | <span class="lbl">main</span> | |
M internal/app/testdata/web/empty.html +2 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/home.html +3 -1
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -115,7 +117,7 @@ | |||
| 115 | 117 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 116 | 118 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 117 | 119 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 118 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 120 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 119 | 121 | gap -> ermiteLive.reload()"> | |
| 120 | 122 | </span> | |
| 121 | 123 | </div> | |
M internal/app/testdata/web/not-found-page.html +2 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/not-found.html +2 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
M internal/app/testdata/web/refs.html +13 -1
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -79,6 +81,10 @@ | |||
| 79 | 81 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 80 | 82 | </a> | |
| 81 | 83 | </li> | |
| 84 | + | <li> | |
| 85 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 86 | + | </a> | |
| 87 | + | </li> | |
| 82 | 88 | </ul> | |
| 83 | 89 | </nav> | |
| 84 | 90 | </header> | |
| @@ -198,7 +204,7 @@ | |||
| 198 | 204 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 199 | 205 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 200 | 206 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 201 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 207 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 202 | 208 | gap -> ermiteLive.reload()"> | |
| 203 | 209 | </span> | |
| 204 | 210 | </div> | |
| @@ -382,6 +388,12 @@ | |||
| 382 | 388 | <span class="k">section</span> | |
| 383 | 389 | </a> | |
| 384 | 390 | </li> | |
| 391 | + | <li data-k="section actions"> | |
| 392 | + | <a href="/demo/actions" tabindex="-1"> | |
| 393 | + | <span class="lbl">actions</span> | |
| 394 | + | <span class="k">section</span> | |
| 395 | + | </a> | |
| 396 | + | </li> | |
| 385 | 397 | <li data-k="branch feature/x"> | |
| 386 | 398 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 387 | 399 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/summary.html +17 -1
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -76,6 +78,10 @@ | |||
| 76 | 78 | <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 77 | 79 | </a> | |
| 78 | 80 | </li> | |
| 81 | + | <li> | |
| 82 | + | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 83 | + | </a> | |
| 84 | + | </li> | |
| 79 | 85 | </ul> | |
| 80 | 86 | </nav> | |
| 81 | 87 | </header> | |
| @@ -168,6 +174,8 @@ | |||
| 168 | 174 | <span class="pill tag"> | |
| 169 | 175 | <span class="sr">tag </span>v0.2.0</span> | |
| 170 | 176 | </td> | |
| 177 | + | <td class="c-ck"> | |
| 178 | + | </td> | |
| 171 | 179 | <td class="c-who dim">T</td> | |
| 172 | 180 | <td class="c-when nowrap dim"> | |
| 173 | 181 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -182,6 +190,8 @@ | |||
| 182 | 190 | <span class="pill tag"> | |
| 183 | 191 | <span class="sr">tag </span>v0.1.0</span> | |
| 184 | 192 | </td> | |
| 193 | + | <td class="c-ck"> | |
| 194 | + | </td> | |
| 185 | 195 | <td class="c-who dim">T</td> | |
| 186 | 196 | <td class="c-when nowrap dim"> | |
| 187 | 197 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| @@ -217,7 +227,7 @@ | |||
| 217 | 227 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 218 | 228 | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 219 | 229 | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 220 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 230 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data); | |
| 221 | 231 | gap -> ermiteLive.reload()"> | |
| 222 | 232 | </span> | |
| 223 | 233 | </div> | |
| @@ -401,6 +411,12 @@ | |||
| 401 | 411 | <span class="k">section</span> | |
| 402 | 412 | </a> | |
| 403 | 413 | </li> | |
| 414 | + | <li data-k="section actions"> | |
| 415 | + | <a href="/demo/actions" tabindex="-1"> | |
| 416 | + | <span class="lbl">actions</span> | |
| 417 | + | <span class="k">section</span> | |
| 418 | + | </a> | |
| 419 | + | </li> | |
| 404 | 420 | <li data-k="branch feature/x"> | |
| 405 | 421 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 406 | 422 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-nested.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -88,6 +90,10 @@ | |||
| 88 | 90 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 89 | 91 | </a> | |
| 90 | 92 | </li> | |
| 93 | + | <li> | |
| 94 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 95 | + | </a> | |
| 96 | + | </li> | |
| 91 | 97 | </ul> | |
| 92 | 98 | </nav> | |
| 93 | 99 | </header> | |
| @@ -330,6 +336,12 @@ | |||
| 330 | 336 | <span class="k">section</span> | |
| 331 | 337 | </a> | |
| 332 | 338 | </li> | |
| 339 | + | <li data-k="section actions"> | |
| 340 | + | <a href="/files/actions" tabindex="-1"> | |
| 341 | + | <span class="lbl">actions</span> | |
| 342 | + | <span class="k">section</span> | |
| 343 | + | </a> | |
| 344 | + | </li> | |
| 333 | 345 | <li data-k="branch feature/x"> | |
| 334 | 346 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 335 | 347 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-root.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -82,6 +84,10 @@ | |||
| 82 | 84 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 83 | 85 | </a> | |
| 84 | 86 | </li> | |
| 87 | + | <li> | |
| 88 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 89 | + | </a> | |
| 90 | + | </li> | |
| 85 | 91 | </ul> | |
| 86 | 92 | </nav> | |
| 87 | 93 | </header> | |
| @@ -557,6 +563,12 @@ | |||
| 557 | 563 | <span class="k">section</span> | |
| 558 | 564 | </a> | |
| 559 | 565 | </li> | |
| 566 | + | <li data-k="section actions"> | |
| 567 | + | <a href="/files/actions" tabindex="-1"> | |
| 568 | + | <span class="lbl">actions</span> | |
| 569 | + | <span class="k">section</span> | |
| 570 | + | </a> | |
| 571 | + | </li> | |
| 560 | 572 | <li data-k="branch feature/x"> | |
| 561 | 573 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 562 | 574 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-slash-ref.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -327,6 +333,12 @@ | |||
| 327 | 333 | <span class="k">section</span> | |
| 328 | 334 | </a> | |
| 329 | 335 | </li> | |
| 336 | + | <li data-k="section actions"> | |
| 337 | + | <a href="/files/actions" tabindex="-1"> | |
| 338 | + | <span class="lbl">actions</span> | |
| 339 | + | <span class="k">section</span> | |
| 340 | + | </a> | |
| 341 | + | </li> | |
| 330 | 342 | <li data-k="branch feature/x"> | |
| 331 | 343 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 332 | 344 | <span class="lbl">feature/x</span> | |
M internal/app/testdata/web/tree-subdir.html +12 -0
| @@ -21,6 +21,8 @@ | |||
| 21 | 21 | </script> | |
| 22 | 22 | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | 23 | </script> | |
| 24 | + | <script src="/-/static/js/log.HASH.js" defer> | |
| 25 | + | </script> | |
| 24 | 26 | </head> | |
| 25 | 27 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 26 | 28 | <a class="skip" href="#main">skip to content</a> | |
| @@ -85,6 +87,10 @@ | |||
| 85 | 87 | <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd> | |
| 86 | 88 | </a> | |
| 87 | 89 | </li> | |
| 90 | + | <li> | |
| 91 | + | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd> | |
| 92 | + | </a> | |
| 93 | + | </li> | |
| 88 | 94 | </ul> | |
| 89 | 95 | </nav> | |
| 90 | 96 | </header> | |
| @@ -352,6 +358,12 @@ | |||
| 352 | 358 | <span class="k">section</span> | |
| 353 | 359 | </a> | |
| 354 | 360 | </li> | |
| 361 | + | <li data-k="section actions"> | |
| 362 | + | <a href="/files/actions" tabindex="-1"> | |
| 363 | + | <span class="lbl">actions</span> | |
| 364 | + | <span class="k">section</span> | |
| 365 | + | </a> | |
| 366 | + | </li> | |
| 355 | 367 | <li data-k="branch feature/x"> | |
| 356 | 368 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 357 | 369 | <span class="lbl">feature/x</span> | |
M internal/app/web.go +1 -0
| @@ -39,6 +39,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 39 | 39 | Repos: f.Repos, | |
| 40 | 40 | Browse: f.Browse, | |
| 41 | 41 | MergeRequests: f.MergeRequests, | |
| 42 | + | Runs: f.CI, | |
| 42 | 43 | Events: f.EventLog, | |
| 43 | 44 | PublicURL: cfg.PublicURL, | |
| 44 | 45 | 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 | + | } | |
M internal/domain/principal.go +67 -15
| @@ -4,6 +4,7 @@ package domain | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "slices" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -22,12 +23,14 @@ const ( | |||
| 22 | 23 | // token, the owner signed in on the web, or a stored token with its scopes | |
| 23 | 24 | // and repositories. | |
| 24 | 25 | type Principal struct { | |
| 25 | - | kind principalKind | |
| 26 | - | tokenID string | |
| 27 | - | scopes []Scope | |
| 28 | - | repos []RepoName | |
| 29 | - | expiresAt time.Time | |
| 30 | - | label string | |
| 26 | + | kind principalKind | |
| 27 | + | tokenID string | |
| 28 | + | scopes []Scope | |
| 29 | + | repos []RepoName | |
| 30 | + | // branchPrefixes limits the branches a token may write; empty = all. | |
| 31 | + | branchPrefixes []string | |
| 32 | + | expiresAt time.Time | |
| 33 | + | label string | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // Anonymous returns the principal of a caller without credentials. | |
| @@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} } | |||
| 44 | 47 | // TokenPrincipal returns the principal of the holder of a stored token. | |
| 45 | 48 | func TokenPrincipal(t Token) Principal { | |
| 46 | 49 | return Principal{ | |
| 47 | - | kind: kindToken, | |
| 48 | - | tokenID: t.ID, | |
| 49 | - | scopes: slices.Clone(t.Scopes), | |
| 50 | - | repos: slices.Clone(t.Repos), | |
| 51 | - | expiresAt: t.ExpiresAt, | |
| 52 | - | label: t.Label, | |
| 50 | + | kind: kindToken, | |
| 51 | + | tokenID: t.ID, | |
| 52 | + | scopes: slices.Clone(t.Scopes), | |
| 53 | + | repos: slices.Clone(t.Repos), | |
| 54 | + | branchPrefixes: slices.Clone(t.BranchPrefixes), | |
| 55 | + | expiresAt: t.ExpiresAt, | |
| 56 | + | label: t.Label, | |
| 53 | 57 | } | |
| 54 | 58 | } | |
| 55 | 59 | ||
| @@ -72,13 +76,13 @@ func (p Principal) Label() string { | |||
| 72 | 76 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | |
| 73 | 77 | ||
| 74 | 78 | // HasOwnerRights reports whether p is the owner: root, the owner's web | |
| 75 | - | // session, or a stored token that holds every scope and is not limited to a | |
| 76 | - | // list of repositories. It decides who may manage browser sessions. | |
| 79 | + | // session, or a stored token that holds every scope and is limited neither to | |
| 80 | + | // a list of repositories nor to branch prefixes. It decides who may manage browser sessions. | |
| 77 | 81 | func (p Principal) HasOwnerRights() bool { | |
| 78 | 82 | if p.IsRoot() { | |
| 79 | 83 | return true | |
| 80 | 84 | } | |
| 81 | - | if p.kind != kindToken || len(p.repos) > 0 { | |
| 85 | + | if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() { | |
| 82 | 86 | return false | |
| 83 | 87 | } | |
| 84 | 88 | for _, s := range AllScopes() { | |
| @@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) } | |||
| 105 | 109 | // repository. | |
| 106 | 110 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | |
| 107 | 111 | ||
| 112 | + | // BranchPrefixes returns the branch prefixes of a stored token; empty means | |
| 113 | + | // every branch. | |
| 114 | + | func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) } | |
| 115 | + | ||
| 116 | + | // IsBranchLimited reports whether p is limited to branch prefixes. A limited | |
| 117 | + | // principal has no owner rights, creates no repository and administers none. | |
| 118 | + | func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 } | |
| 119 | + | ||
| 120 | + | // CoversBranchPrefix reports whether s lies under one of the branch prefixes of | |
| 121 | + | // p: s starts with one of them. An empty prefix, which ParseBranchPrefix never | |
| 122 | + | // lets in, covers nothing instead of everything, so that a corrupt record | |
| 123 | + | // limits a token and never widens it. It is false without prefixes: callers | |
| 124 | + | // test IsBranchLimited first. | |
| 125 | + | func (p Principal) CoversBranchPrefix(s string) bool { | |
| 126 | + | for _, prefix := range p.branchPrefixes { | |
| 127 | + | if prefix != "" && strings.HasPrefix(s, prefix) { | |
| 128 | + | return true | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // AllowsRef reports whether p may write the ref (create, update or delete | |
| 135 | + | // it). Without branch prefixes every ref is allowed. With them, only a branch | |
| 136 | + | // "refs/heads/<name>" under one of the prefixes is: tags, every other ref and | |
| 137 | + | // a branch outside the prefixes are not. | |
| 138 | + | // | |
| 139 | + | // It judges the name of the ref only, and rests on two assumptions about the | |
| 140 | + | // repositories ermite serves. First, no symbolic ref lives under refs/heads | |
| 141 | + | // (HEAD is the only symref): ermite never creates one and receive-pack cannot, | |
| 142 | + | // so a name under a prefix never points at a branch outside it. Second, ref | |
| 143 | + | // names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x" | |
| 144 | + | // is not "refs/heads/agent/x". On a case-insensitive file system the two could | |
| 145 | + | // name the same ref and the prefixes would not hold. | |
| 146 | + | func (p Principal) AllowsRef(ref string) bool { | |
| 147 | + | if !p.IsBranchLimited() { | |
| 148 | + | return true | |
| 149 | + | } | |
| 150 | + | name, ok := strings.CutPrefix(ref, "refs/heads/") | |
| 151 | + | if !ok { | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | if _, err := ParseBranchName(name); err != nil { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | return p.CoversBranchPrefix(name) | |
| 158 | + | } | |
| 159 | + | ||
| 108 | 160 | // ExpiresAt returns the expiry of a stored token; zero means none. | |
| 109 | 161 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | |
| 110 | 162 | ||
M internal/domain/token.go +49 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "slices" | |
| 10 | + | "strings" | |
| 10 | 11 | "time" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -80,6 +81,10 @@ type Token struct { | |||
| 80 | 81 | Scopes []Scope | |
| 81 | 82 | // Repos limits the token to these repositories; empty means every one. | |
| 82 | 83 | Repos []RepoName | |
| 84 | + | // BranchPrefixes limits the branches the token may write to those under | |
| 85 | + | // one of these prefixes (each ends with "/"); empty means every branch. | |
| 86 | + | // A limited token cannot write tags or other refs, nor create repositories. | |
| 87 | + | BranchPrefixes []string | |
| 83 | 88 | // ExpiresAt is the zero time for a token that never expires. | |
| 84 | 89 | ExpiresAt time.Time | |
| 85 | 90 | CreatedAt time.Time | |
| @@ -90,3 +95,47 @@ type Token struct { | |||
| 90 | 95 | func (t Token) Expired(now time.Time) bool { | |
| 91 | 96 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | |
| 92 | 97 | } | |
| 98 | + | ||
| 99 | + | const ( | |
| 100 | + | // MaxBranchPrefixLen is the longest branch prefix, in bytes. | |
| 101 | + | MaxBranchPrefixLen = 100 | |
| 102 | + | // MaxBranchPrefixes is the most branch prefixes of a token. | |
| 103 | + | MaxBranchPrefixes = 8 | |
| 104 | + | ) | |
| 105 | + | ||
| 106 | + | // ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends | |
| 107 | + | // with "/", the rest is a valid branch name, and it has at most | |
| 108 | + | // MaxBranchPrefixLen bytes. Errors match ErrInvalidToken. | |
| 109 | + | func ParseBranchPrefix(s string) (string, error) { | |
| 110 | + | if len(s) > MaxBranchPrefixLen { | |
| 111 | + | return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen) | |
| 112 | + | } | |
| 113 | + | name, ok := strings.CutSuffix(s, "/") | |
| 114 | + | if !ok { | |
| 115 | + | return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s) | |
| 116 | + | } | |
| 117 | + | if _, err := ParseBranchName(name); err != nil { | |
| 118 | + | return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err) | |
| 119 | + | } | |
| 120 | + | return s, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ParseBranchPrefixes validates a list of branch prefixes: at most | |
| 124 | + | // MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match | |
| 125 | + | // ErrInvalidToken. The result is nil for an empty list. | |
| 126 | + | func ParseBranchPrefixes(in []string) ([]string, error) { | |
| 127 | + | var out []string | |
| 128 | + | for _, s := range in { | |
| 129 | + | p, err := ParseBranchPrefix(s) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if !slices.Contains(out, p) { | |
| 134 | + | out = append(out, p) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if len(out) > MaxBranchPrefixes { | |
| 138 | + | return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes) | |
| 139 | + | } | |
| 140 | + | return out, nil | |
| 141 | + | } | |
M internal/domain/token_test.go +127 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package domain_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) { | |||
| 111 | 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | |
| 112 | 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | |
| 113 | 114 | } | |
| 115 | + | ||
| 116 | + | func TestParseBranchPrefix(t *testing.T) { | |
| 117 | + | t.Parallel() | |
| 118 | + | for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} { | |
| 119 | + | got, err := domain.ParseBranchPrefix(ok) | |
| 120 | + | require.NoError(t, err, ok) | |
| 121 | + | assert.Equal(t, ok, got) | |
| 122 | + | } | |
| 123 | + | for _, bad := range []string{ | |
| 124 | + | "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/", | |
| 125 | + | "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/", | |
| 126 | + | "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/", | |
| 127 | + | } { | |
| 128 | + | _, err := domain.ParseBranchPrefix(bad) | |
| 129 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestParseBranchPrefixes(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | got, err := domain.ParseBranchPrefixes(nil) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | assert.Empty(t, got) | |
| 138 | + | ||
| 139 | + | got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept") | |
| 142 | + | ||
| 143 | + | eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"} | |
| 144 | + | got, err = domain.ParseBranchPrefixes(eight) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | assert.Len(t, got, 8) | |
| 147 | + | _, err = domain.ParseBranchPrefixes(append(eight, "i/")) | |
| 148 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 149 | + | got, err = domain.ParseBranchPrefixes(append(eight, "a/")) | |
| 150 | + | require.NoError(t, err, "duplicates do not count") | |
| 151 | + | assert.Len(t, got, 8) | |
| 152 | + | ||
| 153 | + | _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"}) | |
| 154 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestAllowsRef(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | limited := domain.TokenPrincipal(domain.Token{ | |
| 160 | + | Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 161 | + | }) | |
| 162 | + | for ref, want := range map[string]bool{ | |
| 163 | + | "refs/heads/agent/claude/x": true, | |
| 164 | + | "refs/heads/agent/claude/a/b": true, | |
| 165 | + | "refs/heads/bot/y": true, | |
| 166 | + | "refs/heads/agent/claude": false, | |
| 167 | + | "refs/heads/agent/claude/": false, | |
| 168 | + | "refs/heads/agent/claudex/y": false, | |
| 169 | + | "refs/heads/main": false, | |
| 170 | + | "refs/heads/agent/other/x": false, | |
| 171 | + | "refs/heads/other/agent/claude/x": false, | |
| 172 | + | "refs/tags/v1": false, | |
| 173 | + | "refs/tags/agent/claude/x": false, | |
| 174 | + | "refs/notes/commits": false, | |
| 175 | + | "refs/remotes/agent/claude/x": false, | |
| 176 | + | "refs/changes/agent/claude/x": false, | |
| 177 | + | "agent/claude/x": false, | |
| 178 | + | "heads/agent/claude/x": false, | |
| 179 | + | "HEAD": false, | |
| 180 | + | "": false, | |
| 181 | + | "refs/heads/agent/claude/../main": false, | |
| 182 | + | "refs/heads/agent/claude/x.lock": false, | |
| 183 | + | "refs/heads/agent/claude/a b": false, | |
| 184 | + | "refs/heads//agent/claude/x": false, | |
| 185 | + | } { | |
| 186 | + | assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // A corrupt empty prefix still limits the token and allows nothing. | |
| 190 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}}) | |
| 191 | + | assert.False(t, corrupt.AllowsRef("refs/heads/main")) | |
| 192 | + | ||
| 193 | + | // Without prefixes every ref is allowed. | |
| 194 | + | unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |
| 195 | + | assert.True(t, unlimited.AllowsRef("refs/tags/v1")) | |
| 196 | + | assert.True(t, unlimited.AllowsRef("refs/heads/main")) | |
| 197 | + | assert.True(t, domain.Root().AllowsRef("refs/tags/v1")) | |
| 198 | + | assert.True(t, domain.Owner().AllowsRef("refs/heads/main")) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestBranchPrefixesOfPrincipal(t *testing.T) { | |
| 202 | + | t.Parallel() | |
| 203 | + | tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}} | |
| 204 | + | p := domain.TokenPrincipal(tok) | |
| 205 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 206 | + | assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner") | |
| 207 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights()) | |
| 208 | + | ||
| 209 | + | // The principal owns copies. | |
| 210 | + | tok.BranchPrefixes[0] = "b/" | |
| 211 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 212 | + | p.BranchPrefixes()[0] = "c/" | |
| 213 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 214 | + | assert.Empty(t, domain.Root().BranchPrefixes()) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) { | |
| 218 | + | t.Parallel() | |
| 219 | + | free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}) | |
| 220 | + | assert.False(t, free.IsBranchLimited()) | |
| 221 | + | assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first") | |
| 222 | + | assert.False(t, domain.Root().IsBranchLimited()) | |
| 223 | + | assert.False(t, domain.Owner().IsBranchLimited()) | |
| 224 | + | assert.False(t, domain.Anonymous().IsBranchLimited()) | |
| 225 | + | ||
| 226 | + | limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}}) | |
| 227 | + | assert.True(t, limited.IsBranchLimited()) | |
| 228 | + | for s, want := range map[string]bool{ | |
| 229 | + | "agent/claude/x": true, "agent/claude/": true, "bot/y": true, | |
| 230 | + | "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false, | |
| 231 | + | } { | |
| 232 | + | assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // A corrupt empty prefix still limits, and covers nothing. | |
| 236 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}}) | |
| 237 | + | assert.True(t, corrupt.IsBranchLimited()) | |
| 238 | + | assert.False(t, corrupt.CoversBranchPrefix("main")) | |
| 239 | + | assert.False(t, corrupt.CoversBranchPrefix("")) | |
| 240 | + | } | |
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 +5 -2
| @@ -61,6 +61,9 @@ type CreateTokenParams struct { | |||
| 61 | 61 | Scopes []domain.Scope | |
| 62 | 62 | // Repos limits the token to these repositories; empty means every one. | |
| 63 | 63 | Repos []domain.RepoName | |
| 64 | + | // BranchPrefixes limits the token to the branches under these prefixes | |
| 65 | + | // (see domain.ParseBranchPrefix); empty means every branch. | |
| 66 | + | BranchPrefixes []string | |
| 64 | 67 | // ExpiresAt is the zero time for a token that never expires. | |
| 65 | 68 | ExpiresAt time.Time | |
| 66 | 69 | } | |
| @@ -137,8 +140,8 @@ type WatchEvent struct { | |||
| 137 | 140 | // Event is nil then. | |
| 138 | 141 | Gap bool | |
| 139 | 142 | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, | |
| 140 | - | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |
| 141 | - | // CommentAdded. | |
| 143 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed, | |
| 144 | + | // CommentAdded, RunUpdated or JobUpdated. | |
| 142 | 145 | Event domain.Event | |
| 143 | 146 | // Repo is the state of the repository at delivery, for RepoCreated and | |
| 144 | 147 | // 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 | + | } | |
M internal/ports/ports.go +3 -1
| @@ -255,7 +255,9 @@ type MergeRequestStore interface { | |||
| 255 | 255 | // ObjectID when there is none. | |
| 256 | 256 | GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) | |
| 257 | 257 | // SetRead records the head the reviewer has read. It returns | |
| 258 | - | // domain.ErrNotFound when the repository does not exist. | |
| 258 | + | // domain.ErrNotFound when the repository does not exist, and | |
| 259 | + | // domain.ErrConflict when concurrent writes to the repository keep it | |
| 260 | + | // from landing. | |
| 259 | 261 | SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error | |
| 260 | 262 | } | |
| 261 | 263 | ||
M internal/usecase/access.go +18 -6
| @@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) | |||
| 26 | 26 | return p.Allows(domain.ScopeRepoRead, name) | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | - | // adminAccess is the access rule for changing or deleting a repository. A | |
| 30 | - | // caller that may not administer it learns nothing it could not already read: | |
| 31 | - | // not found when it cannot see the repository, unauthenticated when it is | |
| 32 | - | // anonymous, forbidden otherwise. | |
| 29 | + | // adminAccess is the access rule for changing or deleting a repository: | |
| 30 | + | // repo:admin on it, and no branch prefixes. A token limited to some branches | |
| 31 | + | // may not delete the repository (that deletes every branch, those outside its | |
| 32 | + | // prefixes included), nor change its default branch, visibility, description, | |
| 33 | + | // merge modes or failing-checks rule: these decide what happens to branches the | |
| 34 | + | // token may not write. A caller that may not administer it learns nothing it | |
| 35 | + | // could not already read: not found when it cannot see the repository, | |
| 36 | + | // unauthenticated when it is anonymous, forbidden otherwise. | |
| 33 | 37 | func adminAccess(p domain.Principal, repo domain.Repo) error { | |
| 34 | 38 | switch { | |
| 39 | + | case canAdmin(p, repo.Name) && p.IsBranchLimited(): | |
| 40 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden) | |
| 35 | 41 | case canAdmin(p, repo.Name): | |
| 36 | 42 | return nil | |
| 37 | 43 | case !canRead(p, repo): | |
| @@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error { | |||
| 42 | 48 | return domain.ErrForbidden | |
| 43 | 49 | } | |
| 44 | 50 | ||
| 45 | - | // canWrite is the access rule for pushing to a repository, or creating it on | |
| 46 | - | // first push: repo:write on it. | |
| 51 | + | // canWrite is the access rule for pushing to a repository: repo:write on it. | |
| 47 | 52 | func canWrite(p domain.Principal, name domain.RepoName) bool { | |
| 48 | 53 | return p.Allows(domain.ScopeRepoWrite, name) | |
| 49 | 54 | } | |
| 50 | 55 | ||
| 56 | + | // canCreateRepo is the access rule for creating a repository, by the API or on | |
| 57 | + | // first push: repo:write on the name, and no branch prefixes. A token limited | |
| 58 | + | // to some branches may push to a repository that exists; it never makes one. | |
| 59 | + | func canCreateRepo(p domain.Principal, name domain.RepoName) bool { | |
| 60 | + | return canWrite(p, name) && !p.IsBranchLimited() | |
| 61 | + | } | |
| 62 | + | ||
| 51 | 63 | // canManageTokens is the access rule for creating, listing and revoking | |
| 52 | 64 | // stored tokens: token:admin, which root holds. | |
| 53 | 65 | func canManageTokens(p domain.Principal) bool { | |
A internal/usecase/ci.go +555 -0
| @@ -0,0 +1,555 @@ | |||
| 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 | + | // runWriteAccess is the branch rule of the write actions on a run, cancel and | |
| 466 | + | // re-run, which come after the ci:write check. A run is tied to the ref that | |
| 467 | + | // made it, and re-running it executes the workflow of that ref again while | |
| 468 | + | // cancelling it stops the checks of that ref. So, like a push, a merge or a | |
| 469 | + | // branch delete, these actions fall under the branch prefixes of a limited | |
| 470 | + | // token (ADR 0027): it may act only on the runs of branches under its prefixes, | |
| 471 | + | // and never on a run of a tag or of any other ref. Without prefixes, every run. | |
| 472 | + | // | |
| 473 | + | // Reading runs, jobs, logs and checks is not a write: it needs only the access | |
| 474 | + | // to read the repository, as for the code, whatever the prefixes. The caller | |
| 475 | + | // has seen the run by then, so the refusal is forbidden, not not found. | |
| 476 | + | func runWriteAccess(p domain.Principal, run domain.CIRun) error { | |
| 477 | + | if !p.AllowsRef(run.Ref) { | |
| 478 | + | return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref) | |
| 479 | + | } | |
| 480 | + | return nil | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // canManageRunners is the access rule of runners: runner:register on a token | |
| 484 | + | // that is not restricted to repositories (a runner serves every repository), | |
| 485 | + | // or root. A token limited to branch prefixes never manages runners, whatever | |
| 486 | + | // scopes it holds: a runner runs the workflows of every branch of every | |
| 487 | + | // repository, with their code and secrets, so registering or removing one | |
| 488 | + | // reaches far beyond the prefixes (a replaced runner could also be a way to | |
| 489 | + | // take over the jobs of main). Like repository administration and owner | |
| 490 | + | // rights, it is out of reach of a limited token. | |
| 491 | + | func canManageRunners(p domain.Principal) error { | |
| 492 | + | switch { | |
| 493 | + | case p.IsAnonymous(): | |
| 494 | + | return domain.ErrUnauthenticated | |
| 495 | + | case !p.HasScope(domain.ScopeRunnerRegister): | |
| 496 | + | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | |
| 497 | + | case len(p.Repos()) > 0: | |
| 498 | + | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | |
| 499 | + | case p.IsBranchLimited(): | |
| 500 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden) | |
| 501 | + | } | |
| 502 | + | return nil | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // --- small helpers --- | |
| 506 | + | ||
| 507 | + | func truncateRunes(s string, n int) string { | |
| 508 | + | if len(s) <= n { | |
| 509 | + | return s | |
| 510 | + | } | |
| 511 | + | r := []rune(s) | |
| 512 | + | if len(r) <= n { | |
| 513 | + | return s | |
| 514 | + | } | |
| 515 | + | return string(r[:n]) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // cutText cuts s to n bytes at a character boundary. | |
| 519 | + | func cutText(s string, n int) string { | |
| 520 | + | if len(s) <= n { | |
| 521 | + | return s | |
| 522 | + | } | |
| 523 | + | for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 { | |
| 524 | + | n-- | |
| 525 | + | } | |
| 526 | + | return s[:n] | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func sortedKeys[V any](m map[string]V) []string { | |
| 530 | + | keys := make([]string, 0, len(m)) | |
| 531 | + | for k := range m { | |
| 532 | + | keys = append(keys, k) | |
| 533 | + | } | |
| 534 | + | slices.Sort(keys) | |
| 535 | + | return keys | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | // ctxLog returns the logger of the context, addressable so that the level | |
| 539 | + | // methods can be chained on it. | |
| 540 | + | func ctxLog(ctx context.Context) *zerowrap.Logger { | |
| 541 | + | l := zerowrap.FromCtx(ctx) | |
| 542 | + | return &l | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // logRun returns the logger of the context with the fields of the run. | |
| 546 | + | func logRun(ctx context.Context, run domain.CIRun) zerowrap.Logger { | |
| 547 | + | return zerowrap.FromCtx(ctx).WithFields(map[string]any{"repo": run.Repo.String(), "run_id": run.ID}) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func isHex40(s string) bool { | |
| 551 | + | if len(s) != 40 { | |
| 552 | + | return false | |
| 553 | + | } | |
| 554 | + | return strings.Trim(s, "0123456789abcdef") == "" | |
| 555 | + | } | |
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 +592 -0
| @@ -0,0 +1,592 @@ | |||
| 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 | + | run, err := c.runOf(ctx, name, runID) | |
| 322 | + | if err != nil { | |
| 323 | + | return domain.CIRun{}, err | |
| 324 | + | } | |
| 325 | + | if err := runWriteAccess(p, run); err != nil { | |
| 326 | + | return domain.CIRun{}, err | |
| 327 | + | } | |
| 328 | + | var out domain.CIRun | |
| 329 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 330 | + | if rs.run.Status == domain.CICompleted { | |
| 331 | + | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | |
| 332 | + | } | |
| 333 | + | if !rs.run.CancelRequested { | |
| 334 | + | rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true | |
| 335 | + | rs.cancelAll("Canceled by " + authorOf(p)) | |
| 336 | + | } | |
| 337 | + | if err := rs.flush(ctx); err != nil { | |
| 338 | + | return err | |
| 339 | + | } | |
| 340 | + | out = rs.run | |
| 341 | + | return nil | |
| 342 | + | }) | |
| 343 | + | if err != nil { | |
| 344 | + | return domain.CIRun{}, err | |
| 345 | + | } | |
| 346 | + | ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run cancelled") | |
| 347 | + | return out, nil | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // RerunRun starts a new attempt of a completed run, from the stored workflow | |
| 351 | + | // file: all the jobs, or with failedOnly the jobs that did not succeed and | |
| 352 | + | // those that need them; the others are kept with their result, steps and log. | |
| 353 | + | func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) { | |
| 354 | + | repo, err := c.writableRepo(ctx, p, name) | |
| 355 | + | if err != nil { | |
| 356 | + | return domain.CIRun{}, err | |
| 357 | + | } | |
| 358 | + | run, err := c.runOf(ctx, name, runID) | |
| 359 | + | if err != nil { | |
| 360 | + | return domain.CIRun{}, err | |
| 361 | + | } | |
| 362 | + | if err := runWriteAccess(p, run); err != nil { | |
| 363 | + | return domain.CIRun{}, err | |
| 364 | + | } | |
| 365 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 366 | + | if rs.run.Status != domain.CICompleted { | |
| 367 | + | return fmt.Errorf("%w: run %d has not completed", domain.ErrPreconditionFailed, runID) | |
| 368 | + | } | |
| 369 | + | if len(rs.jobs) == 0 { | |
| 370 | + | return fmt.Errorf("%w: run %d has no job to run again", domain.ErrPreconditionFailed, runID) | |
| 371 | + | } | |
| 372 | + | redo, err := rerunSet(rs, failedOnly) | |
| 373 | + | if err != nil { | |
| 374 | + | return err | |
| 375 | + | } | |
| 376 | + | next := rs.run.Attempt + 1 | |
| 377 | + | now := c.now() | |
| 378 | + | w := ports.CIWrite{} | |
| 379 | + | var fresh []domain.CIJob | |
| 380 | + | for _, j := range rs.jobs { | |
| 381 | + | if !redo[j.Key] { | |
| 382 | + | kept := j // kept with its own attempt, steps and log | |
| 383 | + | kept.RunAttempt, kept.Version = next, 0 | |
| 384 | + | w.Jobs = append(w.Jobs, kept) | |
| 385 | + | fresh = append(fresh, kept) | |
| 386 | + | continue | |
| 387 | + | } | |
| 388 | + | if j.Combo > 0 { | |
| 389 | + | continue // the matrix is expanded again when the job is planned | |
| 390 | + | } | |
| 391 | + | n := domain.CIJob{ | |
| 392 | + | ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo, | |
| 393 | + | Key: j.Key, Name: j.Key, Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: j.Index, CreatedAt: now, | |
| 394 | + | } | |
| 395 | + | w.Jobs = append(w.Jobs, n) | |
| 396 | + | fresh = append(fresh, n) | |
| 397 | + | } | |
| 398 | + | run := rs.run | |
| 399 | + | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | |
| 400 | + | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | |
| 401 | + | run.CancelRequested, run.CancelRequestedAt = false, time.Time{} | |
| 402 | + | w.Runs = []domain.CIRun{run} | |
| 403 | + | rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}} | |
| 404 | + | for _, j := range fresh { | |
| 405 | + | w.Checks = append(w.Checks, rs2.checkOf(j)) | |
| 406 | + | } | |
| 407 | + | v, err := c.cfg.Store.Apply(ctx, w) | |
| 408 | + | if err != nil { | |
| 409 | + | return err | |
| 410 | + | } | |
| 411 | + | run.Version = v | |
| 412 | + | c.flows.drop(runID) | |
| 413 | + | c.publishRun(ctx, repo, run) | |
| 414 | + | for _, j := range w.Jobs { | |
| 415 | + | j.Version = v | |
| 416 | + | c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 417 | + | } | |
| 418 | + | return nil | |
| 419 | + | }) | |
| 420 | + | if err != nil { | |
| 421 | + | return domain.CIRun{}, err | |
| 422 | + | } | |
| 423 | + | // Plan the new attempt: the kept jobs count as the results of their needs. | |
| 424 | + | if err := c.advanceRun(ctx, runID); err != nil { | |
| 425 | + | return domain.CIRun{}, err | |
| 426 | + | } | |
| 427 | + | run, err = c.runOf(ctx, name, runID) | |
| 428 | + | if err != nil { | |
| 429 | + | return domain.CIRun{}, err | |
| 430 | + | } | |
| 431 | + | ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Int32("attempt", run.Attempt).Bool("failed_only", failedOnly). | |
| 432 | + | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run started again") | |
| 433 | + | return run, nil | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // rerunSet returns the keys of the jobs to run again: all of them, or the ones | |
| 437 | + | // that did not succeed and those that need them, directly or not. | |
| 438 | + | func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |
| 439 | + | redo := map[string]bool{} | |
| 440 | + | if !failedOnly { | |
| 441 | + | for _, j := range rs.jobs { | |
| 442 | + | redo[j.Key] = true | |
| 443 | + | } | |
| 444 | + | return redo, nil | |
| 445 | + | } | |
| 446 | + | for _, j := range rs.jobs { | |
| 447 | + | switch j.Conclusion { | |
| 448 | + | case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired: | |
| 449 | + | redo[j.Key] = true | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | if len(redo) == 0 { | |
| 453 | + | return nil, fmt.Errorf("%w: no job of run %d failed", domain.ErrPreconditionFailed, rs.run.ID) | |
| 454 | + | } | |
| 455 | + | for changed := true; changed; { | |
| 456 | + | changed = false | |
| 457 | + | for _, j := range rs.jobs { | |
| 458 | + | if redo[j.Key] { | |
| 459 | + | continue | |
| 460 | + | } | |
| 461 | + | if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) { | |
| 462 | + | redo[j.Key], changed = true, true | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | return redo, nil | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func (w *wfCache) drop(runID int64) { | |
| 470 | + | w.mu.Lock() | |
| 471 | + | delete(w.m, runID) | |
| 472 | + | w.mu.Unlock() | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // --- checks --- | |
| 476 | + | ||
| 477 | + | // CommitChecks returns the checks of a commit, without access control. A | |
| 478 | + | // commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted): | |
| 479 | + | // the merge rule cannot answer from a part of them. | |
| 480 | + | func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) { | |
| 481 | + | checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks) | |
| 482 | + | if err != nil { | |
| 483 | + | return nil, err | |
| 484 | + | } | |
| 485 | + | if more { | |
| 486 | + | return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks) | |
| 487 | + | } | |
| 488 | + | return checks, nil | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // ListChecks returns the checks of a commit: the one of each job of the latest | |
| 492 | + | // attempt of each run on it. A commit without a run has none. | |
| 493 | + | func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.RepoName, sha string) (ports.ChecksResult, error) { | |
| 494 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 495 | + | return ports.ChecksResult{}, err | |
| 496 | + | } | |
| 497 | + | if !isHex40(sha) { | |
| 498 | + | return ports.ChecksResult{}, fmt.Errorf("%w: a commit is 40 lowercase hexadecimal characters", domain.ErrInvalidObjectID) | |
| 499 | + | } | |
| 500 | + | checks, more, err := c.cfg.Store.ListChecks(ctx, name, sha, MaxCIChecks) | |
| 501 | + | if err != nil { | |
| 502 | + | return ports.ChecksResult{}, err | |
| 503 | + | } | |
| 504 | + | res := ports.ChecksResult{Truncated: more} | |
| 505 | + | var runners []domain.CIRunner | |
| 506 | + | for _, ck := range checks { | |
| 507 | + | v := ports.CheckView{CICheck: ck} | |
| 508 | + | if ck.Status == domain.CIQueued { | |
| 509 | + | if runners == nil { | |
| 510 | + | runners, _ = c.cfg.Store.ListRunners(ctx) | |
| 511 | + | } | |
| 512 | + | v.WaitingForRunner = c.noRunnerFor(ctx, ck, runners) | |
| 513 | + | } | |
| 514 | + | res.Checks = append(res.Checks, v) | |
| 515 | + | } | |
| 516 | + | return res, nil | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // noRunnerFor reports whether no registered runner can take a queued check's | |
| 520 | + | // job. A job that waits for its needs is not waiting for a runner. | |
| 521 | + | func (c *CI) noRunnerFor(ctx context.Context, ck domain.CICheck, runners []domain.CIRunner) bool { | |
| 522 | + | if ck.JobID == "" { | |
| 523 | + | return false | |
| 524 | + | } | |
| 525 | + | j, err := c.cfg.Store.GetJob(ctx, ck.RunID, ck.Attempt, ck.JobID) | |
| 526 | + | if err != nil || j.Phase != domain.CIPhaseReady { | |
| 527 | + | return false | |
| 528 | + | } | |
| 529 | + | return !slices.ContainsFunc(runners, func(r domain.CIRunner) bool { return domain.LabelsMatch(j.Labels, r.Labels) }) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // --- runners --- | |
| 533 | + | ||
| 534 | + | // ListRunners returns a page of the runners ordered by name. The principal | |
| 535 | + | // needs runner:register on a token that is not restricted to repositories. | |
| 536 | + | func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) { | |
| 537 | + | if err := canManageRunners(p); err != nil { | |
| 538 | + | return ports.RunnerPage{}, err | |
| 539 | + | } | |
| 540 | + | n, err := pageSize(size) | |
| 541 | + | if err != nil { | |
| 542 | + | return ports.RunnerPage{}, err | |
| 543 | + | } | |
| 544 | + | after := "" | |
| 545 | + | if token != "" { | |
| 546 | + | raw, ok := decodeToken(token, "rn1:") | |
| 547 | + | if !ok || raw == "" { | |
| 548 | + | return ports.RunnerPage{}, fmt.Errorf("%w: runners", domain.ErrInvalidPageToken) | |
| 549 | + | } | |
| 550 | + | after = raw | |
| 551 | + | } | |
| 552 | + | all, err := c.cfg.Store.ListRunners(ctx) | |
| 553 | + | if err != nil { | |
| 554 | + | return ports.RunnerPage{}, err | |
| 555 | + | } | |
| 556 | + | busy := map[string]bool{} | |
| 557 | + | if active, err := c.cfg.Store.ListActiveJobs(ctx); err == nil { | |
| 558 | + | for _, j := range active { | |
| 559 | + | if j.Phase == domain.CIPhaseRunning { | |
| 560 | + | busy[j.RunnerID] = true | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | } | |
| 564 | + | var page ports.RunnerPage | |
| 565 | + | var rows []domain.CIRunner | |
| 566 | + | rows, page.NextPageToken = pageAfter(all, n, | |
| 567 | + | func(r domain.CIRunner) bool { return after == "" || strings.ToLower(r.Name) > after }, | |
| 568 | + | func(r domain.CIRunner) string { | |
| 569 | + | return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name))) | |
| 570 | + | }) | |
| 571 | + | c.mu.Lock() | |
| 572 | + | defer c.mu.Unlock() | |
| 573 | + | for _, r := range rows { | |
| 574 | + | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]}) | |
| 575 | + | } | |
| 576 | + | return page, nil | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // RemoveRunner removes a runner: its token stops working at once and a poll of | |
| 580 | + | // it in progress ends with unauthenticated. A job it runs is not affected. | |
| 581 | + | func (c *CI) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | |
| 582 | + | if err := canManageRunners(p); err != nil { | |
| 583 | + | return err | |
| 584 | + | } | |
| 585 | + | r, err := c.cfg.Store.RemoveRunner(ctx, id) | |
| 586 | + | if err != nil { | |
| 587 | + | return err | |
| 588 | + | } | |
| 589 | + | c.forgetRunners([]domain.CIRunner{r}) | |
| 590 | + | ctxLog(ctx).Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner removed") | |
| 591 | + | return nil | |
| 592 | + | } | |
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 +59 -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 | } | |
| @@ -710,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 710 | 718 | if err != nil { | |
| 711 | 719 | return domain.MergeRequest{}, false, err | |
| 712 | 720 | } | |
| 721 | + | // A merge moves the target and may delete the source: a token limited to | |
| 722 | + | // branch prefixes may do so only within them. | |
| 723 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 724 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 725 | + | } | |
| 726 | + | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | |
| 727 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 728 | + | } | |
| 713 | 729 | if !mr.IsOpen() { | |
| 714 | 730 | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 715 | 731 | } | |
| @@ -753,6 +769,11 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 753 | 769 | case !ok: | |
| 754 | 770 | 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 | 771 | } | |
| 772 | + | if settings.FailingChecksBlock { | |
| 773 | + | if err := m.checksAllow(ctx, name, head); err != nil { | |
| 774 | + | return domain.MergeRequest{}, false, err | |
| 775 | + | } | |
| 776 | + | } | |
| 756 | 777 | if params.DeleteSource { | |
| 757 | 778 | if err := m.checkDeletable(ctx, name, mr); err != nil { | |
| 758 | 779 | return domain.MergeRequest{}, false, err | |
| @@ -792,9 +813,46 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 792 | 813 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 793 | 814 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 794 | 815 | }) | |
| 816 | + | if m.ci != nil { | |
| 817 | + | // GitHub runs the "push" workflows of a merge, with the owner as pusher. | |
| 818 | + | m.ci.OnPush(ctx, name, updates[:1], domain.Owner()) | |
| 819 | + | } | |
| 795 | 820 | return mr, deleted, nil | |
| 796 | 821 | } | |
| 797 | 822 | ||
| 823 | + | // checksAllow is the merge rule of FailingChecksBlock: every check of the | |
| 824 | + | // head must have ended as success, skipped or neutral. A check still queued or | |
| 825 | + | // running blocks too (the merge could land code that then fails), and a head | |
| 826 | + | // without any check merges. | |
| 827 | + | func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error { | |
| 828 | + | if m.checks == nil { | |
| 829 | + | return nil | |
| 830 | + | } | |
| 831 | + | checks, err := m.checks.CommitChecks(ctx, name, head.String()) | |
| 832 | + | if errors.Is(err, domain.ErrResourceExhausted) { | |
| 833 | + | return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err) | |
| 834 | + | } | |
| 835 | + | if err != nil { | |
| 836 | + | return fmt.Errorf("read the checks of %s: %w", head, err) | |
| 837 | + | } | |
| 838 | + | var failing, pending []string | |
| 839 | + | for _, c := range checks { | |
| 840 | + | switch { | |
| 841 | + | case c.Status != domain.CICompleted: | |
| 842 | + | pending = append(pending, c.Name) | |
| 843 | + | case !c.Conclusion.PassesMerge(): | |
| 844 | + | failing = append(failing, c.Name) | |
| 845 | + | } | |
| 846 | + | } | |
| 847 | + | switch { | |
| 848 | + | case len(failing) > 0: | |
| 849 | + | return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", ")) | |
| 850 | + | case len(pending) > 0: | |
| 851 | + | return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", ")) | |
| 852 | + | } | |
| 853 | + | return nil | |
| 854 | + | } | |
| 855 | + | ||
| 798 | 856 | // checkDeletable refuses to delete the source branch of mr when it is the | |
| 799 | 857 | // default branch or the source or target of another open request. It runs | |
| 800 | 858 | // under the merge lock, which Create also holds while it checks its branches | |
A internal/usecase/mergerequests_branches_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // branchMerger holds mr:merge but may only write the branches under | |
| 16 | + | // agent/claude/. | |
| 17 | + | var branchMerger = domain.TokenPrincipal(domain.Token{ | |
| 18 | + | ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"}, | |
| 19 | + | Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, | |
| 20 | + | }) | |
| 21 | + | ||
| 22 | + | // AC5: a merge writes the target, and the source when it is deleted; a token | |
| 23 | + | // limited to branch prefixes may do neither outside them. | |
| 24 | + | func TestMergeWithBranchLimitedToken(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | ||
| 27 | + | t.Run("into main is forbidden and nothing moves", func(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 29 | + | r := newMRRig(t) | |
| 30 | + | main := r.seed() | |
| 31 | + | head := r.feature("agent/claude/feat") | |
| 32 | + | mr := r.open("agent/claude/feat") // targets main | |
| 33 | + | r.drain() | |
| 34 | + | ||
| 35 | + | for _, del := range []bool{false, true} { | |
| 36 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del}) | |
| 37 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 38 | + | assert.Contains(t, err.Error(), "this token may not write main") | |
| 39 | + | } | |
| 40 | + | assert.Equal(t, main, r.branchHead("main"), "main did not move") | |
| 41 | + | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | |
| 42 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 45 | + | assert.Empty(t, r.drain(), "no event") | |
| 46 | + | ||
| 47 | + | // The same merge by an unlimited token works. | |
| 48 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | assert.Equal(t, head, r.branchHead("main")) | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) { | |
| 54 | + | t.Parallel() | |
| 55 | + | r := newMRRig(t) | |
| 56 | + | r.seed() | |
| 57 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |
| 58 | + | r.push("agent/claude/base") | |
| 59 | + | r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base") | |
| 60 | + | head := r.commit(author, "f.txt", "f\n", "add f") | |
| 61 | + | r.push("agent/claude/feat") | |
| 62 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |
| 63 | + | Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base", | |
| 64 | + | }) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.True(t, res.SourceDeleted) | |
| 70 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |
| 71 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | |
| 72 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 73 | + | }) | |
| 74 | + | ||
| 75 | + | t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) { | |
| 76 | + | t.Parallel() | |
| 77 | + | r := newMRRig(t) | |
| 78 | + | r.seed() | |
| 79 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |
| 80 | + | r.push("agent/claude/base") | |
| 81 | + | base := r.branchHead("agent/claude/base") | |
| 82 | + | r.sh("checkout", "-q", "-B", "other", "agent/claude/base") | |
| 83 | + | head := r.commit(author, "o.txt", "o\n", "add o") | |
| 84 | + | r.push("other") | |
| 85 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |
| 86 | + | Title: "other", Source: "other", Target: "agent/claude/base", | |
| 87 | + | }) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | ||
| 90 | + | _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 91 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 92 | + | assert.Contains(t, err.Error(), "this token may not write other") | |
| 93 | + | assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged") | |
| 94 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |
| 95 | + | ||
| 96 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | assert.False(t, res.SourceDeleted) | |
| 99 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |
| 100 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |
| 101 | + | }) | |
| 102 | + | ||
| 103 | + | t.Run("a squash into main is forbidden too", func(t *testing.T) { | |
| 104 | + | t.Parallel() | |
| 105 | + | r := newMRRig(t) | |
| 106 | + | main := r.seed() | |
| 107 | + | head := r.feature("agent/claude/feat") | |
| 108 | + | mr := r.open("agent/claude/feat") | |
| 109 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 110 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 111 | + | assert.Equal(t, main, r.branchHead("main")) | |
| 112 | + | }) | |
| 113 | + | } | |
M internal/usecase/pushes.go +32 -6
| @@ -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. | |
| @@ -69,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 69 | 77 | return fmt.Errorf("get repository %q: %w", name, err) | |
| 70 | 78 | } | |
| 71 | 79 | ||
| 72 | - | if !create || !canWrite(p, name) { | |
| 80 | + | if !create || !canCreateRepo(p, name) { | |
| 73 | 81 | return domain.ErrNotFound | |
| 74 | 82 | } | |
| 75 | 83 | _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}) | |
| @@ -97,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error { | |||
| 97 | 105 | return domain.ErrNotFound | |
| 98 | 106 | } | |
| 99 | 107 | ||
| 100 | - | // Check is the pre-receive decision. v1 accepts every push of a principal that | |
| 101 | - | // may write the repository; protected branches and limits will refuse here by | |
| 102 | - | // returning an error that matches domain.ErrRejected. | |
| 108 | + | // Check is the pre-receive decision. It accepts a push of a principal that | |
| 109 | + | // may write the repository. A token limited to branch prefixes may only update | |
| 110 | + | // (create, move or delete) branches under them: the first ref outside them | |
| 111 | + | // refuses the whole push, tags and every other ref included. Further rules | |
| 112 | + | // (protected branches, limits) will refuse here too, by returning an error | |
| 113 | + | // that matches domain.ErrRejected. | |
| 114 | + | // | |
| 115 | + | // The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no | |
| 116 | + | // symbolic ref under refs/heads besides HEAD (ermite never creates one and | |
| 117 | + | // receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees | |
| 118 | + | // every ref of the push, so a tag or any other ref is judged like a branch. | |
| 103 | 119 | // | |
| 104 | 120 | // The token of the push was valid when the request began, but a push can run | |
| 105 | 121 | // for long: it is re-validated here, so a token revoked or expired meanwhile | |
| @@ -114,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 114 | 130 | if !canWrite(p, name) { | |
| 115 | 131 | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | |
| 116 | 132 | } | |
| 133 | + | for _, up := range updates { | |
| 134 | + | if !p.AllowsRef(up.Ref) { | |
| 135 | + | return fmt.Errorf("%w: %s is outside the branches this token may push (%s)", | |
| 136 | + | domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", ")) | |
| 137 | + | } | |
| 138 | + | } | |
| 117 | 139 | log := zerowrap.FromCtx(ctx) | |
| 118 | 140 | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | |
| 119 | 141 | return nil | |
| @@ -121,7 +143,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 121 | 143 | ||
| 122 | 144 | // Record is the post-receive step of an accepted push: it sets pushed_at, | |
| 123 | 145 | // repairs HEAD when it points to a missing branch, tells the reader that the | |
| 124 | - | // repository changed, publishes a PushEvent and schedules maintenance. Every | |
| 146 | + | // repository changed, queues the workflows the push triggers, publishes a | |
| 147 | + | // PushEvent and schedules maintenance. Every | |
| 125 | 148 | // step runs even when an earlier one fails; the failures are returned together | |
| 126 | 149 | // and the push itself is not affected. | |
| 127 | 150 | func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| @@ -139,6 +162,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 139 | 162 | if u.fresh != nil { | |
| 140 | 163 | u.fresh.Refresh(ctx, name) | |
| 141 | 164 | } | |
| 165 | + | if u.ci != nil { | |
| 166 | + | u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push | |
| 167 | + | } | |
| 142 | 168 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 143 | 169 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 144 | 170 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
M internal/usecase/pushes_test.go +83 -0
| @@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) { | |||
| 76 | 76 | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound}, | |
| 77 | 77 | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound}, | |
| 78 | 78 | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound}, | |
| 79 | + | {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil}, | |
| 80 | + | {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound}, | |
| 81 | + | {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound}, | |
| 79 | 82 | } | |
| 80 | 83 | for _, tt := range tests { | |
| 81 | 84 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) { | |||
| 203 | 206 | }) | |
| 204 | 207 | } | |
| 205 | 208 | ||
| 209 | + | // AC5: a token limited to branch prefixes pushes only under them. | |
| 210 | + | func TestPushesCheckBranchPrefixes(t *testing.T) { | |
| 211 | + | t.Parallel() | |
| 212 | + | ctx := context.Background() | |
| 213 | + | n := name(t, "tools") | |
| 214 | + | limited := domain.TokenPrincipal(domain.Token{ | |
| 215 | + | ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 216 | + | }) | |
| 217 | + | up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} } | |
| 218 | + | ||
| 219 | + | tests := []struct { | |
| 220 | + | name string | |
| 221 | + | updates []domain.RefUpdate | |
| 222 | + | refused string // the ref named by the error; "" = accepted | |
| 223 | + | }{ | |
| 224 | + | {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""}, | |
| 225 | + | {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""}, | |
| 226 | + | {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""}, | |
| 227 | + | {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""}, | |
| 228 | + | {"several under the prefixes", []domain.RefUpdate{ | |
| 229 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"), | |
| 230 | + | }, ""}, | |
| 231 | + | {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"}, | |
| 232 | + | {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"}, | |
| 233 | + | {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"}, | |
| 234 | + | {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"}, | |
| 235 | + | {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"}, | |
| 236 | + | {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"}, | |
| 237 | + | {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"}, | |
| 238 | + | {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"}, | |
| 239 | + | {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"}, | |
| 240 | + | {"mixed push: one allowed, one forbidden", []domain.RefUpdate{ | |
| 241 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"), | |
| 242 | + | }, "refs/heads/main"}, | |
| 243 | + | {"mixed push, forbidden first", []domain.RefUpdate{ | |
| 244 | + | up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), | |
| 245 | + | }, "refs/heads/main"}, | |
| 246 | + | } | |
| 247 | + | for _, tt := range tests { | |
| 248 | + | t.Run(tt.name, func(t *testing.T) { | |
| 249 | + | t.Parallel() | |
| 250 | + | f := newPushFixture(t) | |
| 251 | + | f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |
| 252 | + | err := f.pushes.Check(ctx, limited, n, tt.updates) | |
| 253 | + | if tt.refused == "" { | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 258 | + | assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)") | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | t.Run("a token without limit pushes anywhere", func(t *testing.T) { | |
| 263 | + | t.Parallel() | |
| 264 | + | f := newPushFixture(t) | |
| 265 | + | f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |
| 266 | + | free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 267 | + | require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{ | |
| 268 | + | up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"), | |
| 269 | + | })) | |
| 270 | + | }) | |
| 271 | + | ||
| 272 | + | t.Run("root pushes anywhere", func(t *testing.T) { | |
| 273 | + | t.Parallel() | |
| 274 | + | f := newPushFixture(t) | |
| 275 | + | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")})) | |
| 276 | + | }) | |
| 277 | + | ||
| 278 | + | t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) { | |
| 279 | + | t.Parallel() | |
| 280 | + | f := newPushFixture(t) | |
| 281 | + | f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil) | |
| 282 | + | p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}}) | |
| 283 | + | err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")}) | |
| 284 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 285 | + | assert.Contains(t, err.Error(), "no write access") | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 206 | 289 | func TestPushesRecord(t *testing.T) { | |
| 207 | 290 | t.Parallel() | |
| 208 | 291 | ctx := context.Background() | |
M internal/usecase/repos.go +12 -3
| @@ -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,14 +32,22 @@ 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 | |
| 37 | 45 | // after the metadata is written removes it again and discards the staged | |
| 38 | 46 | // repository. | |
| 39 | 47 | // | |
| 40 | - | // The principal needs repo:write on the name: anonymous callers get | |
| 41 | - | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | |
| 48 | + | // The principal needs repo:write on the name and no branch prefixes: anonymous | |
| 49 | + | // callers get domain.ErrUnauthenticated, other callers without it (a token | |
| 50 | + | // limited to some branches included) domain.ErrForbidden. | |
| 42 | 51 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | |
| 43 | 52 | if params.Name.IsZero() { | |
| 44 | 53 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| @@ -46,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre | |||
| 46 | 55 | if p.IsAnonymous() { | |
| 47 | 56 | return domain.Repo{}, domain.ErrUnauthenticated | |
| 48 | 57 | } | |
| 49 | - | if !canWrite(p, params.Name) { | |
| 58 | + | if !canCreateRepo(p, params.Name) { | |
| 50 | 59 | return domain.Repo{}, domain.ErrForbidden | |
| 51 | 60 | } | |
| 52 | 61 | if err := validateDescription(params.Description); err != nil { | |
M internal/usecase/repos_test.go +26 -2
| @@ -86,6 +86,18 @@ func TestCreate(t *testing.T) { | |||
| 86 | 86 | } | |
| 87 | 87 | }) | |
| 88 | 88 | ||
| 89 | + | t.Run("a token limited to branches may not create a repository", func(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | f := newFixture(t) // the mocks expect no call: nothing is staged | |
| 92 | + | for _, p := range []domain.Principal{ | |
| 93 | + | branchLimited(domain.ScopeRepoWrite), | |
| 94 | + | branchLimited(domain.ScopeRepoWrite, "tools"), | |
| 95 | + | } { | |
| 96 | + | _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")}) | |
| 97 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | ||
| 89 | 101 | t.Run("token with repo:write creates", func(t *testing.T) { | |
| 90 | 102 | t.Parallel() | |
| 91 | 103 | f := newFixture(t) | |
| @@ -179,8 +191,10 @@ func TestCreate(t *testing.T) { | |||
| 179 | 191 | }) | |
| 180 | 192 | } | |
| 181 | 193 | ||
| 182 | - | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |
| 183 | - | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}} | |
| 194 | + | // scopedToken is the principal of a token with one scope, limited to the | |
| 195 | + | // repositories and to the branch prefixes given (none means no limit). | |
| 196 | + | func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal { | |
| 197 | + | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes} | |
| 184 | 198 | for _, r := range repos { | |
| 185 | 199 | n, err := domain.ParseRepoName(r) | |
| 186 | 200 | if err != nil { | |
| @@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |||
| 191 | 205 | return domain.TokenPrincipal(tok) | |
| 192 | 206 | } | |
| 193 | 207 | ||
| 208 | + | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |
| 209 | + | return scopedToken(s, nil, repos...) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // branchLimited is a token with one scope, optionally limited to repositories, | |
| 213 | + | // and limited to the branches under "agent/". | |
| 214 | + | func branchLimited(s domain.Scope, repos ...string) domain.Principal { | |
| 215 | + | return scopedToken(s, []string{"agent/"}, repos...) | |
| 216 | + | } | |
| 217 | + | ||
| 194 | 218 | func TestGetAndListAccess(t *testing.T) { | |
| 195 | 219 | t.Parallel() | |
| 196 | 220 | ctx := context.Background() | |
M internal/usecase/repos_write.go +16 -2
| @@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err | |||
| 46 | 46 | ||
| 47 | 47 | // Update changes the description, the visibility, the default branch and the | |
| 48 | 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | |
| 49 | - | // (see adminAccess for what the others learn). At least one field must be set, | |
| 49 | + | // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set, | |
| 50 | 50 | // else domain.ErrInvalid. A default branch that does not exist gives | |
| 51 | 51 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | |
| 52 | 52 | // The metadata is written first, then HEAD, and RepoUpdated is published after | |
| @@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 174 | 174 | ||
| 175 | 175 | // Delete moves the repository directory to the trash, removes its metadata | |
| 176 | 176 | // and publishes RepoDeleted. The principal needs repo:admin on it (see | |
| 177 | - | // adminAccess). A repository whose directory is already gone, left by a | |
| 177 | + | // adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a | |
| 178 | 178 | // delete that failed half way, loses its metadata. | |
| 179 | 179 | func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | |
| 180 | 180 | repo, err := lookupRepo(ctx, r.meta, name) | |
| @@ -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 | |
M internal/usecase/repos_write_test.go +26 -0
| @@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) { | |||
| 165 | 165 | }) | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | + | t.Run("a token limited to branches may not change any setting", func(t *testing.T) { | |
| 169 | + | t.Parallel() | |
| 170 | + | modes := []domain.MergeMode{domain.MergeModeSquash} | |
| 171 | + | block := true | |
| 172 | + | for name, params := range map[string]ports.UpdateRepoParams{ | |
| 173 | + | "default branch": {DefaultBranch: ptr("dev")}, | |
| 174 | + | "visibility": {Visibility: ptr(domain.VisibilityPublic)}, | |
| 175 | + | "description": {Description: ptr("x")}, | |
| 176 | + | "merge modes": {AllowedMergeModes: &modes}, | |
| 177 | + | "checks rule": {FailingChecksBlock: &block}, | |
| 178 | + | } { | |
| 179 | + | f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish | |
| 180 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 181 | + | _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params) | |
| 182 | + | assert.ErrorIs(t, err, domain.ErrForbidden, name) | |
| 183 | + | } | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | t.Run("a token limited to branches may not delete a repository", func(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | f := newFixture(t) // no Trash, no Delete | |
| 189 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 190 | + | err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n) | |
| 191 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 192 | + | }) | |
| 193 | + | ||
| 168 | 194 | t.Run("a missing repository is not found", func(t *testing.T) { | |
| 169 | 195 | t.Parallel() | |
| 170 | 196 | f := newFixture(t) | |
M internal/usecase/sessions.go +2 -2
| @@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | // requireOwner is the access rule of the session API: root, or a stored token | |
| 77 | - | // with every scope and no repository list. | |
| 77 | + | // with every scope, no repository list and no branch prefixes. | |
| 78 | 78 | func requireOwner(p domain.Principal) error { | |
| 79 | 79 | switch { | |
| 80 | 80 | case p.IsAnonymous(): | |
| 81 | 81 | return domain.ErrUnauthenticated | |
| 82 | 82 | case !p.HasOwnerRights(): | |
| 83 | - | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden) | |
| 83 | + | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden) | |
| 84 | 84 | } | |
| 85 | 85 | return nil | |
| 86 | 86 | } | |
M internal/usecase/tokens.go +26 -9
| @@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session | |||
| 54 | 54 | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | |
| 55 | 55 | // cannot create a token more powerful than itself: every scope must be one it | |
| 56 | 56 | // holds, the repositories must lie within its list (a limited token cannot | |
| 57 | - | // create an unlimited one) and the expiry must not outlast its own. Violations | |
| 57 | + | // create an unlimited one), the same for branch prefixes (each must start | |
| 58 | + | // with one of its own) and the expiry must not outlast its own. Violations | |
| 58 | 59 | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | |
| 59 | 60 | // domain.ErrInvalidToken (this includes the labels "owner" and "root", which | |
| 60 | 61 | // are reserved, in any case and around spaces), an unknown scope | |
| @@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr | |||
| 88 | 89 | log := zerowrap.FromCtx(ctx) | |
| 89 | 90 | log.Info(). | |
| 90 | 91 | Str("token_id", tok.ID).Str("label", tok.Label). | |
| 91 | - | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)). | |
| 92 | + | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)). | |
| 92 | 93 | Str("created_by", p.Label()).Str("created_by_token", p.TokenID()). | |
| 93 | 94 | Msg("token created") | |
| 94 | 95 | return ports.CreatedToken{Token: tok, Secret: secret}, nil | |
| @@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||
| 122 | 123 | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | |
| 123 | 124 | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | |
| 124 | 125 | } | |
| 126 | + | prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes) | |
| 127 | + | if err != nil { | |
| 128 | + | return domain.Token{}, err | |
| 129 | + | } | |
| 125 | 130 | tok := domain.Token{ | |
| 126 | - | Label: params.Label, | |
| 127 | - | CreatedAt: now, | |
| 128 | - | ExpiresAt: params.ExpiresAt.UTC(), | |
| 131 | + | Label: params.Label, | |
| 132 | + | BranchPrefixes: prefixes, | |
| 133 | + | CreatedAt: now, | |
| 134 | + | ExpiresAt: params.ExpiresAt.UTC(), | |
| 129 | 135 | } | |
| 130 | 136 | // Canonical order, no duplicates. | |
| 131 | 137 | for _, s := range domain.AllScopes() { | |
| @@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||
| 143 | 149 | } | |
| 144 | 150 | ||
| 145 | 151 | // checkNoEscalation enforces that tok is within the rights of p: every scope | |
| 146 | - | // held by p, the repositories within its list and the expiry not beyond its | |
| 147 | - | // own. Root has no limits. | |
| 152 | + | // held by p, the repositories within its list, the branch prefixes within its | |
| 153 | + | // own (a limited p only makes tokens that are limited too, each prefix under | |
| 154 | + | // one of its own) and the expiry not beyond its own. Root has no limits. | |
| 148 | 155 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |
| 149 | 156 | if p.IsRoot() { | |
| 150 | 157 | return nil | |
| @@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 164 | 171 | } | |
| 165 | 172 | } | |
| 166 | 173 | } | |
| 174 | + | if p.IsBranchLimited() { | |
| 175 | + | if len(tok.BranchPrefixes) == 0 { | |
| 176 | + | return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden) | |
| 177 | + | } | |
| 178 | + | for _, bp := range tok.BranchPrefixes { | |
| 179 | + | if !p.CoversBranchPrefix(bp) { | |
| 180 | + | return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 167 | 184 | if exp := p.ExpiresAt(); !exp.IsZero() { | |
| 168 | 185 | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | |
| 169 | 186 | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | |
| @@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 173 | 190 | } | |
| 174 | 191 | ||
| 175 | 192 | // List returns the stored tokens the principal could have created, oldest | |
| 176 | - | // first: those whose scopes, repositories and expiry lie within its own (the | |
| 193 | + | // first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the | |
| 177 | 194 | // no-escalation rule of Create). Root sees every token. It needs token:admin. | |
| 178 | 195 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | |
| 179 | 196 | if err := requireTokenAdmin(p); err != nil { | |
| @@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, | |||
| 195 | 212 | // Create), so that it does not show itself. Root may revoke any token. | |
| 196 | 213 | // | |
| 197 | 214 | // A token that has owner rights (HasOwnerRights: every scope, no repository | |
| 198 | - | // list) could open web sessions, so revoking it also signs every browser out, | |
| 215 | + | // list and no branch prefixes) could open web sessions, so revoking it also signs every browser out, | |
| 199 | 216 | // as `ermite web logout --all` does: web sessions are not tied to the token | |
| 200 | 217 | // that opened them. The revocation holds even if signing out fails, which is | |
| 201 | 218 | // then reported as an error. | |
M internal/usecase/tokens_test.go +117 -0
| @@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||
| 177 | 177 | }) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | adminOn := func(prefixes []string) domain.Principal { | |
| 181 | + | return domain.TokenPrincipal(domain.Token{ | |
| 182 | + | ID: "creator", | |
| 183 | + | Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), | |
| 184 | + | BranchPrefixes: prefixes, | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | ||
| 180 | 188 | tests := []struct { | |
| 181 | 189 | name string | |
| 182 | 190 | principal domain.Principal | |
| @@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||
| 211 | 219 | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil}, | |
| 212 | 220 | {"root: everything", domain.Root(), | |
| 213 | 221 | ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil}, | |
| 222 | + | {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}), | |
| 223 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil}, | |
| 224 | + | {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}), | |
| 225 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil}, | |
| 226 | + | {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}), | |
| 227 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil}, | |
| 228 | + | {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}), | |
| 229 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden}, | |
| 230 | + | {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}), | |
| 231 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden}, | |
| 232 | + | {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}), | |
| 233 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden}, | |
| 234 | + | {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}), | |
| 235 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden}, | |
| 236 | + | {"unlimited creator may limit branches", admin(nil, time.Time{}), | |
| 237 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil}, | |
| 214 | 238 | } | |
| 215 | 239 | for _, tt := range tests { | |
| 216 | 240 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) { | |||
| 283 | 307 | "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true}, | |
| 284 | 308 | "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false}, | |
| 285 | 309 | "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false}, | |
| 310 | + | "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false}, | |
| 286 | 311 | } { | |
| 287 | 312 | t.Run(name, func(t *testing.T) { | |
| 288 | 313 | t.Parallel() | |
| @@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) { | |||
| 551 | 576 | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 552 | 577 | }) | |
| 553 | 578 | } | |
| 579 | + | ||
| 580 | + | func TestTokensCreateBranchPrefixes(t *testing.T) { | |
| 581 | + | t.Parallel() | |
| 582 | + | ctx := context.Background() | |
| 583 | + | ||
| 584 | + | t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) { | |
| 585 | + | t.Parallel() | |
| 586 | + | f := newTokenFixture(t) | |
| 587 | + | f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool { | |
| 588 | + | return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes) | |
| 589 | + | })).Return(nil) | |
| 590 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |
| 591 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"}, | |
| 592 | + | }) | |
| 593 | + | require.NoError(t, err) | |
| 594 | + | assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes) | |
| 595 | + | }) | |
| 596 | + | ||
| 597 | + | t.Run("no prefixes stays unlimited", func(t *testing.T) { | |
| 598 | + | t.Parallel() | |
| 599 | + | f := newTokenFixture(t) | |
| 600 | + | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 601 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 602 | + | require.NoError(t, err) | |
| 603 | + | assert.Empty(t, got.Token.BranchPrefixes) | |
| 604 | + | }) | |
| 605 | + | ||
| 606 | + | t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) { | |
| 607 | + | t.Parallel() | |
| 608 | + | nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"} | |
| 609 | + | for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} { | |
| 610 | + | f := newTokenFixture(t) // the store expects no Create | |
| 611 | + | _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |
| 612 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad, | |
| 613 | + | }) | |
| 614 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |
| 615 | + | } | |
| 616 | + | }) | |
| 617 | + | ||
| 618 | + | t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) { | |
| 619 | + | t.Parallel() | |
| 620 | + | f := newTokenFixture(t) // the store expects no Create | |
| 621 | + | corrupt := domain.TokenPrincipal(domain.Token{ | |
| 622 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""}, | |
| 623 | + | }) | |
| 624 | + | _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{ | |
| 625 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"}, | |
| 626 | + | }) | |
| 627 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 628 | + | }) | |
| 629 | + | ||
| 630 | + | t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) { | |
| 631 | + | t.Parallel() | |
| 632 | + | ctx := context.Background() | |
| 633 | + | creator := domain.TokenPrincipal(domain.Token{ | |
| 634 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |
| 635 | + | }) | |
| 636 | + | inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |
| 637 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |
| 638 | + | sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}} | |
| 639 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |
| 640 | + | ||
| 641 | + | f := newTokenFixture(t) | |
| 642 | + | for _, tok := range []domain.Token{wide, sibling, free} { | |
| 643 | + | f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once() | |
| 644 | + | } | |
| 645 | + | // Out of reach: not found, like an unknown id, and nothing is revoked. | |
| 646 | + | for _, id := range []string{"wide", "sib", "free"} { | |
| 647 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id) | |
| 648 | + | } | |
| 649 | + | // Within its prefixes it revokes, and no web session is signed out. | |
| 650 | + | f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once() | |
| 651 | + | f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once() | |
| 652 | + | require.NoError(t, f.tokens.Revoke(ctx, creator, "in")) | |
| 653 | + | }) | |
| 654 | + | ||
| 655 | + | t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) { | |
| 656 | + | t.Parallel() | |
| 657 | + | f := newTokenFixture(t) | |
| 658 | + | mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |
| 659 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |
| 660 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |
| 661 | + | f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil) | |
| 662 | + | p := domain.TokenPrincipal(domain.Token{ | |
| 663 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |
| 664 | + | }) | |
| 665 | + | got, err := f.tokens.List(ctx, p) | |
| 666 | + | require.NoError(t, err) | |
| 667 | + | require.Len(t, got, 1) | |
| 668 | + | assert.Equal(t, "mine", got[0].ID) | |
| 669 | + | }) | |
| 670 | + | } | |
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 | + | } | |
M proto/ermite/storage/repo.proto +3 -0
| @@ -53,4 +53,7 @@ message Token { | |||
| 53 | 53 | google.protobuf.Timestamp created_at = 7; | |
| 54 | 54 | // Unset until the token is first used. | |
| 55 | 55 | google.protobuf.Timestamp last_used_at = 8; | |
| 56 | + | // Branch prefixes, each ending with "/", that the token may write; empty | |
| 57 | + | // means every branch. Added in storage schema 2. | |
| 58 | + | repeated string branch_prefixes = 9; | |
| 56 | 59 | } | |
M skills/ermite/CLI.md +8 -5
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # ermite CLI reference | |
| 2 | 2 | ||
| 3 | - | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads). Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 3 | + | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 4 | 4 | ||
| 5 | 5 | Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server. | |
| 6 | 6 | ||
| @@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created | |||
| 20 | 20 | ||
| 21 | 21 | | Command | Scope | Notes | | |
| 22 | 22 | |---|---|---| | |
| 23 | - | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | | |
| 23 | + | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | | |
| 24 | 24 | | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed | | |
| 25 | 25 | | `token revoke <id>` | `token:admin` | | | |
| 26 | 26 | ||
| @@ -44,7 +44,9 @@ Scopes: | |||
| 44 | 44 | | `token:admin` | create, list and revoke tokens | | |
| 45 | 45 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | |
| 46 | 46 | ||
| 47 | - | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`. | |
| 47 | + | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`. | |
| 48 | + | ||
| 49 | + | `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags. | |
| 48 | 50 | ||
| 49 | 51 | ## Merge requests | |
| 50 | 52 | ||
| @@ -77,9 +79,10 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||
| 77 | 79 | | Command | Notes | | |
| 78 | 80 | |---|---| | |
| 79 | 81 | | `serve` | configured by `ERMITE_*` environment variables only (see the project README) | | |
| 80 | - | | `version [--json]` | client version; with `ERMITE_URL`, the server version and features | | |
| 82 | + | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 83 | + | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 81 | 84 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 82 | - | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | |
| 85 | + | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
| 83 | 86 | | `web logout --all` | signs every browser out | | |
| 84 | 87 | | `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name | | |
| 85 | 88 | | `web passkey remove <id>` | removes a passkey and signs every browser out; the id comes from `list` | | |
M skills/ermite/SKILL.md +2 -0
| @@ -16,6 +16,8 @@ ermite version # client and server versions, warns about | |||
| 16 | 16 | ermite repo list | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | + | Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file. | |
| 20 | + | ||
| 19 | 21 | Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning. | |
| 20 | 22 | ||
| 21 | 23 | ## Git | |