Merge pull request 'feat(ci): job token, owner git URLs, GitHub REST subset and action resolution' (#22) from feat/ci2-execution into main
58 files · +6273 -114
M .hexcheck.yaml +3 -0
| @@ -73,6 +73,9 @@ allow: | |||
| 73 | 73 | - rule: no-adapter-to-adapter-imports | |
| 74 | 74 | path: internal/adapters/githttp/** | |
| 75 | 75 | reason: rate-limits Git clients with internal/clientlimit | |
| 76 | + | - rule: no-adapter-to-adapter-imports | |
| 77 | + | path: internal/adapters/githubapi/** | |
| 78 | + | reason: rate-limits the clients of the REST subset with internal/clientlimit | |
| 76 | 79 | - rule: no-adapter-to-adapter-imports | |
| 77 | 80 | path: internal/adapters/rpc/** | |
| 78 | 81 | reason: rate-limits API clients with internal/clientlimit | |
M README.md +11 -1
| @@ -93,6 +93,8 @@ git clone http://git.example.com/<repo>.git # public repositories need | |||
| 93 | 93 | git push http://u:<token>@git.example.com/<repo>.git main | |
| 94 | 94 | ``` | |
| 95 | 95 | ||
| 96 | + | The URL GitHub-shaped clients use, `http://git.example.com/<owner>/<repo>` (`<owner>` is `ERMITE_OWNER`, with or without `.git`), is served too; any other owner is a 404. The token goes as the password of Basic with any user (`x-access-token`), or as `Authorization: Bearer <token>` or `token <token>`. | |
| 97 | + | ||
| 96 | 98 | 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`). | |
| 97 | 99 | ||
| 98 | 100 | ## Branch-limited tokens | |
| @@ -116,7 +118,15 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a | |||
| 116 | 118 | ||
| 117 | 119 | 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. | |
| 118 | 120 | ||
| 119 | - | `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`. | |
| 121 | + | Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. | |
| 122 | + | ||
| 123 | + | An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress. | |
| 124 | + | ||
| 125 | + | The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature. | |
| 126 | + | ||
| 127 | + | Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git. | |
| 128 | + | ||
| 129 | + | `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 ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | |
| 120 | 130 | ||
| 121 | 131 | ## Web sign-in | |
| 122 | 132 | ||
M gen/ermite/storage/ci.pb.go +277 -32 generated file: collapsed
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 | |
| 16 | - | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 | |
| 16 | + | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
M go.sum +2 -2 generated file: collapsed
A internal/adapters/gitcli/archive.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os/exec" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // archiveTimeout bounds one archive when ctx has no deadline: a large | |
| 21 | + | // repository takes minutes, a stalled client must not hold a process forever. | |
| 22 | + | const archiveTimeout = 15 * time.Minute | |
| 23 | + | ||
| 24 | + | var _ ports.GitArchive = (*Git)(nil) | |
| 25 | + | ||
| 26 | + | // Archive streams the tree of the commit as a gzip-compressed tar, every path | |
| 27 | + | // below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here, | |
| 28 | + | // not by a gzip binary. A client that goes away ends ctx, which kills git. | |
| 29 | + | // Nothing is written to w when the commit does not exist | |
| 30 | + | // (domain.ErrNotFound). | |
| 31 | + | // | |
| 32 | + | //hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's | |
| 33 | + | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | |
| 34 | + | dir, err := g.repoDir(repo) | |
| 35 | + | if err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | if _, ok := ctx.Deadline(); !ok { | |
| 39 | + | var cancel context.CancelFunc | |
| 40 | + | ctx, cancel = context.WithTimeout(ctx, archiveTimeout) | |
| 41 | + | defer cancel() | |
| 42 | + | } | |
| 43 | + | release, err := g.acquireExec(ctx) | |
| 44 | + | if err != nil { | |
| 45 | + | return fmt.Errorf("git archive: %w", err) | |
| 46 | + | } | |
| 47 | + | defer release() | |
| 48 | + | ||
| 49 | + | cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...) | |
| 50 | + | cmd.Env = g.env | |
| 51 | + | cmd.Dir = g.dataDir | |
| 52 | + | cmd.WaitDelay = 5 * time.Second | |
| 53 | + | stderr := &tailBuffer{max: 4096} | |
| 54 | + | cmd.Stderr = stderr | |
| 55 | + | stdout, err := cmd.StdoutPipe() | |
| 56 | + | if err != nil { | |
| 57 | + | return fmt.Errorf("git archive: %w", err) | |
| 58 | + | } | |
| 59 | + | if err := cmd.Start(); err != nil { | |
| 60 | + | return fmt.Errorf("git archive: %w", err) | |
| 61 | + | } | |
| 62 | + | // The first byte decides whether git found the commit: wait for it before | |
| 63 | + | // writing to w, so that a missing commit can still be answered. | |
| 64 | + | first := make([]byte, 1) | |
| 65 | + | if n, _ := io.ReadFull(stdout, first); n == 0 { | |
| 66 | + | werr := cmd.Wait() | |
| 67 | + | switch { | |
| 68 | + | case ctx.Err() != nil: | |
| 69 | + | return ctx.Err() | |
| 70 | + | case werr != nil: | |
| 71 | + | return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String())) | |
| 72 | + | } | |
| 73 | + | return errors.New("git archive: empty output") | |
| 74 | + | } | |
| 75 | + | zw := gzip.NewWriter(w) | |
| 76 | + | _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout)) | |
| 77 | + | if cerr == nil { | |
| 78 | + | cerr = zw.Close() | |
| 79 | + | } | |
| 80 | + | if cerr != nil { | |
| 81 | + | _ = cmd.Process.Kill() | |
| 82 | + | } | |
| 83 | + | werr := cmd.Wait() | |
| 84 | + | switch { | |
| 85 | + | case ctx.Err() != nil: | |
| 86 | + | return ctx.Err() | |
| 87 | + | case cerr != nil: | |
| 88 | + | return fmt.Errorf("git archive %s: %w", repo, cerr) | |
| 89 | + | case werr != nil: | |
| 90 | + | return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String())) | |
| 91 | + | } | |
| 92 | + | return nil | |
| 93 | + | } | |
A internal/adapters/gitcli/archive_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "io" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) { | |
| 21 | + | g, data := newGit(t) | |
| 22 | + | ctx := context.Background() | |
| 23 | + | m := newMergeFixture(t, data) | |
| 24 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 25 | + | ||
| 26 | + | var buf bytes.Buffer | |
| 27 | + | require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf)) | |
| 28 | + | zr, err := gzip.NewReader(&buf) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | tr := tar.NewReader(zr) | |
| 31 | + | files := map[string]string{} | |
| 32 | + | for { | |
| 33 | + | h, err := tr.Next() | |
| 34 | + | if err == io.EOF { | |
| 35 | + | break | |
| 36 | + | } | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | if h.Typeflag == tar.TypeXGlobalHeader { | |
| 39 | + | // git archive records the commit id here, as GitHub's tarballs do. | |
| 40 | + | assert.Equal(t, m.d.String(), h.PAXRecords["comment"]) | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name) | |
| 44 | + | if h.Typeflag == tar.TypeReg { | |
| 45 | + | b, _ := io.ReadAll(tr) | |
| 46 | + | files[h.Name] = string(b) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD") | |
| 50 | + | assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"]) | |
| 51 | + | assert.NotContains(t, files, "owner-proj-abcdef0/.git") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) { | |
| 55 | + | g, data := newGit(t) | |
| 56 | + | ctx := context.Background() | |
| 57 | + | m := newMergeFixture(t, data) | |
| 58 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 59 | + | ||
| 60 | + | var buf bytes.Buffer | |
| 61 | + | missing, err := domain.ParseObjectID(strings.Repeat("1", 40)) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | err = g.Archive(ctx, m.f.Name, missing, "p", &buf) | |
| 64 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 65 | + | assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404") | |
| 66 | + | ||
| 67 | + | err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf) | |
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestArchiveStopsWithItsContext(t *testing.T) { | |
| 72 | + | g, data := newGit(t) | |
| 73 | + | m := newMergeFixture(t, data) | |
| 74 | + | t.Cleanup(func() { _ = g.Close(context.Background()) }) | |
| 75 | + | ||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | + | cancel() | |
| 78 | + | err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard) | |
| 79 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 80 | + | } | |
M internal/adapters/githttp/handler.go +53 -10
| @@ -38,6 +38,23 @@ var Patterns = []string{ | |||
| 38 | 38 | "POST /{repo}/git-receive-pack", | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | // OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is | |
| 42 | + | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | |
| 43 | + | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | |
| 44 | + | // segment so that the patterns never overlap the /-/ routes, and other owners | |
| 45 | + | // fall through to the web, which answers 404. The handler matches the owner | |
| 46 | + | // without regard to case, as GitHub does, but the mux only the exact spelling | |
| 47 | + | // of ERMITE_OWNER (lower case, like a repository name); register them only for | |
| 48 | + | // a valid owner and set Config.Owner to the same. | |
| 49 | + | func OwnerPatterns(owner string) []string { | |
| 50 | + | out := make([]string, len(Patterns)) | |
| 51 | + | for i, p := range Patterns { | |
| 52 | + | method, path, _ := strings.Cut(p, " ") | |
| 53 | + | out[i] = method + " /" + owner + path | |
| 54 | + | } | |
| 55 | + | return out | |
| 56 | + | } | |
| 57 | + | ||
| 41 | 58 | // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes | |
| 42 | 59 | // is zero. | |
| 43 | 60 | const DefaultMaxBodyBytes int64 = 4 << 30 | |
| @@ -67,6 +84,9 @@ type Config struct { | |||
| 67 | 84 | Repos ports.RepoLookup | |
| 68 | 85 | Pushes ports.PushService | |
| 69 | 86 | Backend ports.GitBackend | |
| 87 | + | // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/... | |
| 88 | + | // (see OwnerPatterns). Empty serves the second form nowhere. | |
| 89 | + | Owner string | |
| 70 | 90 | // Hooks provides hook tokens for pushes. It is required. | |
| 71 | 91 | Hooks ports.HookTokens | |
| 72 | 92 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| @@ -146,12 +166,36 @@ type route struct { | |||
| 146 | 166 | ||
| 147 | 167 | func (rt route) push() bool { return rt.service == serviceReceive } | |
| 148 | 168 | ||
| 169 | + | // gitSuffixes are the ends of the paths the handler serves, after the | |
| 170 | + | // repository. | |
| 171 | + | var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive} | |
| 172 | + | ||
| 149 | 173 | // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack | |
| 150 | - | // and /<repo>/git-receive-pack. info/refs without a service is the dumb | |
| 151 | - | // protocol, which ermite does not serve. | |
| 152 | - | func parseRoute(r *http.Request) (route, int) { | |
| 153 | - | seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") | |
| 154 | - | if !ok || seg == "" { | |
| 174 | + | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of | |
| 175 | + | // the forge. info/refs without a service is the dumb protocol, which ermite | |
| 176 | + | // does not serve. | |
| 177 | + | func (h *Handler) parseRoute(r *http.Request) (route, int) { | |
| 178 | + | p := strings.TrimPrefix(r.URL.Path, "/") | |
| 179 | + | var head, rest string | |
| 180 | + | for _, suffix := range gitSuffixes { | |
| 181 | + | if trimmed, ok := strings.CutSuffix(p, suffix); ok { | |
| 182 | + | head, rest = trimmed, suffix[1:] | |
| 183 | + | break | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | if rest == "" { | |
| 187 | + | return route{}, http.StatusNotFound | |
| 188 | + | } | |
| 189 | + | seg := head | |
| 190 | + | if owner, repo, ok := strings.Cut(head, "/"); ok { | |
| 191 | + | // The owner is matched as GitHub does, without regard to case, like the | |
| 192 | + | // REST subset and the references of actions (domain.OwnerMatches). | |
| 193 | + | if !domain.OwnerMatches(h.cfg.Owner, owner) { | |
| 194 | + | return route{}, http.StatusNotFound | |
| 195 | + | } | |
| 196 | + | seg = repo | |
| 197 | + | } | |
| 198 | + | if seg == "" || strings.Contains(seg, "/") { | |
| 155 | 199 | return route{}, http.StatusNotFound | |
| 156 | 200 | } | |
| 157 | 201 | rt := route{repo: seg, suffix: rest} | |
| @@ -175,8 +219,6 @@ func parseRoute(r *http.Request) (route, int) { | |||
| 175 | 219 | return route{}, http.StatusMethodNotAllowed | |
| 176 | 220 | } | |
| 177 | 221 | rt.service = rest | |
| 178 | - | default: | |
| 179 | - | return route{}, http.StatusNotFound | |
| 180 | 222 | } | |
| 181 | 223 | return rt, 0 | |
| 182 | 224 | } | |
| @@ -198,7 +240,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 198 | 240 | Msg("git request") | |
| 199 | 241 | }() | |
| 200 | 242 | ||
| 201 | - | rt, status := parseRoute(r) | |
| 243 | + | rt, status := h.parseRoute(r) | |
| 202 | 244 | if status != 0 { | |
| 203 | 245 | http.Error(w, http.StatusText(status), status) | |
| 204 | 246 | return | |
| @@ -265,7 +307,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, | |||
| 265 | 307 | } | |
| 266 | 308 | ||
| 267 | 309 | // credentials extracts the token from an Authorization header: the password of | |
| 268 | - | // Basic (the username is ignored) or a Bearer token. present is true when the | |
| 310 | + | // Basic (the username is ignored, actions/checkout sends x-access-token), or | |
| 311 | + | // the value of "Bearer" or "token". present is true when the | |
| 269 | 312 | // header exists, even if it carries nothing usable, which then fails | |
| 270 | 313 | // authentication. | |
| 271 | 314 | func credentials(r *http.Request) (secret string, present bool) { | |
| @@ -276,7 +319,7 @@ func credentials(r *http.Request) (secret string, present bool) { | |||
| 276 | 319 | if _, pass, ok := r.BasicAuth(); ok { | |
| 277 | 320 | return pass, true | |
| 278 | 321 | } | |
| 279 | - | if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") { | |
| 322 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { | |
| 280 | 323 | return strings.TrimSpace(tok), true | |
| 281 | 324 | } | |
| 282 | 325 | return "", true | |
M internal/adapters/githttp/handler_test.go +53 -2
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "net/netip" | |
| 12 | 12 | "os" | |
| 13 | 13 | "path/filepath" | |
| 14 | + | "slices" | |
| 14 | 15 | "strings" | |
| 15 | 16 | "sync" | |
| 16 | 17 | "testing" | |
| @@ -172,7 +173,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||
| 172 | 173 | repos := mocks.NewRepoLookup(t) | |
| 173 | 174 | r.stubUseCases(repos) | |
| 174 | 175 | cfg := githttp.Config{ | |
| 175 | - | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 176 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme", | |
| 176 | 177 | } | |
| 177 | 178 | script := cgiScript(t) | |
| 178 | 179 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |
| @@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||
| 188 | 189 | } | |
| 189 | 190 | r.h = githttp.New(cfg) | |
| 190 | 191 | mux := http.NewServeMux() | |
| 191 | - | for _, p := range githttp.Patterns { | |
| 192 | + | for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) { | |
| 192 | 193 | mux.Handle(p, r.h) | |
| 193 | 194 | } | |
| 194 | 195 | r.srv = httptest.NewServer(mux) | |
| @@ -506,6 +507,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||
| 506 | 507 | assert.Equal(t, 200, code) | |
| 507 | 508 | } | |
| 508 | 509 | ||
| 510 | + | // The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git: | |
| 511 | + | // served for the owner of the forge, 404 for anyone else and for what is not a | |
| 512 | + | // Git path. | |
| 513 | + | func TestHandlerServesTheOwnerForm(t *testing.T) { | |
| 514 | + | t.Parallel() | |
| 515 | + | tests := []struct { | |
| 516 | + | name, method, path, pass string | |
| 517 | + | want int | |
| 518 | + | }{ | |
| 519 | + | {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200}, | |
| 520 | + | {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200}, | |
| 521 | + | {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200}, | |
| 522 | + | {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401}, | |
| 523 | + | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | |
| 524 | + | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | |
| 525 | + | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 526 | + | {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200}, | |
| 527 | + | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 528 | + | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 529 | + | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 530 | + | {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 531 | + | {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405}, | |
| 532 | + | {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404}, | |
| 533 | + | } | |
| 534 | + | for _, tt := range tests { | |
| 535 | + | t.Run(tt.name, func(t *testing.T) { | |
| 536 | + | t.Parallel() | |
| 537 | + | r := newRig(t, nil) | |
| 538 | + | user := "" | |
| 539 | + | if tt.pass != "" { | |
| 540 | + | user = "x-access-token" | |
| 541 | + | } | |
| 542 | + | code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil) | |
| 543 | + | assert.Equal(t, tt.want, code) | |
| 544 | + | }) | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // Without an owner configured only the plain form is served. | |
| 549 | + | func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) { | |
| 550 | + | t.Parallel() | |
| 551 | + | r := newRig(t, func(c *githttp.Config) { c.Owner = "" }) | |
| 552 | + | code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 553 | + | assert.Equal(t, 404, code) | |
| 554 | + | code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 555 | + | assert.Equal(t, 200, code) | |
| 556 | + | } | |
| 557 | + | ||
| 509 | 558 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |
| 510 | 559 | t.Parallel() | |
| 511 | 560 | r := newRig(t, nil) | |
| @@ -514,6 +563,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |||
| 514 | 563 | assert.Equal(t, 200, code) | |
| 515 | 564 | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"}) | |
| 516 | 565 | assert.Equal(t, 200, code, "the scheme is case-insensitive") | |
| 566 | + | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"}) | |
| 567 | + | assert.Equal(t, 200, code, "GitHub's own scheme") | |
| 517 | 568 | for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} { | |
| 518 | 569 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h}) | |
| 519 | 570 | assert.Equal(t, 401, code, h) | |
A internal/adapters/githubapi/handler.go +345 -0
| @@ -0,0 +1,345 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package githubapi serves the subset of GitHub's REST API that the actions | |
| 4 | + | // running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2): | |
| 5 | + | // | |
| 6 | + | // GET /repos/{owner}/{repo} | |
| 7 | + | // GET /repos/{owner}/{repo}/hash-algorithm | |
| 8 | + | // GET /repos/{owner}/{repo}/tarball[/{ref}] | |
| 9 | + | // | |
| 10 | + | // Everything else under the prefix is GitHub's 404 JSON, so that an action | |
| 11 | + | // calling an endpoint of a feature ermite lacks fails as it does on a GitHub | |
| 12 | + | // instance without it, never silently succeeds. The auth is the forge's: a | |
| 13 | + | // token (stored, or the repository token of a job) as the password of Basic with | |
| 14 | + | // any user, or as "Bearer" or "token" credentials; no credentials is anonymous, | |
| 15 | + | // which reads public repositories. | |
| 16 | + | package githubapi | |
| 17 | + | ||
| 18 | + | import ( | |
| 19 | + | "cmp" | |
| 20 | + | "context" | |
| 21 | + | "errors" | |
| 22 | + | "io" | |
| 23 | + | "net/http" | |
| 24 | + | "net/netip" | |
| 25 | + | "strconv" | |
| 26 | + | "strings" | |
| 27 | + | "sync" | |
| 28 | + | "time" | |
| 29 | + | ||
| 30 | + | "github.com/bnema/zerowrap" | |
| 31 | + | ||
| 32 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 33 | + | "bnema.dev/ermite/internal/domain" | |
| 34 | + | "bnema.dev/ermite/internal/ports" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // Prefix is where the API is mounted. | |
| 38 | + | const Prefix = "/-/github/api" | |
| 39 | + | ||
| 40 | + | // Patterns are the ServeMux patterns the handler serves: its whole prefix. | |
| 41 | + | var Patterns = []string{Prefix + "/"} | |
| 42 | + | ||
| 43 | + | // notFoundBody is GitHub's body for an unknown endpoint or repository. | |
| 44 | + | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 45 | + | ||
| 46 | + | // retryAfter is the Retry-After, in seconds, of an answer that asks the client to | |
| 47 | + | // come back (429 and 503). | |
| 48 | + | const retryAfter = "5" | |
| 49 | + | ||
| 50 | + | // Bounds on the archives written at once. | |
| 51 | + | const ( | |
| 52 | + | // DefaultMaxArchives is the archives written at once on the server. | |
| 53 | + | DefaultMaxArchives = 16 | |
| 54 | + | // DefaultMaxArchivesPerClient is the archives written at once for one client | |
| 55 | + | // (see clientlimit.Key). A runner host runs several jobs at once, each | |
| 56 | + | // downloading its actions, and all come from one address; eight is half of | |
| 57 | + | // the server's slots, so one host cannot take them all. Beyond it the answer | |
| 58 | + | // is 429 with Retry-After: the runner waits and tries again. | |
| 59 | + | DefaultMaxArchivesPerClient = 8 | |
| 60 | + | // DefaultWriteIdleTimeout is how long a write of the archive may wait for the | |
| 61 | + | // client. The deadline moves forward with every chunk: an archive is bounded | |
| 62 | + | // by its size, not by its duration, but a client that stops reading is cut. | |
| 63 | + | DefaultWriteIdleTimeout = 2 * time.Minute | |
| 64 | + | ) | |
| 65 | + | ||
| 66 | + | // Config configures a Handler. | |
| 67 | + | type Config struct { | |
| 68 | + | Auth ports.AuthService | |
| 69 | + | API ports.GitHubAPIService | |
| 70 | + | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 71 | + | TrustedProxies []netip.Prefix | |
| 72 | + | // Limiter counts failed authentications; nil builds a default one. | |
| 73 | + | Limiter ports.FailureLimiter | |
| 74 | + | // MaxArchives bounds the archives written at once (each runs a git | |
| 75 | + | // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them | |
| 76 | + | // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is | |
| 77 | + | // the idle write deadline; zero means DefaultWriteIdleTimeout. | |
| 78 | + | MaxArchives int | |
| 79 | + | MaxArchivesPerClient int | |
| 80 | + | WriteIdleTimeout time.Duration | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Handler serves the REST subset. | |
| 84 | + | type Handler struct { | |
| 85 | + | cfg Config | |
| 86 | + | limiter ports.FailureLimiter | |
| 87 | + | archives chan struct{} | |
| 88 | + | perCli int | |
| 89 | + | idle time.Duration | |
| 90 | + | ||
| 91 | + | mu sync.Mutex // guards clients | |
| 92 | + | clients map[string]int // archives being written, per clientlimit.Key | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // New builds a Handler. | |
| 96 | + | func New(cfg Config) *Handler { | |
| 97 | + | h := &Handler{cfg: cfg, limiter: cfg.Limiter} | |
| 98 | + | if h.limiter == nil { | |
| 99 | + | h.limiter = clientlimit.NewLimiter(nil) | |
| 100 | + | } | |
| 101 | + | n := cfg.MaxArchives | |
| 102 | + | if n <= 0 { | |
| 103 | + | n = DefaultMaxArchives | |
| 104 | + | } | |
| 105 | + | h.archives = make(chan struct{}, n) | |
| 106 | + | h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient) | |
| 107 | + | h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout) | |
| 108 | + | h.clients = make(map[string]int) | |
| 109 | + | return h | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // ServeHTTP implements http.Handler. | |
| 113 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 114 | + | w.Header().Set("Cache-Control", "no-store") | |
| 115 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 116 | + | rest := strings.TrimPrefix(r.URL.Path, Prefix) | |
| 117 | + | kind, owner, repo, ref, ok := route(rest) | |
| 118 | + | if !ok { | |
| 119 | + | notFound(w) | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 123 | + | w.Header().Set("Allow", "GET, HEAD") | |
| 124 | + | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | ctx := r.Context() | |
| 129 | + | addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies) | |
| 130 | + | if blocked, _ := h.limiter.Blocked(ctx, addr); blocked { | |
| 131 | + | jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded") | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | p, err := h.authenticate(r, addr) | |
| 135 | + | if err != nil { | |
| 136 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 137 | + | jsonError(w, http.StatusUnauthorized, "Bad credentials") | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | log := zerowrap.FromCtx(ctx) | |
| 141 | + | log.Error().Err(err).Msg("authenticate github api request") | |
| 142 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | switch kind { | |
| 147 | + | case "repo": | |
| 148 | + | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | |
| 149 | + | if err != nil { | |
| 150 | + | h.fail(ctx, w, err) | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | writeJSON(w, r, body) | |
| 154 | + | case "hash": | |
| 155 | + | alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo) | |
| 156 | + | if err != nil { | |
| 157 | + | h.fail(ctx, w, err) | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | |
| 161 | + | case "tarball": | |
| 162 | + | h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr)) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // route recognises the three endpoints. ref is "" for /tarball. | |
| 167 | + | func route(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 168 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 169 | + | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | |
| 170 | + | return "", "", "", "", false | |
| 171 | + | } | |
| 172 | + | owner, repo = parts[1], parts[2] | |
| 173 | + | switch { | |
| 174 | + | case len(parts) == 3: | |
| 175 | + | return "repo", owner, repo, "", true | |
| 176 | + | case len(parts) == 4 && parts[3] == "hash-algorithm": | |
| 177 | + | return "hash", owner, repo, "", true | |
| 178 | + | case len(parts) >= 4 && parts[3] == "tarball": | |
| 179 | + | // The ref may hold slashes (feature/x): everything after tarball/. | |
| 180 | + | return "tarball", owner, repo, strings.Join(parts[4:], "/"), true | |
| 181 | + | } | |
| 182 | + | return "", "", "", "", false | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // tarball answers an archive request. A HEAD resolves the ref and sends the | |
| 186 | + | // headers of the archive, and runs no git. A GET streams the archive; it is not | |
| 187 | + | // a 302 to a codeload: ermite serves the bytes itself, which the runner follows | |
| 188 | + | // without a second credential. | |
| 189 | + | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) { | |
| 190 | + | ctx := r.Context() | |
| 191 | + | if r.Method == http.MethodHead { | |
| 192 | + | name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref) | |
| 193 | + | if err != nil { | |
| 194 | + | h.fail(ctx, w, err) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | setArchiveHeaders(w, name) | |
| 198 | + | w.WriteHeader(http.StatusOK) | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | release, ok := h.acquire(w, client) | |
| 202 | + | if !ok { | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | defer release() | |
| 206 | + | rc := http.NewResponseController(w) | |
| 207 | + | started := false | |
| 208 | + | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |
| 209 | + | started = true | |
| 210 | + | setArchiveHeaders(w, name) | |
| 211 | + | return &idleWriter{w: w, rc: rc, idle: h.idle} | |
| 212 | + | }) | |
| 213 | + | switch { | |
| 214 | + | case err == nil: | |
| 215 | + | case started: | |
| 216 | + | // The status is out: the client sees a truncated archive. Log it. | |
| 217 | + | log := zerowrap.FromCtx(ctx) | |
| 218 | + | log.Warn().Err(err).Msg("github api archive cut short") | |
| 219 | + | default: | |
| 220 | + | h.fail(ctx, w, err) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // acquire takes an archive slot for the client, or answers 429 (the client | |
| 225 | + | // already has its share) or 503 (the server writes all it can) and reports | |
| 226 | + | // false. It never waits: an archive can take minutes. | |
| 227 | + | func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) { | |
| 228 | + | h.mu.Lock() | |
| 229 | + | if h.clients[client] >= h.perCli { | |
| 230 | + | h.mu.Unlock() | |
| 231 | + | w.Header().Set("Retry-After", retryAfter) | |
| 232 | + | jsonError(w, http.StatusTooManyRequests, "Too many archives in progress") | |
| 233 | + | return nil, false | |
| 234 | + | } | |
| 235 | + | h.clients[client]++ | |
| 236 | + | h.mu.Unlock() | |
| 237 | + | releaseClient := func() { | |
| 238 | + | h.mu.Lock() | |
| 239 | + | if h.clients[client]--; h.clients[client] <= 0 { | |
| 240 | + | delete(h.clients, client) | |
| 241 | + | } | |
| 242 | + | h.mu.Unlock() | |
| 243 | + | } | |
| 244 | + | select { | |
| 245 | + | case h.archives <- struct{}{}: | |
| 246 | + | return func() { <-h.archives; releaseClient() }, true | |
| 247 | + | default: | |
| 248 | + | releaseClient() | |
| 249 | + | w.Header().Set("Retry-After", retryAfter) | |
| 250 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 251 | + | return nil, false | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // idleWriter moves the write deadline of the connection forward before each | |
| 256 | + | // chunk, so that only a client that stops reading is cut. Where the response | |
| 257 | + | // writer cannot set a deadline, the writes are not bounded here. | |
| 258 | + | type idleWriter struct { | |
| 259 | + | w io.Writer | |
| 260 | + | rc *http.ResponseController | |
| 261 | + | idle time.Duration | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (i *idleWriter) Write(b []byte) (int, error) { | |
| 265 | + | _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle)) | |
| 266 | + | return i.w.Write(b) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func setArchiveHeaders(w http.ResponseWriter, name string) { | |
| 270 | + | w.Header().Set("Content-Type", "application/x-gzip") | |
| 271 | + | w.Header().Set("Content-Disposition", "attachment; filename="+name) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // authenticate reads the credentials of the request. None is the anonymous | |
| 275 | + | // principal; credentials that are not valid count as a failure of the client | |
| 276 | + | // address. | |
| 277 | + | func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) { | |
| 278 | + | secret, present := credentials(r) | |
| 279 | + | if !present { | |
| 280 | + | return domain.Anonymous(), nil | |
| 281 | + | } | |
| 282 | + | p, err := h.cfg.Auth.Authenticate(r.Context(), secret) | |
| 283 | + | if err != nil { | |
| 284 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 285 | + | h.limiter.Fail(r.Context(), addr) | |
| 286 | + | } | |
| 287 | + | return domain.Anonymous(), err | |
| 288 | + | } | |
| 289 | + | return p, nil | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // credentials extracts the token of an Authorization header: the password of | |
| 293 | + | // Basic (the user is ignored, checkout sends x-access-token), or the value of | |
| 294 | + | // "Bearer" or "token". | |
| 295 | + | func credentials(r *http.Request) (secret string, present bool) { | |
| 296 | + | auth := r.Header.Get("Authorization") | |
| 297 | + | if auth == "" { | |
| 298 | + | return "", false | |
| 299 | + | } | |
| 300 | + | if _, pass, ok := r.BasicAuth(); ok { | |
| 301 | + | return pass, true | |
| 302 | + | } | |
| 303 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { | |
| 304 | + | return strings.TrimSpace(tok), true | |
| 305 | + | } | |
| 306 | + | return "", true | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // fail answers an error of a use case. A repository the caller cannot read is | |
| 310 | + | // as not found as one that does not exist. | |
| 311 | + | func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) { | |
| 312 | + | switch { | |
| 313 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |
| 314 | + | notFound(w) | |
| 315 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 316 | + | w.Header().Set("Retry-After", retryAfter) | |
| 317 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 318 | + | case errors.Is(err, context.Canceled): | |
| 319 | + | default: | |
| 320 | + | log := zerowrap.FromCtx(ctx) | |
| 321 | + | log.Error().Err(err).Msg("github api request failed") | |
| 322 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func notFound(w http.ResponseWriter) { | |
| 327 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 328 | + | w.WriteHeader(http.StatusNotFound) | |
| 329 | + | _, _ = io.WriteString(w, notFoundBody) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |
| 333 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 334 | + | w.WriteHeader(status) | |
| 335 | + | _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) { | |
| 339 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 340 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |
| 341 | + | w.WriteHeader(http.StatusOK) | |
| 342 | + | if r.Method != http.MethodHead { | |
| 343 | + | _, _ = w.Write(body) | |
| 344 | + | } | |
| 345 | + | } | |
A internal/adapters/githubapi/handler_test.go +370 -0
| @@ -0,0 +1,370 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubapi | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "net/http" | |
| 11 | + | "net/http/httptest" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | type rig struct { | |
| 26 | + | h *Handler | |
| 27 | + | srv *httptest.Server | |
| 28 | + | api *mocks.GitHubAPIService | |
| 29 | + | auth *mocks.AuthService | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func newRig(t *testing.T, mut func(*Config)) *rig { | |
| 33 | + | t.Helper() | |
| 34 | + | r := &rig{api: mocks.NewGitHubAPIService(t), auth: mocks.NewAuthService(t)} | |
| 35 | + | cfg := Config{Auth: r.auth, API: r.api} | |
| 36 | + | if mut != nil { | |
| 37 | + | mut(&cfg) | |
| 38 | + | } | |
| 39 | + | r.h = New(cfg) | |
| 40 | + | mux := http.NewServeMux() | |
| 41 | + | mux.Handle(Prefix+"/", r.h) | |
| 42 | + | r.srv = httptest.NewServer(mux) | |
| 43 | + | t.Cleanup(r.srv.Close) | |
| 44 | + | return r | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (r *rig) do(t *testing.T, method, path, authorization string) (*http.Response, string) { | |
| 48 | + | t.Helper() | |
| 49 | + | req, err := http.NewRequest(method, r.srv.URL+Prefix+path, nil) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | if authorization != "" { | |
| 52 | + | req.Header.Set("Authorization", authorization) | |
| 53 | + | } | |
| 54 | + | resp, err := http.DefaultClient.Do(req) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | defer func() { _ = resp.Body.Close() }() | |
| 57 | + | b, err := io.ReadAll(resp.Body) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | return resp, string(b) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | const tarballPath = "/repos/acme/proj/tarball/main" | |
| 63 | + | ||
| 64 | + | // blockingArchive makes Tarball write one chunk and then wait for release, so | |
| 65 | + | // that the archive stays in progress while the test looks at the others. | |
| 66 | + | type blockingArchive struct { | |
| 67 | + | started chan struct{} | |
| 68 | + | release chan struct{} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func newBlockingArchive() *blockingArchive { | |
| 72 | + | return &blockingArchive{started: make(chan struct{}, 32), release: make(chan struct{})} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (b *blockingArchive) run(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error { | |
| 76 | + | w := begin("acme-proj-abcdef0.tar.gz") | |
| 77 | + | _, _ = w.Write([]byte("chunk")) | |
| 78 | + | b.started <- struct{}{} | |
| 79 | + | select { | |
| 80 | + | case <-b.release: | |
| 81 | + | case <-ctx.Done(): | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // A HEAD resolves the ref and answers the headers of the archive: git archive is | |
| 87 | + | // not run (the mock fails on a call to Tarball), no slot is taken, and an error | |
| 88 | + | // is answered as for a GET. | |
| 89 | + | func TestHeadOfTheTarballRunsNoArchive(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 }) | |
| 92 | + | r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "main").Return("acme-proj-abcdef0.tar.gz", nil) | |
| 93 | + | r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "nope").Return("", domain.ErrNotFound) | |
| 94 | + | r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "").Return("", errors.New("disk")) | |
| 95 | + | ||
| 96 | + | resp, body := r.do(t, http.MethodHead, tarballPath, "") | |
| 97 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 98 | + | assert.Empty(t, body) | |
| 99 | + | assert.Equal(t, "attachment; filename=acme-proj-abcdef0.tar.gz", resp.Header.Get("Content-Disposition")) | |
| 100 | + | assert.Equal(t, "application/x-gzip", resp.Header.Get("Content-Type")) | |
| 101 | + | ||
| 102 | + | resp, body = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/nope", "") | |
| 103 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 104 | + | assert.Empty(t, body, "a HEAD has no body, the status says it") | |
| 105 | + | resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball", "") | |
| 106 | + | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |
| 107 | + | ||
| 108 | + | // The one slot is free for a GET after any number of HEADs. | |
| 109 | + | for range 5 { | |
| 110 | + | resp, _ := r.do(t, http.MethodHead, tarballPath, "") | |
| 111 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // The per-client cap answers 429 with Retry-After, the global cap 503 with | |
| 116 | + | // Retry-After, and a finished archive gives its slot back. | |
| 117 | + | func TestArchiveSlots(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | t.Run("per client", func(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | arch := newBlockingArchive() | |
| 122 | + | r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 8, 2 }) | |
| 123 | + | r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run) | |
| 124 | + | ||
| 125 | + | var wg sync.WaitGroup | |
| 126 | + | for range 2 { | |
| 127 | + | wg.Add(1) | |
| 128 | + | go func() { defer wg.Done(); r.do(t, http.MethodGet, tarballPath, "") }() | |
| 129 | + | } | |
| 130 | + | for range 2 { | |
| 131 | + | <-arch.started | |
| 132 | + | } | |
| 133 | + | resp, body := r.do(t, http.MethodGet, tarballPath, "") | |
| 134 | + | assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) | |
| 135 | + | assert.Equal(t, "5", resp.Header.Get("Retry-After")) | |
| 136 | + | assert.Contains(t, body, "Too many archives") | |
| 137 | + | assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") | |
| 138 | + | ||
| 139 | + | // Other endpoints and HEAD are not archives. | |
| 140 | + | r.api.EXPECT().HashAlgorithm(mock.Anything, mock.Anything, "acme", "proj").Return("sha1", nil) | |
| 141 | + | resp, _ = r.do(t, http.MethodGet, "/repos/acme/proj/hash-algorithm", "") | |
| 142 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 143 | + | ||
| 144 | + | close(arch.release) | |
| 145 | + | wg.Wait() | |
| 146 | + | // The slots are given back: two more go through. | |
| 147 | + | arch2 := newBlockingArchive() | |
| 148 | + | close(arch2.release) | |
| 149 | + | r.api.ExpectedCalls = nil | |
| 150 | + | r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run) | |
| 151 | + | for range 4 { | |
| 152 | + | resp, _ := r.do(t, http.MethodGet, tarballPath, "") | |
| 153 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | ||
| 157 | + | t.Run("global", func(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | arch := newBlockingArchive() | |
| 160 | + | r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 8 }) | |
| 161 | + | r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run) | |
| 162 | + | ||
| 163 | + | done := make(chan struct{}) | |
| 164 | + | go func() { defer close(done); r.do(t, http.MethodGet, tarballPath, "") }() | |
| 165 | + | <-arch.started | |
| 166 | + | resp, body := r.do(t, http.MethodGet, tarballPath, "") | |
| 167 | + | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |
| 168 | + | assert.Equal(t, "5", resp.Header.Get("Retry-After")) | |
| 169 | + | assert.Contains(t, body, "Server busy") | |
| 170 | + | close(arch.release) | |
| 171 | + | <-done | |
| 172 | + | ||
| 173 | + | // The client's own count was given back when the global slot refused it: | |
| 174 | + | // with a cap of one per client, the next one still gets a slot. | |
| 175 | + | r2 := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 }) | |
| 176 | + | arch2 := newBlockingArchive() | |
| 177 | + | r2.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run) | |
| 178 | + | done2 := make(chan struct{}) | |
| 179 | + | go func() { defer close(done2); r2.do(t, http.MethodGet, tarballPath, "") }() | |
| 180 | + | <-arch2.started | |
| 181 | + | resp, _ = r2.do(t, http.MethodGet, tarballPath, "") | |
| 182 | + | assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode, "the client cap answers first") | |
| 183 | + | close(arch2.release) | |
| 184 | + | <-done2 | |
| 185 | + | close(arch.started) | |
| 186 | + | }) | |
| 187 | + | ||
| 188 | + | t.Run("a server busy answer does not leak the client slot", func(t *testing.T) { | |
| 189 | + | t.Parallel() | |
| 190 | + | h := New(Config{MaxArchives: 1, MaxArchivesPerClient: 3, Auth: mocks.NewAuthService(t), API: mocks.NewGitHubAPIService(t)}) | |
| 191 | + | h.archives <- struct{}{} // the server is full | |
| 192 | + | for range 10 { | |
| 193 | + | rec := httptest.NewRecorder() | |
| 194 | + | _, ok := h.acquire(rec, "client") | |
| 195 | + | require.False(t, ok) | |
| 196 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 197 | + | assert.Equal(t, "5", rec.Header().Get("Retry-After")) | |
| 198 | + | } | |
| 199 | + | assert.Empty(t, h.clients, "no client count was left behind") | |
| 200 | + | <-h.archives | |
| 201 | + | release, ok := h.acquire(httptest.NewRecorder(), "client") | |
| 202 | + | require.True(t, ok) | |
| 203 | + | release() | |
| 204 | + | assert.Empty(t, h.clients) | |
| 205 | + | assert.Empty(t, h.archives) | |
| 206 | + | }) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // fakeRW is a response writer that can set a write deadline, like the one of | |
| 210 | + | // net/http, and records the deadlines and the writes. | |
| 211 | + | type fakeRW struct { | |
| 212 | + | header http.Header | |
| 213 | + | body strings.Builder | |
| 214 | + | deadlines []time.Time | |
| 215 | + | failWith error | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (f *fakeRW) Header() http.Header { return f.header } | |
| 219 | + | func (f *fakeRW) WriteHeader(int) {} | |
| 220 | + | func (f *fakeRW) Write(b []byte) (int, error) { | |
| 221 | + | if f.failWith != nil { | |
| 222 | + | return 0, f.failWith | |
| 223 | + | } | |
| 224 | + | return f.body.Write(b) | |
| 225 | + | } | |
| 226 | + | func (f *fakeRW) SetWriteDeadline(t time.Time) error { | |
| 227 | + | f.deadlines = append(f.deadlines, t) | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // The deadline of the connection moves forward before every chunk, by the idle | |
| 232 | + | // timeout, so that only a client that stops reading is cut. | |
| 233 | + | func TestIdleWriterMovesTheDeadlineForEveryChunk(t *testing.T) { | |
| 234 | + | t.Parallel() | |
| 235 | + | rw := &fakeRW{header: http.Header{}} | |
| 236 | + | w := &idleWriter{w: rw, rc: http.NewResponseController(rw), idle: 90 * time.Second} | |
| 237 | + | ||
| 238 | + | before := time.Now() | |
| 239 | + | for i := range 3 { | |
| 240 | + | n, err := w.Write([]byte("chunk")) | |
| 241 | + | require.NoError(t, err, i) | |
| 242 | + | assert.Equal(t, 5, n) | |
| 243 | + | time.Sleep(5 * time.Millisecond) | |
| 244 | + | } | |
| 245 | + | assert.Equal(t, "chunkchunkchunk", rw.body.String()) | |
| 246 | + | require.Len(t, rw.deadlines, 3, "one deadline per chunk") | |
| 247 | + | for i, d := range rw.deadlines { | |
| 248 | + | assert.WithinDuration(t, before.Add(90*time.Second), d, 2*time.Second, i) | |
| 249 | + | if i > 0 { | |
| 250 | + | assert.True(t, d.After(rw.deadlines[i-1]), "it moves forward: %d", i) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // A write error is returned as it is. | |
| 255 | + | rw.failWith = errors.New("broken pipe") | |
| 256 | + | _, err := w.Write([]byte("x")) | |
| 257 | + | assert.EqualError(t, err, "broken pipe") | |
| 258 | + | ||
| 259 | + | // A writer that cannot set a deadline still writes: the bound is then not | |
| 260 | + | // applied here, and nothing fails. | |
| 261 | + | plain := httptest.NewRecorder() | |
| 262 | + | pw := &idleWriter{w: plain, rc: http.NewResponseController(plain), idle: time.Second} | |
| 263 | + | _, err = pw.Write([]byte("ok")) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | assert.Equal(t, "ok", plain.Body.String()) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // A client that stops reading is cut after the idle timeout, with a real | |
| 269 | + | // connection: the archive is cut short, the slot is given back, and the next | |
| 270 | + | // download goes through. | |
| 271 | + | func TestAClientThatStopsReadingIsCut(t *testing.T) { | |
| 272 | + | t.Parallel() | |
| 273 | + | var writeErr = make(chan error, 1) | |
| 274 | + | r := newRig(t, func(c *Config) { | |
| 275 | + | c.MaxArchives, c.MaxArchivesPerClient, c.WriteIdleTimeout = 2, 1, 300*time.Millisecond | |
| 276 | + | }) | |
| 277 | + | chunk := make([]byte, 1<<20) | |
| 278 | + | r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn( | |
| 279 | + | func(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error { | |
| 280 | + | w := begin("a.tar.gz") | |
| 281 | + | // More than the socket buffers hold: the write blocks once the client | |
| 282 | + | // reads nothing, and fails when the idle deadline passes. | |
| 283 | + | for range 1024 { | |
| 284 | + | if _, err := w.Write(chunk); err != nil { | |
| 285 | + | writeErr <- err | |
| 286 | + | return err | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | writeErr <- nil | |
| 290 | + | return nil | |
| 291 | + | }).Once() | |
| 292 | + | ||
| 293 | + | // The client takes the headers and reads no body. | |
| 294 | + | conn, err := net.Dial("tcp", strings.TrimPrefix(r.srv.URL, "http://")) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | t.Cleanup(func() { _ = conn.Close() }) | |
| 297 | + | _, err = io.WriteString(conn, "GET "+Prefix+tarballPath+" HTTP/1.1\r\nHost: x\r\n\r\n") | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | ||
| 300 | + | select { | |
| 301 | + | case err := <-writeErr: | |
| 302 | + | require.Error(t, err, "the server gave up on a client that reads nothing") | |
| 303 | + | assert.Contains(t, err.Error(), "i/o timeout") | |
| 304 | + | case <-time.After(15 * time.Second): | |
| 305 | + | t.Fatal("the stalled client was never cut") | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // The slot came back: with a cap of one per client, another download starts. | |
| 309 | + | r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn( | |
| 310 | + | func(_ context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error { | |
| 311 | + | _, err := begin("a.tar.gz").Write([]byte("fine")) | |
| 312 | + | return err | |
| 313 | + | }).Once() | |
| 314 | + | require.Eventually(t, func() bool { | |
| 315 | + | resp, body := r.do(t, http.MethodGet, tarballPath, "") | |
| 316 | + | return resp.StatusCode == http.StatusOK && body == "fine" | |
| 317 | + | }, 5*time.Second, 50*time.Millisecond) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // The credentials of a request are the forge's: Basic with any user, Bearer or | |
| 321 | + | // token; none is anonymous and does not touch the failure limiter. | |
| 322 | + | func TestCredentialsAndNotFound(t *testing.T) { | |
| 323 | + | t.Parallel() | |
| 324 | + | r := newRig(t, nil) | |
| 325 | + | p := domain.Root() | |
| 326 | + | r.auth.EXPECT().Authenticate(mock.Anything, "good").Return(p, nil) | |
| 327 | + | r.auth.EXPECT().Authenticate(mock.Anything, "bad").Return(domain.Principal{}, domain.ErrUnauthenticated) | |
| 328 | + | // A header that carries no usable token authenticates the empty secret. | |
| 329 | + | r.auth.EXPECT().Authenticate(mock.Anything, "").Return(domain.Principal{}, domain.ErrUnauthenticated) | |
| 330 | + | r.api.EXPECT().HashAlgorithm(mock.Anything, p, "acme", "proj").Return("sha1", nil) | |
| 331 | + | r.api.EXPECT().HashAlgorithm(mock.Anything, domain.Anonymous(), "acme", "proj").Return("sha1", nil) | |
| 332 | + | ||
| 333 | + | path := "/repos/acme/proj/hash-algorithm" | |
| 334 | + | for _, h := range []string{"Basic eC1hY2Nlc3MtdG9rZW46Z29vZA==", "Bearer good", "token good", "bearer good"} { | |
| 335 | + | resp, body := r.do(t, http.MethodGet, path, h) | |
| 336 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, h) | |
| 337 | + | assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body) | |
| 338 | + | } | |
| 339 | + | resp, _ := r.do(t, http.MethodGet, path, "") | |
| 340 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "anonymous") | |
| 341 | + | for _, h := range []string{"Bearer bad", "Digest x", "Basic !!!", "Bearer"} { | |
| 342 | + | resp, body := r.do(t, http.MethodGet, path, h) | |
| 343 | + | assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, h) | |
| 344 | + | assert.Contains(t, body, "Bad credentials") | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Not found is GitHub's body, for an unknown endpoint and for a use case that | |
| 348 | + | // says so, and a method other than GET and HEAD is 405. | |
| 349 | + | const gh = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 350 | + | for _, p := range []string{"", "/", "/user", "/repos", "/repos/acme", "/repos//proj", "/repos/acme/proj/issues", "/repos/acme/proj/hash-algorithm/x"} { | |
| 351 | + | resp, body := r.do(t, http.MethodGet, p, "") | |
| 352 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode, p) | |
| 353 | + | assert.JSONEq(t, gh, body, p) | |
| 354 | + | } | |
| 355 | + | r.api.EXPECT().GetRepo(mock.Anything, domain.Anonymous(), "acme", "gone").Return(nil, domain.ErrNotFound) | |
| 356 | + | resp, body := r.do(t, http.MethodGet, "/repos/acme/gone", "") | |
| 357 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 358 | + | assert.JSONEq(t, gh, body) | |
| 359 | + | req, _ := http.NewRequest(http.MethodPost, r.srv.URL+Prefix+path, nil) | |
| 360 | + | post, err := http.DefaultClient.Do(req) | |
| 361 | + | require.NoError(t, err) | |
| 362 | + | _ = post.Body.Close() | |
| 363 | + | assert.Equal(t, http.StatusMethodNotAllowed, post.StatusCode) | |
| 364 | + | assert.Equal(t, "GET, HEAD", post.Header.Get("Allow")) | |
| 365 | + | ||
| 366 | + | // A ref with slashes is passed whole. | |
| 367 | + | r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "feature/x/y").Return("n.tar.gz", nil) | |
| 368 | + | resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/feature/x/y", "") | |
| 369 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 370 | + | } | |
A internal/adapters/gitremote/gitremote.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package gitremote lists the refs of repositories on github.com with | |
| 4 | + | // `git ls-remote`, for the resolution of actions (ports.GitRemote). It sends no | |
| 5 | + | // credentials, never prompts, and bounds the time and the output of git. | |
| 6 | + | package gitremote | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "bytes" | |
| 10 | + | "context" | |
| 11 | + | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "os/exec" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Defaults of the bounds. | |
| 22 | + | const ( | |
| 23 | + | // DefaultTimeout is how long one ls-remote may take. | |
| 24 | + | DefaultTimeout = 20 * time.Second | |
| 25 | + | // DefaultMaxOutput is the most bytes of ls-remote output read: a repository | |
| 26 | + | // with 100,000 tags lists about 8 MB. More is refused as unavailable. | |
| 27 | + | DefaultMaxOutput = 16 << 20 | |
| 28 | + | // DefaultBaseURL is where the repositories are. | |
| 29 | + | DefaultBaseURL = "https://github.com" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Config configures a Remote. | |
| 33 | + | type Config struct { | |
| 34 | + | // Bin is the git executable. Required. | |
| 35 | + | Bin string | |
| 36 | + | // Path is the PATH given to git, which it uses to find its helpers. | |
| 37 | + | Path string | |
| 38 | + | // Home is the HOME of git; it should be an empty directory the process | |
| 39 | + | // owns, so that no user configuration or credential is read. | |
| 40 | + | Home string | |
| 41 | + | // BaseURL, Timeout and MaxOutput default to the constants above; tests | |
| 42 | + | // replace them. | |
| 43 | + | BaseURL string | |
| 44 | + | Timeout time.Duration | |
| 45 | + | MaxOutput int | |
| 46 | + | // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default. | |
| 47 | + | AllowedProtocols string | |
| 48 | + | // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see | |
| 49 | + | // ProxyEnv. Nothing else of the environment of ermite reaches git. | |
| 50 | + | Proxy []string | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // proxyVars are the variables git (through libcurl) reads to reach github.com | |
| 54 | + | // through a proxy. They are the only ones of the environment of ermite that | |
| 55 | + | // the ls-remote processes get. | |
| 56 | + | var proxyVars = []string{ | |
| 57 | + | "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY", | |
| 58 | + | "https_proxy", "http_proxy", "no_proxy", "all_proxy", | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ProxyEnv picks the proxy variables from an environment lookup (os.Getenv), | |
| 62 | + | // for Config.Proxy. Empty ones are left out. | |
| 63 | + | func ProxyEnv(get func(string) string) []string { | |
| 64 | + | var out []string | |
| 65 | + | for _, name := range proxyVars { | |
| 66 | + | if v := get(name); v != "" { | |
| 67 | + | out = append(out, name+"="+v) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return out | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Remote implements ports.GitRemote over the git binary. | |
| 74 | + | type Remote struct{ cfg Config } | |
| 75 | + | ||
| 76 | + | var _ ports.GitRemote = (*Remote)(nil) | |
| 77 | + | ||
| 78 | + | // New builds a Remote. | |
| 79 | + | func New(cfg Config) *Remote { | |
| 80 | + | if cfg.BaseURL == "" { | |
| 81 | + | cfg.BaseURL = DefaultBaseURL | |
| 82 | + | } | |
| 83 | + | if cfg.Timeout <= 0 { | |
| 84 | + | cfg.Timeout = DefaultTimeout | |
| 85 | + | } | |
| 86 | + | if cfg.MaxOutput <= 0 { | |
| 87 | + | cfg.MaxOutput = DefaultMaxOutput | |
| 88 | + | } | |
| 89 | + | if cfg.AllowedProtocols == "" { | |
| 90 | + | cfg.AllowedProtocols = "https" | |
| 91 | + | } | |
| 92 | + | return &Remote{cfg: cfg} | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // validSegment reports whether s can be an owner or a repository name on | |
| 96 | + | // GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'. | |
| 97 | + | func validSegment(s string) bool { | |
| 98 | + | if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | for _, r := range s { | |
| 102 | + | switch { | |
| 103 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 104 | + | default: | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // limitWriter keeps at most max bytes and records that more came. | |
| 112 | + | type limitWriter struct { | |
| 113 | + | buf bytes.Buffer | |
| 114 | + | max int | |
| 115 | + | exceeded bool | |
| 116 | + | cancel context.CancelFunc | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |
| 120 | + | if w.buf.Len()+len(p) > w.max { | |
| 121 | + | w.exceeded = true | |
| 122 | + | w.cancel() | |
| 123 | + | return 0, errors.New("output limit reached") | |
| 124 | + | } | |
| 125 | + | return w.buf.Write(p) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ListRefs lists the branches and tags of owner/repo, with the peeled commit | |
| 129 | + | // of the annotated tags. | |
| 130 | + | func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 131 | + | if !validSegment(owner) || !validSegment(repo) { | |
| 132 | + | return nil, domain.ErrNotFound | |
| 133 | + | } | |
| 134 | + | ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout) | |
| 135 | + | defer cancel() | |
| 136 | + | out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel} | |
| 137 | + | var stderr bytes.Buffer | |
| 138 | + | url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo | |
| 139 | + | cmd := exec.CommandContext(ctx, r.cfg.Bin, | |
| 140 | + | "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false", | |
| 141 | + | "ls-remote", "--", url, "refs/heads/*", "refs/tags/*") | |
| 142 | + | cmd.Env = []string{ | |
| 143 | + | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home, | |
| 144 | + | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | |
| 145 | + | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | |
| 146 | + | } | |
| 147 | + | cmd.Env = append(cmd.Env, r.cfg.Proxy...) | |
| 148 | + | cmd.Dir = r.cfg.Home | |
| 149 | + | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | |
| 150 | + | cmd.WaitDelay = 2 * time.Second | |
| 151 | + | err := cmd.Run() | |
| 152 | + | switch { | |
| 153 | + | case out.exceeded: | |
| 154 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput) | |
| 155 | + | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 156 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout) | |
| 157 | + | case ctx.Err() != nil: | |
| 158 | + | return nil, ctx.Err() | |
| 159 | + | case err != nil: | |
| 160 | + | msg := stderr.String() | |
| 161 | + | if notFound(msg) { | |
| 162 | + | return nil, domain.ErrNotFound | |
| 163 | + | } | |
| 164 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg)) | |
| 165 | + | } | |
| 166 | + | return parse(out.buf.String()), nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // notFound recognises what git says when github.com answers 404 or asks for | |
| 170 | + | // credentials, which it does for a repository that does not exist as for a | |
| 171 | + | // private one, and the prompt being disabled. | |
| 172 | + | func notFound(stderr string) bool { | |
| 173 | + | for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} { | |
| 174 | + | if strings.Contains(stderr, s) { | |
| 175 | + | return true | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func firstLine(s string) string { | |
| 182 | + | s = strings.TrimSpace(s) | |
| 183 | + | if i := strings.IndexByte(s, '\n'); i >= 0 { | |
| 184 | + | s = s[:i] | |
| 185 | + | } | |
| 186 | + | return s | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // limitedBuffer keeps the first max bytes written and drops the rest. | |
| 190 | + | type limitedBuffer struct { | |
| 191 | + | b *bytes.Buffer | |
| 192 | + | max int | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (l *limitedBuffer) Write(p []byte) (int, error) { | |
| 196 | + | if room := l.max - l.b.Len(); room > 0 { | |
| 197 | + | l.b.Write(p[:min(room, len(p))]) | |
| 198 | + | } | |
| 199 | + | return len(p), nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives | |
| 203 | + | // the peeled commit of the annotated tag listed before it. | |
| 204 | + | func parse(out string) []ports.RemoteRef { | |
| 205 | + | var refs []ports.RemoteRef | |
| 206 | + | index := map[string]int{} | |
| 207 | + | for line := range strings.SplitSeq(out, "\n") { | |
| 208 | + | id, name, ok := strings.Cut(line, "\t") | |
| 209 | + | if !ok || len(id) != domain.ObjectIDLen { | |
| 210 | + | continue | |
| 211 | + | } | |
| 212 | + | if base, peeled := strings.CutSuffix(name, "^{}"); peeled { | |
| 213 | + | if i, ok := index[base]; ok { | |
| 214 | + | refs[i].Peeled = id | |
| 215 | + | } | |
| 216 | + | continue | |
| 217 | + | } | |
| 218 | + | index[name] = len(refs) | |
| 219 | + | refs = append(refs, ports.RemoteRef{Name: name, ID: id}) | |
| 220 | + | } | |
| 221 | + | return refs | |
| 222 | + | } | |
A internal/adapters/gitremote/gitremote_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitremote_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func gitBin(t *testing.T) string { | |
| 22 | + | t.Helper() | |
| 23 | + | bin, err := exec.LookPath("git") | |
| 24 | + | if err != nil { | |
| 25 | + | t.Skip("git is not installed") | |
| 26 | + | } | |
| 27 | + | return bin | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func run(t *testing.T, dir string, args ...string) string { | |
| 31 | + | t.Helper() | |
| 32 | + | cmd := exec.Command("git", args...) | |
| 33 | + | cmd.Dir = dir | |
| 34 | + | cmd.Env = []string{ | |
| 35 | + | "PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", | |
| 36 | + | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", | |
| 37 | + | } | |
| 38 | + | out, err := cmd.CombinedOutput() | |
| 39 | + | require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out) | |
| 40 | + | return strings.TrimSpace(string(out)) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // fixture builds <base>/o/r with a branch, a lightweight tag and an annotated | |
| 44 | + | // tag, and returns the base as a file:// URL. | |
| 45 | + | func fixture(t *testing.T) (base, commit, tagObj string) { | |
| 46 | + | t.Helper() | |
| 47 | + | root := t.TempDir() | |
| 48 | + | repo := filepath.Join(root, "o", "r") | |
| 49 | + | require.NoError(t, os.MkdirAll(repo, 0o755)) | |
| 50 | + | run(t, repo, "init", "-q", "--initial-branch=main") | |
| 51 | + | require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644)) | |
| 52 | + | run(t, repo, "add", "a") | |
| 53 | + | run(t, repo, "commit", "-q", "-m", "a") | |
| 54 | + | commit = run(t, repo, "rev-parse", "HEAD") | |
| 55 | + | run(t, repo, "tag", "light") | |
| 56 | + | run(t, repo, "tag", "-a", "-m", "annotated", "v1") | |
| 57 | + | tagObj = run(t, repo, "rev-parse", "refs/tags/v1") | |
| 58 | + | return "file://" + root, commit, tagObj | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote { | |
| 62 | + | t.Helper() | |
| 63 | + | cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"} | |
| 64 | + | if mut != nil { | |
| 65 | + | mut(&cfg) | |
| 66 | + | } | |
| 67 | + | return gitremote.New(cfg) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestListRefsPeelsAnnotatedTags(t *testing.T) { | |
| 71 | + | base, commit, tagObj := fixture(t) | |
| 72 | + | refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r") | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | byName := map[string][2]string{} | |
| 75 | + | for _, r := range refs { | |
| 76 | + | byName[r.Name] = [2]string{r.ID, r.Peeled} | |
| 77 | + | } | |
| 78 | + | assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"]) | |
| 79 | + | assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"]) | |
| 80 | + | assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled") | |
| 81 | + | assert.Len(t, refs, 3) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // github.com answers a repository that does not exist, or that is private, with | |
| 85 | + | // a 404 that git reports as a missing repository or as a prompt it may not | |
| 86 | + | // show; both are "repository not found". Any other failure is unavailable. | |
| 87 | + | func TestListRefsMapsWhatGitSays(t *testing.T) { | |
| 88 | + | fake := func(stderr string) string { | |
| 89 | + | bin := filepath.Join(t.TempDir(), "git") | |
| 90 | + | require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755)) | |
| 91 | + | return bin | |
| 92 | + | } | |
| 93 | + | for _, tc := range []struct { | |
| 94 | + | stderr string | |
| 95 | + | want error | |
| 96 | + | }{ | |
| 97 | + | {"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound}, | |
| 98 | + | {"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound}, | |
| 99 | + | {"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable}, | |
| 100 | + | {"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable}, | |
| 101 | + | } { | |
| 102 | + | _, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r") | |
| 103 | + | assert.ErrorIs(t, err, tc.want, tc.stderr) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestListRefsRefusesBadNames(t *testing.T) { | |
| 108 | + | r := newRemote(t, "file:///nonexistent", nil) | |
| 109 | + | for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} { | |
| 110 | + | _, err := r.ListRefs(context.Background(), bad[0], bad[1]) | |
| 111 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1]) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestListRefsBoundsTheOutput(t *testing.T) { | |
| 116 | + | base, _, _ := fixture(t) | |
| 117 | + | _, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r") | |
| 118 | + | assert.ErrorIs(t, err, domain.ErrUnavailable) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestListRefsTimesOut(t *testing.T) { | |
| 122 | + | bin := filepath.Join(t.TempDir(), "git") | |
| 123 | + | require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755)) | |
| 124 | + | start := time.Now() | |
| 125 | + | _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 200*time.Millisecond }).ListRefs(context.Background(), "o", "r") | |
| 126 | + | assert.ErrorIs(t, err, domain.ErrUnavailable) | |
| 127 | + | assert.Less(t, time.Since(start), 10*time.Second) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // git asks for no credentials and runs with no configuration of the machine: | |
| 131 | + | // the environment it gets is only what the adapter builds. | |
| 132 | + | func TestListRefsGitEnvironment(t *testing.T) { | |
| 133 | + | out := filepath.Join(t.TempDir(), "env") | |
| 134 | + | bin := filepath.Join(t.TempDir(), "git") | |
| 135 | + | script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n" | |
| 136 | + | require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) | |
| 137 | + | t.Setenv("GITHUB_TOKEN", "secret-from-the-process") | |
| 138 | + | _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r") | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | b, err := os.ReadFile(out) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | env := string(b) | |
| 143 | + | assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0") | |
| 144 | + | assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https") | |
| 145 | + | assert.NotContains(t, env, "secret-from-the-process") | |
| 146 | + | assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*") | |
| 147 | + | assert.Contains(t, env, "credential.helper=") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // GO-008: the proxy variables of ermite reach git, and nothing else of its | |
| 151 | + | // environment does. | |
| 152 | + | func TestListRefsForwardsTheProxyEnvironment(t *testing.T) { | |
| 153 | + | out := filepath.Join(t.TempDir(), "env") | |
| 154 | + | bin := filepath.Join(t.TempDir(), "git") | |
| 155 | + | require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755)) | |
| 156 | + | t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128") | |
| 157 | + | t.Setenv("no_proxy", "localhost,.internal") | |
| 158 | + | t.Setenv("HTTP_PROXY", "") | |
| 159 | + | t.Setenv("GITHUB_TOKEN", "not-for-git") | |
| 160 | + | ||
| 161 | + | proxy := gitremote.ProxyEnv(os.Getenv) | |
| 162 | + | assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out") | |
| 163 | + | ||
| 164 | + | _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r") | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | b, err := os.ReadFile(out) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | env := string(b) | |
| 169 | + | assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128") | |
| 170 | + | assert.Contains(t, env, "no_proxy=localhost,.internal") | |
| 171 | + | assert.NotContains(t, env, "not-for-git") | |
| 172 | + | assert.NotContains(t, env, "HTTP_PROXY=") | |
| 173 | + | ||
| 174 | + | // Without Config.Proxy nothing is forwarded, whatever the process has. | |
| 175 | + | _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r") | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | b, _ = os.ReadFile(out) | |
| 178 | + | assert.NotContains(t, string(b), "proxy.internal") | |
| 179 | + | } | |
M internal/adapters/kvstore/ci.go +10 -1
| @@ -37,6 +37,8 @@ import ( | |||
| 37 | 37 | // ciready/<ready_at:020d>/<job key> job waiting for a runner | |
| 38 | 38 | // ciactive/<job key> job assigned or running | |
| 39 | 39 | // jobtoken/<hash> job token (value: job key) | |
| 40 | + | // | |
| 41 | + | // The job repository tokens and the action resolutions are in ci_token.go. | |
| 40 | 42 | const ( | |
| 41 | 43 | runPrefix = "run/" | |
| 42 | 44 | runSrcPrefix = "runsrc/" | |
| @@ -507,6 +509,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||
| 507 | 509 | guarded[j.Repo] = true | |
| 508 | 510 | } | |
| 509 | 511 | } | |
| 512 | + | c.stageRepoTokens(ctx, b, w, guarded) | |
| 510 | 513 | // Checks are derived from the jobs, written in the same batch and under | |
| 511 | 514 | // the lock of their run: they are upserted without a precondition. | |
| 512 | 515 | for _, ck := range w.Checks { | |
| @@ -587,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||
| 587 | 590 | } | |
| 588 | 591 | src := mustMarshal(&storagepb.CIRunSource{ | |
| 589 | 592 | RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON, | |
| 593 | + | PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID, | |
| 590 | 594 | }) | |
| 591 | 595 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | |
| 592 | 596 | b.Put(runSrcKey(draft.Run.ID), src) | |
| @@ -639,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, | |||
| 639 | 643 | if err != nil { | |
| 640 | 644 | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | |
| 641 | 645 | } | |
| 642 | - | return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil | |
| 646 | + | return domain.CIRunSource{ | |
| 647 | + | RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(), | |
| 648 | + | Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()}, | |
| 649 | + | }, nil | |
| 643 | 650 | } | |
| 644 | 651 | ||
| 645 | 652 | // ListRuns returns the runs of the repository, newest first. | |
| @@ -828,6 +835,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l | |||
| 828 | 835 | // runKeys lists every key of a run except its record: source, jobs, indexes. | |
| 829 | 836 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | |
| 830 | 837 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 838 | + | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |
| 831 | 839 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 832 | 840 | keys = append(keys, slices.Clone(key)) | |
| 833 | 841 | if j, err := decodeJob(item); err == nil { | |
| @@ -852,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||
| 852 | 860 | } | |
| 853 | 861 | keys = append(keys, slices.Clone(key)) | |
| 854 | 862 | } | |
| 863 | + | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | |
| 855 | 864 | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} { | |
| 856 | 865 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 857 | 866 | keys = append(keys, slices.Clone(k)) | |
A internal/adapters/kvstore/ci_token.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "fmt" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | kv "bnema.dev/ermite-kv" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Key layout of the job repository tokens and of the action resolutions, | |
| 21 | + | // written by CI.Apply under the lock of their run: | |
| 22 | + | // | |
| 23 | + | // ghtoken/<id> CIRepoToken | |
| 24 | + | // ghtokenhash/<hash> token id | |
| 25 | + | // ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job | |
| 26 | + | // ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository | |
| 27 | + | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | |
| 28 | + | // | |
| 29 | + | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | |
| 30 | + | // job token is never listed, revoked or managed as a stored token. | |
| 31 | + | const ( | |
| 32 | + | repoTokenPrefix = "ghtoken/" | |
| 33 | + | repoTokenHashPrefix = "ghtokenhash/" | |
| 34 | + | repoTokenJobPrefix = "ghtokenjob/" | |
| 35 | + | repoTokenRepoPrefix = "ghtokenrepo/" | |
| 36 | + | resolutionPrefix = "actres/" | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | var _ ports.CIRepoTokenStore = (*CI)(nil) | |
| 40 | + | ||
| 41 | + | func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) } | |
| 42 | + | func repoTokenHashKey(h domain.TokenHash) []byte { | |
| 43 | + | return []byte(repoTokenHashPrefix + h.String()) | |
| 44 | + | } | |
| 45 | + | func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string { | |
| 46 | + | return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID) | |
| 47 | + | } | |
| 48 | + | func repoTokenJobKey(t domain.CIRepoToken) []byte { | |
| 49 | + | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | |
| 50 | + | } | |
| 51 | + | func repoTokenRepoPrefixOf(repo domain.RepoName) string { | |
| 52 | + | return repoTokenRepoPrefix + repo.String() + "/" | |
| 53 | + | } | |
| 54 | + | func repoTokenRepoKey(repo domain.RepoName, id string) []byte { | |
| 55 | + | return []byte(repoTokenRepoPrefixOf(repo) + id) | |
| 56 | + | } | |
| 57 | + | func resolutionKey(runID int64, attempt int32, key string) []byte { | |
| 58 | + | sum := sha256.Sum256([]byte(key)) | |
| 59 | + | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func encodeRepoToken(t domain.CIRepoToken) []byte { | |
| 63 | + | return mustMarshal(&storagepb.CIRepoToken{ | |
| 64 | + | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | |
| 65 | + | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func decodeRepoToken(val []byte) (domain.CIRepoToken, error) { | |
| 70 | + | var pb storagepb.CIRepoToken | |
| 71 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 72 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |
| 73 | + | } | |
| 74 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 75 | + | if err != nil { | |
| 76 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |
| 77 | + | } | |
| 78 | + | t := domain.CIRepoToken{ | |
| 79 | + | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |
| 80 | + | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 81 | + | Sha: pb.GetSha(), | |
| 82 | + | } | |
| 83 | + | copy(t.Hash[:], pb.GetHash()) | |
| 84 | + | return t, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // stageRepoTokens stages the creation of the tokens, the deletion of the | |
| 88 | + | // tokens of the jobs to revoke, and the resolutions to keep. | |
| 89 | + | func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) { | |
| 90 | + | snap := c.db.Snapshot(ctx) | |
| 91 | + | for _, ref := range w.RevokeJobs { | |
| 92 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) { | |
| 93 | + | b.Delete(append([]byte(nil), key...)) | |
| 94 | + | id := string(item.Value) | |
| 95 | + | if id == "" { | |
| 96 | + | id = string(key[strings.LastIndexByte(string(key), '/')+1:]) | |
| 97 | + | } | |
| 98 | + | b.Delete(repoTokenKey(id)) | |
| 99 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |
| 100 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 101 | + | b.Delete(repoTokenHashKey(t.Hash)) | |
| 102 | + | b.Delete(repoTokenRepoKey(t.Repo, id)) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | for _, t := range w.RepoTokens { | |
| 108 | + | b.RequireAbsent(repoTokenKey(t.ID)) | |
| 109 | + | b.RequireAbsent(repoTokenHashKey(t.Hash)) | |
| 110 | + | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | |
| 111 | + | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | |
| 112 | + | b.Put(repoTokenJobKey(t), []byte(t.ID)) | |
| 113 | + | b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID)) | |
| 114 | + | guarded[t.Repo] = true | |
| 115 | + | } | |
| 116 | + | for _, r := range w.Resolutions { | |
| 117 | + | b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{ | |
| 118 | + | Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2 | |
| 119 | + | })) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // FindRepoTokenByHash resolves the hash index, then reads the token. | |
| 124 | + | func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) { | |
| 125 | + | snap := c.db.Snapshot(ctx) | |
| 126 | + | idx, ok := snap.Get(ctx, repoTokenHashKey(hash)) | |
| 127 | + | if !ok { | |
| 128 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 129 | + | } | |
| 130 | + | it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value))) | |
| 131 | + | if !ok { | |
| 132 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 133 | + | } | |
| 134 | + | t, err := decodeRepoToken(it.Value) | |
| 135 | + | if err != nil { | |
| 136 | + | return domain.CIRepoToken{}, err | |
| 137 | + | } | |
| 138 | + | if t.Hash != hash { | |
| 139 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 140 | + | } | |
| 141 | + | return t, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // GetRepoToken reads ghtoken/<id>. | |
| 145 | + | func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) { | |
| 146 | + | if !validTokenID(id) { | |
| 147 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 148 | + | } | |
| 149 | + | it, ok := c.db.Get(ctx, repoTokenKey(id)) | |
| 150 | + | if !ok { | |
| 151 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 152 | + | } | |
| 153 | + | return decodeRepoToken(it.Value) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // GetActionResolution reads the resolution kept for the key. | |
| 157 | + | func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) { | |
| 158 | + | it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key)) | |
| 159 | + | if !ok { | |
| 160 | + | return domain.CIActionResolution{}, domain.ErrNotFound | |
| 161 | + | } | |
| 162 | + | var pb storagepb.CIActionResolution | |
| 163 | + | if err := proto.Unmarshal(it.Value, &pb); err != nil { | |
| 164 | + | return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err) | |
| 165 | + | } | |
| 166 | + | return domain.CIActionResolution{ | |
| 167 | + | RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(), | |
| 168 | + | ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()), | |
| 169 | + | }, nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // tokenKeys lists the keys of the token with that id: the record, its hash | |
| 173 | + | // index, its entry in the list of its job and in the list of its repository. | |
| 174 | + | func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte { | |
| 175 | + | keys := [][]byte{repoTokenKey(id)} | |
| 176 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |
| 177 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 178 | + | keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id)) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return keys | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // repoTokenKeys lists the keys of the repository tokens and action | |
| 185 | + | // resolutions of a run, for its deletion. | |
| 186 | + | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | |
| 187 | + | var keys [][]byte | |
| 188 | + | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | |
| 189 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 190 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) | |
| 191 | + | } | |
| 192 | + | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | |
| 193 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 194 | + | } | |
| 195 | + | return keys | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // repoReaderKeys lists the keys of every repository token that reads the | |
| 199 | + | // repository, whichever job holds it: the archive tokens given to the jobs of | |
| 200 | + | // other repositories go with the repository they read. | |
| 201 | + | func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 202 | + | var keys [][]byte | |
| 203 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) { | |
| 204 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 205 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) | |
| 206 | + | } | |
| 207 | + | return keys | |
| 208 | + | } | |
A internal/adapters/kvstore/ci_token_test.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func repoToken(id, secret string, run jobRefT, repo domain.RepoName, archive bool) domain.CIRepoToken { | |
| 18 | + | return domain.CIRepoToken{ | |
| 19 | + | ID: id, Hash: domain.HashTokenSecret(secret), Repo: repo, RunID: run.RunID, RunAttempt: 1, JobID: run.JobID, | |
| 20 | + | Archive: archive, ExpiresAt: created.Add(time.Hour), CreatedAt: created, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestCIRepoTokensLifecycle(t *testing.T) { | |
| 25 | + | ctx := context.Background() | |
| 26 | + | s, ci, n := ciRepo(t) | |
| 27 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2)) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | a, b := d.Jobs[0], d.Jobs[1] | |
| 30 | + | refA := jobRefT{RunID: d.Run.ID, JobID: a.ID} | |
| 31 | + | refB := jobRefT{RunID: d.Run.ID, JobID: b.ID} | |
| 32 | + | ||
| 33 | + | own := repoToken("t1", "ermitegh_own", refA, n, false) | |
| 34 | + | arch := repoToken("t2", "ermitegh_arch", refA, n, true) | |
| 35 | + | other := repoToken("t3", "ermitegh_other", refB, n, false) | |
| 36 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch, other}}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | got, err := ci.FindRepoTokenByHash(ctx, own.Hash) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.Equal(t, own.ID, got.ID) | |
| 42 | + | assert.Equal(t, n, got.Repo) | |
| 43 | + | assert.False(t, got.Archive) | |
| 44 | + | assert.True(t, got.ExpiresAt.Equal(own.ExpiresAt)) | |
| 45 | + | byID, err := ci.GetRepoToken(ctx, "t2") | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | assert.True(t, byID.Archive) | |
| 48 | + | _, err = ci.FindRepoTokenByHash(ctx, domain.HashTokenSecret("ermitegh_nope")) | |
| 49 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 50 | + | _, err = ci.GetRepoToken(ctx, "../token/x") | |
| 51 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | ||
| 53 | + | // They live apart from the stored tokens: ListTokens-style reads see none. | |
| 54 | + | stored, err := s.Tokens().List(ctx) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | assert.Empty(t, stored) | |
| 57 | + | _, err = s.Tokens().FindByHash(ctx, own.Hash) | |
| 58 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 59 | + | _, err = s.Tokens().Get(ctx, "t1") | |
| 60 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 61 | + | ||
| 62 | + | // A token that exists is not created twice. | |
| 63 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own}}) | |
| 64 | + | assert.Error(t, err) | |
| 65 | + | ||
| 66 | + | // Revoking a job deletes its tokens, and only its tokens. | |
| 67 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}}}) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | for _, tok := range []domain.CIRepoToken{own, arch} { | |
| 70 | + | _, err = ci.FindRepoTokenByHash(ctx, tok.Hash) | |
| 71 | + | assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID) | |
| 72 | + | _, err = ci.GetRepoToken(ctx, tok.ID) | |
| 73 | + | assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID) | |
| 74 | + | } | |
| 75 | + | _, err = ci.FindRepoTokenByHash(ctx, other.Hash) | |
| 76 | + | assert.NoError(t, err, "the other job keeps its token") | |
| 77 | + | ||
| 78 | + | // Revoking again, or a job without tokens, changes nothing. | |
| 79 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}, {RunID: 99, RunAttempt: 1, JobID: "99-0-0"}}}) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | ||
| 82 | + | // A token can be revoked and created again in one batch (a new assignment). | |
| 83 | + | again := repoToken("t4", "ermitegh_again", refB, n, false) | |
| 84 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: b.ID}}, RepoTokens: []domain.CIRepoToken{again}}) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | _, err = ci.FindRepoTokenByHash(ctx, other.Hash) | |
| 87 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 88 | + | _, err = ci.FindRepoTokenByHash(ctx, again.Hash) | |
| 89 | + | assert.NoError(t, err) | |
| 90 | + | ||
| 91 | + | // A token of a repository that no longer exists cannot be created. | |
| 92 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 93 | + | _, err = ci.FindRepoTokenByHash(ctx, again.Hash) | |
| 94 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "deleting the repository deletes the tokens of its runs") | |
| 95 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{repoToken("t5", "ermitegh_late", refA, n, false)}}) | |
| 96 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestCIActionResolutionsAreKeptPerAttemptAndSwept(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", 1)) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | r := domain.CIActionResolution{ | |
| 105 | + | RunID: d.Run.ID, RunAttempt: 1, Key: "actions/checkout@v4", NameWithOwner: "actions/checkout", | |
| 106 | + | ResolvedSHA: "1111111111111111111111111111111111111111", Source: domain.CIActionGitHub, | |
| 107 | + | } | |
| 108 | + | _, err = ci.Apply(ctx, ports.CIWrite{Resolutions: []domain.CIActionResolution{r}}) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | ||
| 111 | + | got, err := ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4") | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | assert.Equal(t, r, got) | |
| 114 | + | _, err = ci.GetActionResolution(ctx, d.Run.ID, 2, "actions/checkout@v4") | |
| 115 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "another attempt resolves again") | |
| 116 | + | _, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v5") | |
| 117 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 118 | + | ||
| 119 | + | // With a token of the run, both go when the run is swept. | |
| 120 | + | tok := repoToken("t1", "ermitegh_x", jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}, n, false) | |
| 121 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{tok}}) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | run, _ := ci.GetRun(ctx, n, d.Run.ID) | |
| 124 | + | run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, created | |
| 125 | + | job, _ := ci.GetJob(ctx, run.ID, 1, d.Jobs[0].ID) | |
| 126 | + | job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess | |
| 127 | + | _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}}) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | ids, err := ci.Sweep(ctx, created.Add(time.Hour)) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | assert.Equal(t, []int64{d.Run.ID}, ids) | |
| 132 | + | _, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4") | |
| 133 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 134 | + | _, err = ci.FindRepoTokenByHash(ctx, tok.Hash) | |
| 135 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // jobRefT names a job of the first attempt of a run in these tests. | |
| 139 | + | type jobRefT struct { | |
| 140 | + | RunID int64 | |
| 141 | + | JobID string | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // GO-006: an archive token is indexed by the repository it reads, so deleting | |
| 145 | + | // that repository deletes it, though it belongs to a job of another one. | |
| 146 | + | func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |
| 147 | + | ctx := context.Background() | |
| 148 | + | s, ci, run := ciRepo(t) | |
| 149 | + | target := name(t, "action") | |
| 150 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 151 | + | d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1)) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID} | |
| 154 | + | ||
| 155 | + | own := repoToken("own", "ermitegh_own", ref, run, false) | |
| 156 | + | arch := repoToken("arch", "ermitegh_arch", ref, target, true) | |
| 157 | + | arch.Sha = "1111111111111111111111111111111111111111" | |
| 158 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}}) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | got, err := ci.FindRepoTokenByHash(ctx, arch.Hash) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | assert.Equal(t, arch.Sha, got.Sha, "the commit is stored") | |
| 163 | + | assert.True(t, got.Archive) | |
| 164 | + | ||
| 165 | + | require.NoError(t, s.Delete(ctx, target)) | |
| 166 | + | _, err = ci.FindRepoTokenByHash(ctx, arch.Hash) | |
| 167 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads") | |
| 168 | + | _, err = ci.GetRepoToken(ctx, "arch") | |
| 169 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 170 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |
| 171 | + | assert.NoError(t, err, "the token of the run's own repository is untouched") | |
| 172 | + | ||
| 173 | + | // Revoking the job afterwards finds no stale entry to trip on. | |
| 174 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}}) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |
| 177 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // GO-001: the pusher recorded with a run comes back with its source. | |
| 181 | + | func TestCIRunSourceKeepsThePusher(t *testing.T) { | |
| 182 | + | ctx := context.Background() | |
| 183 | + | _, ci, n := ciRepo(t) | |
| 184 | + | for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} { | |
| 185 | + | d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft { | |
| 186 | + | dr := draftOf(n, "w.yml", 0)(id, number) | |
| 187 | + | dr.Source.Pusher = p | |
| 188 | + | return dr | |
| 189 | + | }) | |
| 190 | + | require.NoError(t, err, i) | |
| 191 | + | src, err := ci.GetRunSource(ctx, d.Run.ID) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | assert.Equal(t, p, src.Pusher) | |
| 194 | + | } | |
| 195 | + | } | |
M internal/adapters/rpc/api.go +1 -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", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 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", "ci.actions"} | |
| 32 | 32 | ||
| 33 | 33 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | 34 | const FeaturePasskeys = "passkeys" | |
M internal/adapters/rpc/ci.go +38 -0
| @@ -33,6 +33,9 @@ var runnerSecretProcedures = map[string]bool{ | |||
| 33 | 33 | ermiteconnect.RunnerServiceHeartbeatProcedure: true, | |
| 34 | 34 | ermiteconnect.RunnerServiceWatchJobProcedure: true, | |
| 35 | 35 | ermiteconnect.RunnerServiceCompleteJobProcedure: true, | |
| 36 | + | // ResolveActions and UploadSummary are job calls too. | |
| 37 | + | ermiteconnect.RunnerServiceResolveActionsProcedure: true, | |
| 38 | + | ermiteconnect.RunnerServiceUploadSummaryProcedure: true, | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | // ciError maps an error of the CI use cases. A call on a job that ended gets | |
| @@ -169,6 +172,30 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb | |||
| 169 | 172 | return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil | |
| 170 | 173 | } | |
| 171 | 174 | ||
| 175 | + | // | |
| 176 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps the references of the request to strings and the answers back to the contract; the rules are the use case's | |
| 177 | + | func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) { | |
| 178 | + | uses := make([]string, 0, len(req.Msg.GetRefs())) | |
| 179 | + | for _, r := range req.Msg.GetRefs() { | |
| 180 | + | uses = append(uses, r.GetUses()) | |
| 181 | + | } | |
| 182 | + | res, err := s.runners.ResolveActions(ctx, secretFrom(ctx), req.Msg.GetJobId(), uses) | |
| 183 | + | if err != nil { | |
| 184 | + | return nil, s.failed(ctx, err) | |
| 185 | + | } | |
| 186 | + | out := &pb.ResolveActionsResponse{Actions: make([]*pb.ResolvedAction, 0, len(res))} | |
| 187 | + | for _, a := range res { | |
| 188 | + | out.Actions = append(out.Actions, resolvedActionToPB(a)) | |
| 189 | + | } | |
| 190 | + | return connect.NewResponse(out), nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // UploadSummary is the E2 work (summaries storage): until the server lists | |
| 194 | + | // "ci.summaries" it answers unimplemented, as runner.proto says. | |
| 195 | + | func (s *runnerService) UploadSummary(context.Context, *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) { | |
| 196 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.UploadSummary is not implemented")) | |
| 197 | + | } | |
| 198 | + | ||
| 172 | 199 | // runService implements ermiteconnect.RunServiceHandler. | |
| 173 | 200 | type runService struct{ runs ports.RunService } | |
| 174 | 201 | ||
| @@ -306,3 +333,14 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R | |||
| 306 | 333 | } | |
| 307 | 334 | return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil | |
| 308 | 335 | } | |
| 336 | + | ||
| 337 | + | // ListAnnotations and GetStepSummaries are the E2 work (annotations and | |
| 338 | + | // summaries): unimplemented until the server lists "ci.annotations" and | |
| 339 | + | // "ci.summaries". | |
| 340 | + | func (s *runService) ListAnnotations(context.Context, *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) { | |
| 341 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.ListAnnotations is not implemented")) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (s *runService) GetStepSummaries(context.Context, *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) { | |
| 345 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.GetStepSummaries is not implemented")) | |
| 346 | + | } | |
M internal/adapters/rpc/ci_convert.go +16 -1
| @@ -205,10 +205,25 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job { | |||
| 205 | 205 | JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token, | |
| 206 | 206 | WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON, | |
| 207 | 207 | Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes, | |
| 208 | - | Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, | |
| 208 | + | Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken, | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | + | func resolvedActionToPB(a ports.ResolvedAction) *pb.ResolvedAction { | |
| 213 | + | out := &pb.ResolvedAction{Ref: a.Ref, Error: a.Error} | |
| 214 | + | if a.Error != "" { | |
| 215 | + | return out | |
| 216 | + | } | |
| 217 | + | out.NameWithOwner, out.Path, out.ResolvedSha, out.TarballUrl, out.Token = a.NameWithOwner, a.Path, a.ResolvedSHA, a.TarballURL, a.Token | |
| 218 | + | switch a.Source { | |
| 219 | + | case domain.CIActionGitHub: | |
| 220 | + | out.Source = pb.ActionSource_ACTION_SOURCE_GITHUB | |
| 221 | + | case domain.CIActionErmite: | |
| 222 | + | out.Source = pb.ActionSource_ACTION_SOURCE_ERMITE | |
| 223 | + | } | |
| 224 | + | return out | |
| 225 | + | } | |
| 226 | + | ||
| 212 | 227 | func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | |
| 213 | 228 | out := make([]ports.StepUpdate, 0, len(steps)) | |
| 214 | 229 | for _, s := range steps { | |
M internal/adapters/rpc/errors.go +2 -0
| @@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) { | |||
| 55 | 55 | return connect.CodeAborted, true | |
| 56 | 56 | case errors.Is(err, domain.ErrResourceExhausted): | |
| 57 | 57 | return connect.CodeResourceExhausted, true | |
| 58 | + | case errors.Is(err, domain.ErrUnavailable): | |
| 59 | + | return connect.CodeUnavailable, true | |
| 58 | 60 | } | |
| 59 | 61 | if domain.IsInvalid(err) { | |
| 60 | 62 | return connect.CodeInvalidArgument, true | |
M internal/adapters/web/web.go +1 -1
| @@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) { | |||
| 235 | 235 | return "", false | |
| 236 | 236 | } | |
| 237 | 237 | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | |
| 238 | - | if !ok || first != s.cfg.Owner || rest == "" { | |
| 238 | + | if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" { | |
| 239 | 239 | return "", false | |
| 240 | 240 | } | |
| 241 | 241 | repo, _, _ := strings.Cut(rest, "/") | |
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", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, 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", "ci.actions"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
A internal/app/ci_execution_test.go +1541 -0
| @@ -0,0 +1,1541 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "encoding/base64" | |
| 11 | + | "encoding/json" | |
| 12 | + | "io" | |
| 13 | + | "net/http" | |
| 14 | + | "net/http/httptest" | |
| 15 | + | "path/filepath" | |
| 16 | + | "slices" | |
| 17 | + | "strings" | |
| 18 | + | "sync" | |
| 19 | + | "sync/atomic" | |
| 20 | + | "testing" | |
| 21 | + | "time" | |
| 22 | + | ||
| 23 | + | "connectrpc.com/connect" | |
| 24 | + | "github.com/stretchr/testify/assert" | |
| 25 | + | "github.com/stretchr/testify/mock" | |
| 26 | + | "github.com/stretchr/testify/require" | |
| 27 | + | ||
| 28 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 29 | + | "bnema.dev/ermite/internal/domain" | |
| 30 | + | "bnema.dev/ermite/internal/ports" | |
| 31 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 32 | + | "bnema.dev/ermite/internal/usecase" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // CI-2 execution (phase E1): the job repository token, the git URLs of the | |
| 36 | + | // owner, the REST subset and the resolution of actions. | |
| 37 | + | ||
| 38 | + | // fakeRemote stands in for github.com: no test reaches the network. It is the | |
| 39 | + | // generated mock of ports.GitRemote answering from a table the test edits. | |
| 40 | + | type fakeRemote struct { | |
| 41 | + | m *mocks.GitRemote | |
| 42 | + | mu sync.Mutex | |
| 43 | + | refs map[string][]ports.RemoteRef | |
| 44 | + | err map[string]error | |
| 45 | + | calls []string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func newFakeRemote(t *testing.T) *fakeRemote { | |
| 49 | + | t.Helper() | |
| 50 | + | f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}} | |
| 51 | + | f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 52 | + | func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 53 | + | f.mu.Lock() | |
| 54 | + | defer f.mu.Unlock() | |
| 55 | + | key := strings.ToLower(owner + "/" + repo) | |
| 56 | + | f.calls = append(f.calls, key) | |
| 57 | + | if err := f.err[key]; err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | refs, ok := f.refs[key] | |
| 61 | + | if !ok { | |
| 62 | + | return nil, domain.ErrNotFound | |
| 63 | + | } | |
| 64 | + | return refs, nil | |
| 65 | + | }).Maybe() | |
| 66 | + | return f | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) { | |
| 70 | + | f.mu.Lock() | |
| 71 | + | defer f.mu.Unlock() | |
| 72 | + | f.refs[repo] = refs | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (f *fakeRemote) asked(key string) bool { | |
| 76 | + | f.mu.Lock() | |
| 77 | + | defer f.mu.Unlock() | |
| 78 | + | return slices.Contains(f.calls, key) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (f *fakeRemote) count() int { | |
| 82 | + | f.mu.Lock() | |
| 83 | + | defer f.mu.Unlock() | |
| 84 | + | return len(f.calls) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // newExecHarness is newCIHarness with a fake github.com. | |
| 88 | + | func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness { | |
| 89 | + | t.Helper() | |
| 90 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m }) | |
| 91 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 92 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | c.wt = h.workTree("main") | |
| 95 | + | return c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // startJob pushes a workflow and returns the job given to a runner. | |
| 99 | + | func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job { | |
| 100 | + | c.t.Helper() | |
| 101 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "") | |
| 102 | + | return runner.await() | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) { | |
| 106 | + | c.t.Helper() | |
| 107 | + | _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 108 | + | require.NoError(c.t, err) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // checkoutHeader is what actions/checkout sets in http.extraheader. | |
| 112 | + | func checkoutHeader(token string) string { | |
| 113 | + | return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // clone clones url as actions/checkout does: the token in http.extraheader. | |
| 117 | + | func (c *ciHarness) clone(token, url string, args ...string) (string, error) { | |
| 118 | + | dest := filepath.Join(c.t.TempDir(), "w") | |
| 119 | + | a := []string{} | |
| 120 | + | if token != "" { | |
| 121 | + | a = append(a, "-c", "http.extraheader="+checkoutHeader(token)) | |
| 122 | + | } | |
| 123 | + | a = append(a, "clone", "-q") | |
| 124 | + | a = append(a, args...) | |
| 125 | + | a = append(a, url, dest) | |
| 126 | + | return c.git(c.t.TempDir(), a...) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (c *ciHarness) get(path, authorization string) (int, string, http.Header) { | |
| 130 | + | c.t.Helper() | |
| 131 | + | req, err := http.NewRequest(http.MethodGet, c.url(path), nil) | |
| 132 | + | require.NoError(c.t, err) | |
| 133 | + | if authorization != "" { | |
| 134 | + | req.Header.Set("Authorization", authorization) | |
| 135 | + | } | |
| 136 | + | resp, err := http.DefaultClient.Do(req) | |
| 137 | + | require.NoError(c.t, err) | |
| 138 | + | defer func() { _ = resp.Body.Close() }() | |
| 139 | + | b, err := io.ReadAll(resp.Body) | |
| 140 | + | require.NoError(c.t, err) | |
| 141 | + | return resp.StatusCode, string(b), resp.Header | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func basic(token string) string { | |
| 145 | + | return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // AC3: the job gets a repository token at its assignment, in the Job and not in | |
| 149 | + | // the contexts; it clones its repository, public or private, over the URL | |
| 150 | + | // github.repository gives, with and without .git, the way checkout does. | |
| 151 | + | func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) { | |
| 152 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 153 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 154 | + | job := c.startJob(runner) | |
| 155 | + | tok := job.GetGithubToken() | |
| 156 | + | require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters") | |
| 157 | + | assert.NotEqual(t, job.GetJobToken(), tok) | |
| 158 | + | assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores") | |
| 159 | + | assert.NotContains(t, job.GetWorkflowYaml(), tok) | |
| 160 | + | c.heartbeat(runner, job) | |
| 161 | + | ||
| 162 | + | // Private: checkout's clone, shallow, without .git. | |
| 163 | + | out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1") | |
| 164 | + | require.NoError(t, err, out) | |
| 165 | + | out, err = c.clone(tok, c.url("/owner/proj.git")) | |
| 166 | + | require.NoError(t, err, out) | |
| 167 | + | out, err = c.clone(tok, c.url("/proj.git")) | |
| 168 | + | require.NoError(t, err, "the plain URL works too: "+out) | |
| 169 | + | // Without a token a private repository asks for credentials. | |
| 170 | + | _, err = c.clone("", c.url("/owner/proj")) | |
| 171 | + | require.Error(t, err) | |
| 172 | + | // Another owner has no repositories. | |
| 173 | + | out, err = c.clone(tok, c.url("/someone/proj")) | |
| 174 | + | require.Error(t, err, out) | |
| 175 | + | ||
| 176 | + | // The same token and the other schemes, on the wire. | |
| 177 | + | for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} { | |
| 178 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h) | |
| 179 | + | assert.Equal(t, http.StatusOK, status, h[:12]) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Public: anyone clones, the token too. | |
| 183 | + | vis := domain.VisibilityPublic | |
| 184 | + | _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | out, err = c.clone("", c.url("/owner/proj")) | |
| 187 | + | require.NoError(t, err, out) | |
| 188 | + | out, err = c.clone(tok, c.url("/owner/proj")) | |
| 189 | + | require.NoError(t, err, out) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) } | |
| 193 | + | ||
| 194 | + | // AC3: refused on another private repository, for push, and for the REST | |
| 195 | + | // subset of another repository; never a stored token for the API. | |
| 196 | + | func TestJobTokenReadsItsRepositoryOnly(t *testing.T) { | |
| 197 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 198 | + | c.seed("secret", domain.VisibilityPrivate, "a.txt") | |
| 199 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 200 | + | job := c.startJob(runner) | |
| 201 | + | tok := job.GetGithubToken() | |
| 202 | + | c.heartbeat(runner, job) | |
| 203 | + | ||
| 204 | + | out, err := c.clone(tok, c.url("/owner/secret")) | |
| 205 | + | require.Error(t, err) | |
| 206 | + | assert.Contains(t, out, "not found", "the other private repository does not show itself") | |
| 207 | + | status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok)) | |
| 208 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 209 | + | status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok)) | |
| 210 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 211 | + | ||
| 212 | + | // Push: the receive-pack advertisement and the push itself are 403. | |
| 213 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |
| 214 | + | assert.Equal(t, http.StatusForbidden, status) | |
| 215 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "x") | |
| 216 | + | out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x") | |
| 217 | + | require.Error(t, err) | |
| 218 | + | assert.Contains(t, out, "403") | |
| 219 | + | assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed") | |
| 220 | + | ||
| 221 | + | // The API takes it as the principal it is (repo:read on one repository): it | |
| 222 | + | // lists that repository and no other, and writes nothing. | |
| 223 | + | list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | require.Len(t, list.Msg.GetRepos(), 1) | |
| 226 | + | assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName()) | |
| 227 | + | _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"})) | |
| 228 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 229 | + | _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}})) | |
| 230 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 231 | + | _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 232 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 233 | + | _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 234 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write") | |
| 235 | + | ||
| 236 | + | // A made-up token of the right shape is refused, and so are wrong ones. | |
| 237 | + | for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} { | |
| 238 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad)) | |
| 239 | + | assert.Equal(t, http.StatusUnauthorized, status, bad) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // The token is not a stored token: ListTokens does not show it, token:admin | |
| 244 | + | // does not manage it, and the principal it gives is read-only on one repository. | |
| 245 | + | func TestJobTokenIsNotAStoredToken(t *testing.T) { | |
| 246 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 247 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 248 | + | job := c.startJob(runner) | |
| 249 | + | tok := job.GetGithubToken() | |
| 250 | + | ||
| 251 | + | before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | assert.Empty(t, before.Msg.GetTokens(), "no stored token exists") | |
| 254 | + | ||
| 255 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes()) | |
| 258 | + | assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos()) | |
| 259 | + | assert.False(t, p.ExpiresAt().IsZero()) | |
| 260 | + | assert.False(t, p.HasScope(domain.ScopeTokenAdmin)) | |
| 261 | + | assert.False(t, p.IsRoot()) | |
| 262 | + | ||
| 263 | + | // Revoking by its id (the "job:" id the principal shows) finds nothing. | |
| 264 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")})) | |
| 265 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 266 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()})) | |
| 267 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 268 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |
| 269 | + | require.NoError(t, err, "still valid") | |
| 270 | + | require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p)) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // AC3: refused after the job ends, however it ends. | |
| 274 | + | func TestJobTokenEndsWithTheJob(t *testing.T) { | |
| 275 | + | cloneStatus := func(c *ciHarness, tok string) int { | |
| 276 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |
| 277 | + | return status | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | t.Run("CompleteJob", func(t *testing.T) { | |
| 281 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 282 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 283 | + | job := c.startJob(runner) | |
| 284 | + | tok := job.GetGithubToken() | |
| 285 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 288 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 289 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |
| 290 | + | assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops") | |
| 291 | + | out, err := c.clone(tok, c.url("/owner/proj")) | |
| 292 | + | require.Error(t, err, out) | |
| 293 | + | }) | |
| 294 | + | ||
| 295 | + | t.Run("cancel", func(t *testing.T) { | |
| 296 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 297 | + | cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 298 | + | }) | |
| 299 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 300 | + | job := c.startJob(runner) | |
| 301 | + | c.heartbeat(runner, job) | |
| 302 | + | tok := job.GetGithubToken() | |
| 303 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 304 | + | _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 305 | + | require.NoError(t, err) | |
| 306 | + | // The runner is told to stop, and the job concludes when it answers or | |
| 307 | + | // when the grace ends: either way the token goes with it. | |
| 308 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 309 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus()) | |
| 310 | + | }) | |
| 311 | + | ||
| 312 | + | t.Run("lost runner", func(t *testing.T) { | |
| 313 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 314 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 315 | + | }) | |
| 316 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 317 | + | job := c.startJob(runner) | |
| 318 | + | c.heartbeat(runner, job) | |
| 319 | + | tok := job.GetGithubToken() | |
| 320 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 321 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 322 | + | assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication") | |
| 323 | + | }) | |
| 324 | + | ||
| 325 | + | t.Run("assignment not acknowledged", func(t *testing.T) { | |
| 326 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 327 | + | cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 328 | + | }) | |
| 329 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 330 | + | job := c.startJob(runner) | |
| 331 | + | tok := job.GetGithubToken() | |
| 332 | + | c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 333 | + | again := runner.await() | |
| 334 | + | assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token") | |
| 335 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken())) | |
| 336 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked") | |
| 337 | + | }) | |
| 338 | + | ||
| 339 | + | t.Run("repository deleted", func(t *testing.T) { | |
| 340 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 341 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 342 | + | job := c.startJob(runner) | |
| 343 | + | tok := job.GetGithubToken() | |
| 344 | + | _, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj"))) | |
| 347 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |
| 348 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone") | |
| 349 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |
| 350 | + | }) | |
| 351 | + | ||
| 352 | + | t.Run("expiry", func(t *testing.T) { | |
| 353 | + | // The deadline of the job plus five minutes: a job of one minute. | |
| 354 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 355 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 356 | + | c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "") | |
| 357 | + | job := runner.await() | |
| 358 | + | p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace) | |
| 361 | + | assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second) | |
| 362 | + | }) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // The retry of a poll gets the token of the first call, and a job token of the | |
| 366 | + | // protocol never doubles as the repository token. | |
| 367 | + | func TestJobTokenSurvivesAPollRetry(t *testing.T) { | |
| 368 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 369 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 370 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 371 | + | var first *pb.Job | |
| 372 | + | c.waitFor("a job", func() bool { | |
| 373 | + | j, err := runner.fetch("poll-1") | |
| 374 | + | require.NoError(t, err) | |
| 375 | + | first = j | |
| 376 | + | return j != nil | |
| 377 | + | }) | |
| 378 | + | again, err := runner.fetch("poll-1") | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | require.NotNil(t, again) | |
| 381 | + | assert.Equal(t, first.GetJobToken(), again.GetJobToken()) | |
| 382 | + | assert.Equal(t, first.GetGithubToken(), again.GetGithubToken()) | |
| 383 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken())) | |
| 384 | + | assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing") | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // The REST subset: GitHub's shapes for what checkout and github-script call, | |
| 388 | + | // GitHub's 404 for everything else. | |
| 389 | + | func TestGitHubRESTSubset(t *testing.T) { | |
| 390 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 391 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 392 | + | job := c.startJob(runner) | |
| 393 | + | tok := job.GetGithubToken() | |
| 394 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 395 | + | notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 396 | + | ||
| 397 | + | // Private: the token reads it, anonymous sees nothing. | |
| 398 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok)) | |
| 399 | + | require.Equal(t, http.StatusOK, status, body) | |
| 400 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json") | |
| 401 | + | var repo map[string]any | |
| 402 | + | require.NoError(t, json.Unmarshal([]byte(body), &repo)) | |
| 403 | + | assert.Equal(t, "proj", repo["name"]) | |
| 404 | + | assert.Equal(t, "owner/proj", repo["full_name"]) | |
| 405 | + | assert.Equal(t, true, repo["private"]) | |
| 406 | + | assert.Equal(t, "private", repo["visibility"]) | |
| 407 | + | assert.Equal(t, "main", repo["default_branch"]) | |
| 408 | + | assert.Equal(t, "http://example.test/owner/proj", repo["html_url"]) | |
| 409 | + | assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"]) | |
| 410 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"]) | |
| 411 | + | assert.NotZero(t, repo["id"]) | |
| 412 | + | assert.NotEmpty(t, repo["node_id"]) | |
| 413 | + | owner, _ := repo["owner"].(map[string]any) | |
| 414 | + | assert.Equal(t, "owner", owner["login"]) | |
| 415 | + | assert.Equal(t, "User", owner["type"]) | |
| 416 | + | assert.Equal(t, false, repo["fork"]) | |
| 417 | + | assert.Equal(t, false, repo["archived"]) | |
| 418 | + | assert.Equal(t, false, repo["has_issues"] == nil, "present") | |
| 419 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601") | |
| 420 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"]) | |
| 421 | + | perms, _ := repo["permissions"].(map[string]any) | |
| 422 | + | assert.Equal(t, true, perms["pull"]) | |
| 423 | + | assert.Equal(t, false, perms["push"], "the job token cannot write") | |
| 424 | + | assert.Equal(t, false, perms["admin"]) | |
| 425 | + | assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not") | |
| 426 | + | ||
| 427 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |
| 428 | + | assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself") | |
| 429 | + | assert.JSONEq(t, notFound, body) | |
| 430 | + | status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok)) | |
| 431 | + | assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub") | |
| 432 | + | status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok)) | |
| 433 | + | assert.Equal(t, http.StatusNotFound, status, "another owner") | |
| 434 | + | ||
| 435 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok)) | |
| 436 | + | assert.Equal(t, http.StatusOK, status) | |
| 437 | + | assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body) | |
| 438 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "") | |
| 439 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 440 | + | ||
| 441 | + | // The tarball: a gzip tar with one top-level directory, the contents of the | |
| 442 | + | // commit, for a branch, a tag and a full id alike. | |
| 443 | + | c.mustGit(c.wt, "tag", "v1") | |
| 444 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |
| 445 | + | for _, ref := range []string{"main", "v1", sha, ""} { | |
| 446 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok)) | |
| 447 | + | require.Equal(t, http.StatusOK, status, ref) | |
| 448 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |
| 449 | + | files := untar(t, body) | |
| 450 | + | assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref) | |
| 451 | + | } | |
| 452 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |
| 453 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 454 | + | assert.JSONEq(t, notFound, body) | |
| 455 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 456 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |
| 457 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 458 | + | assert.Equal(t, http.StatusOK, status, "any principal that reads the repository") | |
| 459 | + | ||
| 460 | + | // A public repository answers anonymous callers. | |
| 461 | + | vis := domain.VisibilityPublic | |
| 462 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |
| 463 | + | require.NoError(t, err) | |
| 464 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |
| 465 | + | require.Equal(t, http.StatusOK, status) | |
| 466 | + | assert.Contains(t, body, `"visibility":"public"`) | |
| 467 | + | assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none") | |
| 468 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 469 | + | assert.Equal(t, http.StatusOK, status) | |
| 470 | + | ||
| 471 | + | // Everything else is GitHub's 404, whatever the method. | |
| 472 | + | for _, p := range []string{ | |
| 473 | + | "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql", | |
| 474 | + | "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main", | |
| 475 | + | "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs", | |
| 476 | + | "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README", | |
| 477 | + | } { | |
| 478 | + | status, body, hdr := c.get(p, basic(tok)) | |
| 479 | + | assert.Equal(t, http.StatusNotFound, status, p) | |
| 480 | + | assert.JSONEq(t, notFound, body, p) | |
| 481 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json", p) | |
| 482 | + | } | |
| 483 | + | req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}")) | |
| 484 | + | require.NoError(t, err) | |
| 485 | + | resp, err := http.DefaultClient.Do(req) | |
| 486 | + | require.NoError(t, err) | |
| 487 | + | _ = resp.Body.Close() | |
| 488 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 489 | + | ||
| 490 | + | // A bad credential is refused as GitHub does. | |
| 491 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43))) | |
| 492 | + | assert.Equal(t, http.StatusUnauthorized, status) | |
| 493 | + | assert.Contains(t, body, "Bad credentials") | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func untar(t *testing.T, gz string) map[string]string { | |
| 497 | + | t.Helper() | |
| 498 | + | zr, err := gzip.NewReader(strings.NewReader(gz)) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | tr := tar.NewReader(zr) | |
| 501 | + | files := map[string]string{} | |
| 502 | + | for { | |
| 503 | + | h, err := tr.Next() | |
| 504 | + | if err == io.EOF { | |
| 505 | + | break | |
| 506 | + | } | |
| 507 | + | require.NoError(t, err) | |
| 508 | + | if h.Typeflag != tar.TypeReg { | |
| 509 | + | continue | |
| 510 | + | } | |
| 511 | + | var b bytes.Buffer | |
| 512 | + | _, err = io.Copy(&b, tr) | |
| 513 | + | require.NoError(t, err) | |
| 514 | + | files[h.Name] = b.String() | |
| 515 | + | } | |
| 516 | + | return files | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction { | |
| 520 | + | c.t.Helper() | |
| 521 | + | res, err := c.resolveErr(runner, job, uses...) | |
| 522 | + | require.NoError(c.t, err) | |
| 523 | + | return res | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) { | |
| 527 | + | refs := make([]*pb.ActionRef, 0, len(uses)) | |
| 528 | + | for _, u := range uses { | |
| 529 | + | refs = append(refs, &pb.ActionRef{Uses: u}) | |
| 530 | + | } | |
| 531 | + | res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs})) | |
| 532 | + | if err != nil { | |
| 533 | + | return nil, err | |
| 534 | + | } | |
| 535 | + | return res.Msg.GetActions(), nil | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | const ( | |
| 539 | + | shaMain = "1111111111111111111111111111111111111111" | |
| 540 | + | shaTag = "2222222222222222222222222222222222222222" | |
| 541 | + | shaTagObj = "3333333333333333333333333333333333333333" | |
| 542 | + | shaPeeled = "4444444444444444444444444444444444444444" | |
| 543 | + | shaOther = "5555555555555555555555555555555555555555" | |
| 544 | + | ) | |
| 545 | + | ||
| 546 | + | // AC2, AC4: github.com actions are resolved with ls-remote (tag before branch, | |
| 547 | + | // the peeled commit of an annotated tag, a full id as it is), ermite | |
| 548 | + | // repositories win, and nothing reaches the network but through the fake. | |
| 549 | + | func TestResolveActionsFromGitHub(t *testing.T) { | |
| 550 | + | remote := newFakeRemote(t) | |
| 551 | + | remote.set("actions/checkout", | |
| 552 | + | ports.RemoteRef{Name: "refs/heads/main", ID: shaMain}, | |
| 553 | + | ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled}, | |
| 554 | + | ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag}, | |
| 555 | + | ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag | |
| 556 | + | ) | |
| 557 | + | c := newExecHarness(t, remote, nil) | |
| 558 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 559 | + | job := c.startJob(runner) | |
| 560 | + | c.heartbeat(runner, job) | |
| 561 | + | ||
| 562 | + | got := c.resolve(runner, job, | |
| 563 | + | "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4", | |
| 564 | + | "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4", | |
| 565 | + | ) | |
| 566 | + | require.Len(t, got, 7) | |
| 567 | + | assert.Equal(t, "actions/checkout@v4", got[0].GetRef()) | |
| 568 | + | assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner()) | |
| 569 | + | assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit") | |
| 570 | + | assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl()) | |
| 571 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource()) | |
| 572 | + | assert.Empty(t, got[0].GetToken(), "no token goes to github.com") | |
| 573 | + | assert.Empty(t, got[0].GetError()) | |
| 574 | + | assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch") | |
| 575 | + | assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name") | |
| 576 | + | assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written") | |
| 577 | + | assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner()) | |
| 578 | + | assert.Equal(t, "sub/dir", got[3].GetPath()) | |
| 579 | + | assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive") | |
| 580 | + | assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked") | |
| 581 | + | assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know") | |
| 582 | + | assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one") | |
| 583 | + | assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call") | |
| 584 | + | ||
| 585 | + | // GitHub's words for what cannot be resolved; the call itself succeeds. | |
| 586 | + | bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d") | |
| 587 | + | assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError()) | |
| 588 | + | assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError()) | |
| 589 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError()) | |
| 590 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError()) | |
| 591 | + | assert.NotEmpty(t, bad[4].GetError()) | |
| 592 | + | assert.NotEmpty(t, bad[5].GetError()) | |
| 593 | + | for _, b := range bad { | |
| 594 | + | assert.Empty(t, b.GetResolvedSha(), b.GetRef()) | |
| 595 | + | assert.Empty(t, b.GetTarballUrl(), b.GetRef()) | |
| 596 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef()) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // github.com unreachable is an error of the call, not of one action. | |
| 600 | + | remote.err["flaky/action"] = domain.ErrUnavailable | |
| 601 | + | _, err := c.resolveErr(runner, job, "flaky/action@v1") | |
| 602 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err)) | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) { | |
| 606 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 607 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 608 | + | job := c.startJob(runner) | |
| 609 | + | c.heartbeat(runner, job) | |
| 610 | + | for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} { | |
| 611 | + | _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u) | |
| 612 | + | if u == "actions/checkout@v4" { | |
| 613 | + | assert.NoError(t, err) | |
| 614 | + | continue | |
| 615 | + | } | |
| 616 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u) | |
| 617 | + | } | |
| 618 | + | many := make([]string, 101) | |
| 619 | + | for i := range many { | |
| 620 | + | many[i] = "a/b@" + shaMain | |
| 621 | + | } | |
| 622 | + | _, err := c.resolveErr(runner, job, many...) | |
| 623 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100") | |
| 624 | + | many = many[:100] | |
| 625 | + | got, err := c.resolveErr(runner, job, many...) | |
| 626 | + | require.NoError(t, err) | |
| 627 | + | assert.Len(t, got, 100) | |
| 628 | + | got, err = c.resolveErr(runner, job) | |
| 629 | + | require.NoError(t, err) | |
| 630 | + | assert.Empty(t, got) | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // AC2: another job's token, a repository token, an ended job and a bad token | |
| 634 | + | // are refused. | |
| 635 | + | func TestResolveActionsAuthentication(t *testing.T) { | |
| 636 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 637 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 638 | + | job := c.startJob(runner) | |
| 639 | + | c.heartbeat(runner, job) | |
| 640 | + | call := func(secret, id string) error { | |
| 641 | + | _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}})) | |
| 642 | + | return err | |
| 643 | + | } | |
| 644 | + | assert.NoError(t, call(job.GetJobToken(), job.GetJobId())) | |
| 645 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token") | |
| 646 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token") | |
| 647 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId()))) | |
| 648 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId()))) | |
| 649 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job") | |
| 650 | + | ||
| 651 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 652 | + | err := call(job.GetJobToken(), job.GetJobId()) | |
| 653 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job") | |
| 654 | + | var ce *connect.Error | |
| 655 | + | require.ErrorAs(t, err, &ce) | |
| 656 | + | assert.NotEmpty(t, ce.Details(), "with a JobEnded detail") | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // AC4: an action of a repository of ermite wins over github.com, is downloaded | |
| 660 | + | // with a per-job token bound to that commit of that repository, and the same ref gives the | |
| 661 | + | // same commit to every job of the attempt (and a new one to a re-run). | |
| 662 | + | func TestResolveActionsFromErmite(t *testing.T) { | |
| 663 | + | remote := newFakeRemote(t) | |
| 664 | + | remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite | |
| 665 | + | c := newExecHarness(t, remote, nil) | |
| 666 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |
| 667 | + | tag := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 668 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |
| 669 | + | c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2") | |
| 670 | + | c.mustGit(c.repoDir("myaction"), "branch", "dev") | |
| 671 | + | c.seed("hidden", domain.VisibilityPrivate, "secret.txt") | |
| 672 | + | ||
| 673 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 674 | + | c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "") | |
| 675 | + | a, b := runner.await(), runner.await() | |
| 676 | + | c.heartbeat(runner, a) | |
| 677 | + | c.heartbeat(runner, b) | |
| 678 | + | ||
| 679 | + | got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1") | |
| 680 | + | require.Len(t, got, 6) | |
| 681 | + | for i := range 4 { | |
| 682 | + | assert.Empty(t, got[i].GetError(), got[i].GetRef()) | |
| 683 | + | assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef()) | |
| 684 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef()) | |
| 685 | + | assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef()) | |
| 686 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl()) | |
| 687 | + | } | |
| 688 | + | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | |
| 689 | + | assert.Equal(t, "sub", got[1].GetPath()) | |
| 690 | + | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job") | |
| 691 | + | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | |
| 692 | + | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | |
| 693 | + | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | |
| 694 | + | assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com") | |
| 695 | + | ||
| 696 | + | // The archive token downloads that repository, whatever the private | |
| 697 | + | // repository of the run's token reaches, and nothing else. | |
| 698 | + | arch := got[0].GetToken() | |
| 699 | + | status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |
| 700 | + | require.Equal(t, http.StatusOK, status) | |
| 701 | + | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | |
| 702 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | |
| 703 | + | assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets") | |
| 704 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | |
| 705 | + | assert.Equal(t, http.StatusNotFound, status, "and never writes") | |
| 706 | + | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | |
| 707 | + | assert.Equal(t, http.StatusNotFound, status, "another private repository") | |
| 708 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | |
| 709 | + | assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run") | |
| 710 | + | status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch)) | |
| 711 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 712 | + | // The job's own token does not read the action repository: this is why the | |
| 713 | + | // archive has a token of its own. | |
| 714 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken())) | |
| 715 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 716 | + | ||
| 717 | + | // Per job: the same commit, another token. | |
| 718 | + | other := c.resolve(runner, b, "owner/myaction@v1") | |
| 719 | + | assert.Equal(t, tag, other[0].GetResolvedSha()) | |
| 720 | + | assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs") | |
| 721 | + | ||
| 722 | + | // Stable for the attempt: the branch moves, the answer does not. | |
| 723 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "scratch") | |
| 724 | + | c.writeFile(c.wt, "later.txt", []byte("later")) | |
| 725 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f") | |
| 726 | + | moved := c.bareGit("myaction", "rev-parse", "dev") | |
| 727 | + | require.NotEqual(t, tag, moved) | |
| 728 | + | again := c.resolve(runner, b, "owner/myaction@dev") | |
| 729 | + | assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands") | |
| 730 | + | ||
| 731 | + | // The token goes with its job. | |
| 732 | + | runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 733 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |
| 734 | + | assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job") | |
| 735 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken())) | |
| 736 | + | assert.Equal(t, http.StatusOK, status, "the other job's token lives on") | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} } | |
| 740 | + | ||
| 741 | + | // A re-run resolves again, and github.com is asked once for a repository in | |
| 742 | + | // each attempt. | |
| 743 | + | func TestResolveActionsAreCachedPerAttempt(t *testing.T) { | |
| 744 | + | remote := newFakeRemote(t) | |
| 745 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain)) | |
| 746 | + | c := newExecHarness(t, remote, nil) | |
| 747 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 748 | + | job := c.startJob(runner) | |
| 749 | + | c.heartbeat(runner, job) | |
| 750 | + | ||
| 751 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha()) | |
| 752 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther)) | |
| 753 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt") | |
| 754 | + | assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution") | |
| 755 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 756 | + | ||
| 757 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 758 | + | require.NoError(t, err) | |
| 759 | + | job2 := runner.await() | |
| 760 | + | assert.Equal(t, int32(2), job2.GetAttempt()) | |
| 761 | + | c.heartbeat(runner, job2) | |
| 762 | + | assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again") | |
| 763 | + | assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken()) | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // AC2: no secret reaches the logs of the server: the repository token, the | |
| 767 | + | // archive tokens and the job token, whatever happened with them. | |
| 768 | + | func TestRepositoryTokensAreNeverLogged(t *testing.T) { | |
| 769 | + | remote := newFakeRemote(t) | |
| 770 | + | c := newExecHarness(t, remote, nil) | |
| 771 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 772 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 773 | + | job := c.startJob(runner) | |
| 774 | + | c.heartbeat(runner, job) | |
| 775 | + | tok := job.GetGithubToken() | |
| 776 | + | ||
| 777 | + | arch := c.resolve(runner, job, "owner/myaction@main")[0] | |
| 778 | + | require.NotEmpty(t, arch.GetToken()) | |
| 779 | + | _, err := c.clone(tok, c.url("/owner/proj")) | |
| 780 | + | require.NoError(t, err) | |
| 781 | + | c.get("/-/github/api/repos/owner/proj", basic(tok)) | |
| 782 | + | c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok) | |
| 783 | + | c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |
| 784 | + | c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one | |
| 785 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 786 | + | c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |
| 787 | + | ||
| 788 | + | logs := c.logs.String() | |
| 789 | + | require.NotEmpty(t, logs) | |
| 790 | + | for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} { | |
| 791 | + | assert.NotContains(t, logs, secret) | |
| 792 | + | } | |
| 793 | + | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | // pushAs pushes a commit to branch of the repository with the token and returns | |
| 797 | + | // its sha. | |
| 798 | + | func (c *ciHarness) pushAs(token, branch string, files map[string]string) string { | |
| 799 | + | c.t.Helper() | |
| 800 | + | if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil { | |
| 801 | + | c.mustGit(c.wt, "checkout", "-q", "-b", branch) | |
| 802 | + | } | |
| 803 | + | for name, content := range files { | |
| 804 | + | c.writeFile(c.wt, name, []byte(content)) | |
| 805 | + | } | |
| 806 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch) | |
| 807 | + | return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | const oneJobWorkflow = ".github/workflows/ci.yml" | |
| 811 | + | ||
| 812 | + | func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] } | |
| 813 | + | ||
| 814 | + | // GO-001a: an action of another repository of ermite is resolved on behalf of | |
| 815 | + | // the pusher of the run, with the rule of the forge: a pusher that cannot read | |
| 816 | + | // the repository gets "repository not found", exactly as for one that does not | |
| 817 | + | // exist, and the check is made again at each answer with the live token. | |
| 818 | + | func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) { | |
| 819 | + | notFound := "Unable to resolve action `owner/private-action@v1`, repository not found" | |
| 820 | + | missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found" | |
| 821 | + | ||
| 822 | + | type scenario struct { | |
| 823 | + | name string | |
| 824 | + | // token makes the stored token that pushes the run, nil for the root token. | |
| 825 | + | token func(c *ciHarness) (secret, id string) | |
| 826 | + | reads bool | |
| 827 | + | } | |
| 828 | + | scenarios := []scenario{ | |
| 829 | + | {"root", nil, true}, | |
| 830 | + | {"a token without the repository in its list", func(c *ciHarness) (string, string) { | |
| 831 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 832 | + | }, false}, | |
| 833 | + | {"a token without repo:read", func(c *ciHarness) (string, string) { | |
| 834 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil) | |
| 835 | + | }, false}, | |
| 836 | + | {"a token that lists both repositories", func(c *ciHarness) (string, string) { | |
| 837 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action") | |
| 838 | + | }, true}, | |
| 839 | + | {"a token for every repository", func(c *ciHarness) (string, string) { | |
| 840 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 841 | + | }, true}, | |
| 842 | + | } | |
| 843 | + | for _, sc := range scenarios { | |
| 844 | + | t.Run(sc.name, func(t *testing.T) { | |
| 845 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 846 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 847 | + | secret := adminToken | |
| 848 | + | if sc.token != nil { | |
| 849 | + | secret, _ = sc.token(c) | |
| 850 | + | } | |
| 851 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 852 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 853 | + | job := runner.await() | |
| 854 | + | c.heartbeat(runner, job) | |
| 855 | + | ||
| 856 | + | got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1") | |
| 857 | + | assert.Equal(t, missing, got[1].GetError()) | |
| 858 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 859 | + | got = c.resolve(runner, job, "owner/private-action@v1") | |
| 860 | + | if sc.reads { | |
| 861 | + | require.Empty(t, got[0].GetError()) | |
| 862 | + | assert.True(t, domain.IsRepoToken(got[0].GetToken())) | |
| 863 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 864 | + | } else { | |
| 865 | + | assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there") | |
| 866 | + | assert.Empty(t, got[0].GetToken()) | |
| 867 | + | assert.Empty(t, got[0].GetResolvedSha()) | |
| 868 | + | assert.Empty(t, got[0].GetTarballUrl()) | |
| 869 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource()) | |
| 870 | + | } | |
| 871 | + | // A wrong ref of a repository that cannot be read leaks nothing either: | |
| 872 | + | // the same text as for a missing repository, not "unable to find version". | |
| 873 | + | bad := c.resolve(runner, job, "owner/private-action@nope") | |
| 874 | + | if sc.reads { | |
| 875 | + | assert.Contains(t, bad[0].GetError(), "unable to find version `nope`") | |
| 876 | + | } else { | |
| 877 | + | assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError()) | |
| 878 | + | } | |
| 879 | + | }) | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) { | |
| 883 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 884 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 885 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 886 | + | secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 887 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 888 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 889 | + | job := runner.await() | |
| 890 | + | c.heartbeat(runner, job) | |
| 891 | + | ||
| 892 | + | first := c.resolve(runner, job, "owner/private-action@v1") | |
| 893 | + | require.Empty(t, first[0].GetError()) | |
| 894 | + | ||
| 895 | + | require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id)) | |
| 896 | + | again := c.resolve(runner, job, "owner/private-action@v1") | |
| 897 | + | assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads") | |
| 898 | + | assert.Empty(t, again[0].GetToken()) | |
| 899 | + | }) | |
| 900 | + | ||
| 901 | + | t.Run("a repository made private since", func(t *testing.T) { | |
| 902 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 903 | + | c.seed("private-action", domain.VisibilityPublic, "action.yml") | |
| 904 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 905 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 906 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 907 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 908 | + | job := runner.await() | |
| 909 | + | c.heartbeat(runner, job) | |
| 910 | + | ||
| 911 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it") | |
| 912 | + | priv := domain.VisibilityPrivate | |
| 913 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv}) | |
| 914 | + | require.NoError(t, err) | |
| 915 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 916 | + | }) | |
| 917 | + | ||
| 918 | + | t.Run("an expired token", func(t *testing.T) { | |
| 919 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 920 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 921 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 922 | + | exp := time.Now().Add(1500 * time.Millisecond) | |
| 923 | + | got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{ | |
| 924 | + | Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp, | |
| 925 | + | }) | |
| 926 | + | require.NoError(t, err) | |
| 927 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 928 | + | c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 929 | + | job := runner.await() | |
| 930 | + | c.heartbeat(runner, job) | |
| 931 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 932 | + | time.Sleep(time.Until(exp) + 200*time.Millisecond) | |
| 933 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 934 | + | }) | |
| 935 | + | ||
| 936 | + | t.Run("the repository of the run is always readable by its job", func(t *testing.T) { | |
| 937 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 938 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 939 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 940 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 941 | + | job := runner.await() | |
| 942 | + | c.heartbeat(runner, job) | |
| 943 | + | got := c.resolve(runner, job, "owner/proj@main") | |
| 944 | + | require.Empty(t, got[0].GetError()) | |
| 945 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 946 | + | }) | |
| 947 | + | } | |
| 948 | + | ||
| 949 | + | // GO-001b: the archive token is bound to (repository, commit). Only the archive | |
| 950 | + | // endpoint reads it, only for that commit; it is refused by Git over HTTP, the | |
| 951 | + | // repository and hash-algorithm endpoints, and for any other commit, ref or | |
| 952 | + | // repository, public or not. | |
| 953 | + | func TestArchiveTokenIsBoundToItsCommit(t *testing.T) { | |
| 954 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 955 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |
| 956 | + | first := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 957 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |
| 958 | + | // A second commit, to have a sha the token was not issued for. | |
| 959 | + | other := c.workTree("main", "action.yml", "dist/index.js", "more.js") | |
| 960 | + | c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main") | |
| 961 | + | second := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 962 | + | require.NotEqual(t, first, second) | |
| 963 | + | c.seed("openrepo", domain.VisibilityPublic, "a.txt") | |
| 964 | + | openSha := c.bareGit("openrepo", "rev-parse", "HEAD") | |
| 965 | + | ||
| 966 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 967 | + | job := c.startJob(runner) | |
| 968 | + | c.heartbeat(runner, job) | |
| 969 | + | got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second) | |
| 970 | + | require.Empty(t, got[0].GetError()) | |
| 971 | + | require.Empty(t, got[1].GetError()) | |
| 972 | + | assert.Equal(t, first, got[0].GetResolvedSha()) | |
| 973 | + | assert.Equal(t, second, got[1].GetResolvedSha()) | |
| 974 | + | assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit") | |
| 975 | + | arch := got[0].GetToken() | |
| 976 | + | tarball := func(repo, ref, tok string) int { | |
| 977 | + | status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok)) | |
| 978 | + | return status | |
| 979 | + | } | |
| 980 | + | ||
| 981 | + | // What it is for: that commit, named by its sha or by a ref that resolves to it. | |
| 982 | + | assert.Equal(t, http.StatusOK, tarball("myaction", first, arch)) | |
| 983 | + | assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit") | |
| 984 | + | assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive") | |
| 985 | + | status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch)) | |
| 986 | + | assert.Equal(t, http.StatusOK, status) | |
| 987 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7]) | |
| 988 | + | ||
| 989 | + | // Nothing else. | |
| 990 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository") | |
| 991 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere") | |
| 992 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch") | |
| 993 | + | assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run") | |
| 994 | + | assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository") | |
| 995 | + | assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one") | |
| 996 | + | status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch)) | |
| 997 | + | assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET") | |
| 998 | + | for _, p := range []string{ | |
| 999 | + | "/owner/myaction/info/refs?service=git-upload-pack", | |
| 1000 | + | "/owner/myaction.git/info/refs?service=git-upload-pack", | |
| 1001 | + | "/myaction/info/refs?service=git-upload-pack", | |
| 1002 | + | "/owner/myaction/info/refs?service=git-receive-pack", | |
| 1003 | + | "/owner/openrepo/info/refs?service=git-upload-pack", | |
| 1004 | + | "/-/github/api/repos/owner/myaction", | |
| 1005 | + | "/-/github/api/repos/owner/myaction/hash-algorithm", | |
| 1006 | + | "/-/github/api/repos/owner/openrepo", | |
| 1007 | + | "/-/github/api/repos/owner/openrepo/hash-algorithm", | |
| 1008 | + | } { | |
| 1009 | + | status, _, _ := c.get(p, basic(arch)) | |
| 1010 | + | assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p) | |
| 1011 | + | } | |
| 1012 | + | // It lists no repository over the API either, and holds no scope there. | |
| 1013 | + | list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |
| 1014 | + | require.NoError(t, err) | |
| 1015 | + | names := []string{} | |
| 1016 | + | for _, r := range list.Msg.GetRepos() { | |
| 1017 | + | names = append(names, r.GetName()) | |
| 1018 | + | } | |
| 1019 | + | assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads") | |
| 1020 | + | _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"})) | |
| 1021 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 1022 | + | ||
| 1023 | + | // The job's own token is the opposite: it reads its repository whole, and | |
| 1024 | + | // the archive of nothing else. | |
| 1025 | + | own := job.GetGithubToken() | |
| 1026 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own)) | |
| 1027 | + | assert.Equal(t, http.StatusOK, tarball("proj", "main", own)) | |
| 1028 | + | ||
| 1029 | + | // Principals. | |
| 1030 | + | p, err := c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1031 | + | require.NoError(t, err) | |
| 1032 | + | repo, sha, ok := p.ArchiveGrant() | |
| 1033 | + | require.True(t, ok) | |
| 1034 | + | assert.Equal(t, "myaction", repo.String()) | |
| 1035 | + | assert.Equal(t, first, sha) | |
| 1036 | + | assert.Empty(t, p.Scopes()) | |
| 1037 | + | assert.False(t, p.HasScope(domain.ScopeRepoRead)) | |
| 1038 | + | assert.False(t, p.Allows(domain.ScopeRepoRead, repo)) | |
| 1039 | + | assert.False(t, p.IsRoot()) | |
| 1040 | + | ownP, err := c.forge.Auth.Authenticate(c.ctx, own) | |
| 1041 | + | require.NoError(t, err) | |
| 1042 | + | _, _, ok = ownP.ArchiveGrant() | |
| 1043 | + | assert.False(t, ok) | |
| 1044 | + | } | |
| 1045 | + | ||
| 1046 | + | func (c *ciHarness) head(path, authorization string) (int, string, http.Header) { | |
| 1047 | + | c.t.Helper() | |
| 1048 | + | req, err := http.NewRequest(http.MethodHead, c.url(path), nil) | |
| 1049 | + | require.NoError(c.t, err) | |
| 1050 | + | if authorization != "" { | |
| 1051 | + | req.Header.Set("Authorization", authorization) | |
| 1052 | + | } | |
| 1053 | + | resp, err := http.DefaultClient.Do(req) | |
| 1054 | + | require.NoError(c.t, err) | |
| 1055 | + | defer func() { _ = resp.Body.Close() }() | |
| 1056 | + | b, _ := io.ReadAll(resp.Body) | |
| 1057 | + | return resp.StatusCode, string(b), resp.Header | |
| 1058 | + | } | |
| 1059 | + | ||
| 1060 | + | // GO-002: a HEAD of the tarball answers the headers of the archive of the ref it | |
| 1061 | + | // resolves, runs no git archive, and 404s a ref that does not resolve. | |
| 1062 | + | func TestTarballHeadRunsNoArchive(t *testing.T) { | |
| 1063 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 1064 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1065 | + | job := c.startJob(runner) | |
| 1066 | + | tok := job.GetGithubToken() | |
| 1067 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 1068 | + | ||
| 1069 | + | status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok)) | |
| 1070 | + | assert.Equal(t, http.StatusOK, status) | |
| 1071 | + | assert.Empty(t, body, "no body") | |
| 1072 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |
| 1073 | + | assert.Contains(t, hdr.Get("Content-Type"), "gzip") | |
| 1074 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |
| 1075 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 1076 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 1077 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |
| 1078 | + | status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok)) | |
| 1079 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | // GO-003: one client cannot take every archive slot, and the slots are given | |
| 1083 | + | // back. | |
| 1084 | + | func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) { | |
| 1085 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 }) | |
| 1086 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 1087 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 1088 | + | require.NoError(t, err) | |
| 1089 | + | c.wt = h.workTree("main") | |
| 1090 | + | c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible | |
| 1091 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main") | |
| 1092 | + | ||
| 1093 | + | // Start a download and read none of it: the response is held open. | |
| 1094 | + | req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil) | |
| 1095 | + | require.NoError(t, err) | |
| 1096 | + | req.Header.Set("Authorization", "Bearer "+adminToken) | |
| 1097 | + | resp, err := http.DefaultClient.Do(req) | |
| 1098 | + | require.NoError(t, err) | |
| 1099 | + | ||
| 1100 | + | var status int | |
| 1101 | + | c.waitFor("the second download is refused", func() bool { | |
| 1102 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1103 | + | return status == http.StatusTooManyRequests | |
| 1104 | + | }) | |
| 1105 | + | assert.Equal(t, http.StatusTooManyRequests, status) | |
| 1106 | + | hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1107 | + | assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot") | |
| 1108 | + | metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken) | |
| 1109 | + | assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint") | |
| 1110 | + | ||
| 1111 | + | // Reading it to the end gives the slot back. | |
| 1112 | + | _, err = io.Copy(io.Discard, resp.Body) | |
| 1113 | + | require.NoError(t, err) | |
| 1114 | + | _ = resp.Body.Close() | |
| 1115 | + | c.waitFor("the slot is given back", func() bool { | |
| 1116 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1117 | + | return status == http.StatusOK | |
| 1118 | + | }) | |
| 1119 | + | } | |
| 1120 | + | ||
| 1121 | + | // GO-004: owner and repository are validated with GitHub's charset for every | |
| 1122 | + | // source; nothing but a name reaches a URL, a path or a command. | |
| 1123 | + | func TestResolveActionsRejectsMalformedNames(t *testing.T) { | |
| 1124 | + | remote := newFakeRemote(t) | |
| 1125 | + | c := newExecHarness(t, remote, nil) | |
| 1126 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1127 | + | job := c.startJob(runner) | |
| 1128 | + | c.heartbeat(runner, job) | |
| 1129 | + | ||
| 1130 | + | bad := []string{ | |
| 1131 | + | "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain, | |
| 1132 | + | "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain, | |
| 1133 | + | "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1", | |
| 1134 | + | "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@", | |
| 1135 | + | } | |
| 1136 | + | got := c.resolve(runner, job, bad...) | |
| 1137 | + | require.Len(t, got, len(bad)) | |
| 1138 | + | for i, g := range got { | |
| 1139 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i]) | |
| 1140 | + | assert.Empty(t, g.GetTarballUrl(), "%q", bad[i]) | |
| 1141 | + | assert.Empty(t, g.GetToken(), "%q", bad[i]) | |
| 1142 | + | } | |
| 1143 | + | assert.Zero(t, remote.count(), "no malformed name reached ls-remote") | |
| 1144 | + | ||
| 1145 | + | // Names GitHub accepts still do. | |
| 1146 | + | remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain)) | |
| 1147 | + | ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain) | |
| 1148 | + | assert.Empty(t, ok[0].GetError()) | |
| 1149 | + | assert.Empty(t, ok[1].GetError()) | |
| 1150 | + | } | |
| 1151 | + | ||
| 1152 | + | // A token revoked, a job lost, the server restarted: the archive token goes with | |
| 1153 | + | // the job, and it was never written anywhere but hashed. | |
| 1154 | + | func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) { | |
| 1155 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 1156 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 1157 | + | }) | |
| 1158 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1159 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 1160 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1161 | + | job := c.startJob(runner) | |
| 1162 | + | c.heartbeat(runner, job) | |
| 1163 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |
| 1164 | + | require.NotEmpty(t, arch) | |
| 1165 | + | url := "/-/github/api/repos/owner/myaction/tarball/" + sha | |
| 1166 | + | status, _, _ := c.get(url, basic(arch)) | |
| 1167 | + | require.Equal(t, http.StatusOK, status) | |
| 1168 | + | ||
| 1169 | + | // Restart: the tokens are in the store, the secrets in memory are gone. | |
| 1170 | + | cfg, hookExe := c.forge.cfg, binary(t) | |
| 1171 | + | c.srv.Close() | |
| 1172 | + | c.forge.Close(c.ctx) | |
| 1173 | + | f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) { | |
| 1174 | + | cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond | |
| 1175 | + | }}) | |
| 1176 | + | require.NoError(t, err) | |
| 1177 | + | t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) }) | |
| 1178 | + | srv := httptest.NewServer(f2.server.Handler()) | |
| 1179 | + | t.Cleanup(srv.Close) | |
| 1180 | + | ||
| 1181 | + | get := func(tok string) int { | |
| 1182 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil) | |
| 1183 | + | req.Header.Set("Authorization", basic(tok)) | |
| 1184 | + | resp, err := http.DefaultClient.Do(req) | |
| 1185 | + | require.NoError(t, err) | |
| 1186 | + | _ = resp.Body.Close() | |
| 1187 | + | return resp.StatusCode | |
| 1188 | + | } | |
| 1189 | + | assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart") | |
| 1190 | + | ||
| 1191 | + | // The runner is gone: the heartbeat deadline of the new server passes, the | |
| 1192 | + | // job is lost and its tokens with it. | |
| 1193 | + | require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond) | |
| 1194 | + | assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well") | |
| 1195 | + | api := newClients(srv.URL+"/-/api", adminToken) | |
| 1196 | + | res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()})) | |
| 1197 | + | require.NoError(t, err) | |
| 1198 | + | assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication") | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | // GO-006: the archive tokens that read a repository go when it is deleted, and | |
| 1202 | + | // the run that held one goes on without a stale index. | |
| 1203 | + | func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |
| 1204 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 1205 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1206 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 1207 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1208 | + | job := c.startJob(runner) | |
| 1209 | + | c.heartbeat(runner, job) | |
| 1210 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |
| 1211 | + | _, err := c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1212 | + | require.NoError(t, err) | |
| 1213 | + | ||
| 1214 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction"))) | |
| 1215 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1216 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads") | |
| 1217 | + | _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |
| 1218 | + | assert.NoError(t, err, "the job's own token is the run's repository's, not touched") | |
| 1219 | + | ||
| 1220 | + | // A repository created again under the name does not inherit the token. | |
| 1221 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1222 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1223 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 1224 | + | status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch)) | |
| 1225 | + | assert.Equal(t, http.StatusUnauthorized, status) | |
| 1226 | + | ||
| 1227 | + | // The job ending afterwards revokes what is left without an error. | |
| 1228 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | // GO-005: ls-remote runs are bounded server-wide and a call has a deadline. | |
| 1232 | + | func TestResolveActionsBoundsRemoteWork(t *testing.T) { | |
| 1233 | + | t.Run("concurrency", func(t *testing.T) { | |
| 1234 | + | // The jobs of a run resolve at the same time: five calls, two slots. | |
| 1235 | + | var running, peak atomic.Int32 | |
| 1236 | + | release := make(chan struct{}) | |
| 1237 | + | remote := newFakeRemote(t) | |
| 1238 | + | remote.m.ExpectedCalls = nil | |
| 1239 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1240 | + | func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 1241 | + | n := running.Add(1) | |
| 1242 | + | defer running.Add(-1) | |
| 1243 | + | for { | |
| 1244 | + | p := peak.Load() | |
| 1245 | + | if n <= p || peak.CompareAndSwap(p, n) { | |
| 1246 | + | break | |
| 1247 | + | } | |
| 1248 | + | } | |
| 1249 | + | select { | |
| 1250 | + | case <-release: | |
| 1251 | + | case <-ctx.Done(): | |
| 1252 | + | return nil, ctx.Err() | |
| 1253 | + | } | |
| 1254 | + | return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil | |
| 1255 | + | }).Maybe() | |
| 1256 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 }) | |
| 1257 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1258 | + | job := c.startJob(runner) | |
| 1259 | + | c.heartbeat(runner, job) | |
| 1260 | + | ||
| 1261 | + | var wg sync.WaitGroup | |
| 1262 | + | results := make([][]*pb.ResolvedAction, 5) | |
| 1263 | + | errs := make([]error, 5) | |
| 1264 | + | for i, repo := range []string{"one", "two", "three", "four", "five"} { | |
| 1265 | + | wg.Add(1) | |
| 1266 | + | go func() { | |
| 1267 | + | defer wg.Done() | |
| 1268 | + | results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1") | |
| 1269 | + | }() | |
| 1270 | + | } | |
| 1271 | + | require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond) | |
| 1272 | + | time.Sleep(150 * time.Millisecond) | |
| 1273 | + | assert.EqualValues(t, 2, running.Load(), "the others wait for a slot") | |
| 1274 | + | close(release) | |
| 1275 | + | wg.Wait() | |
| 1276 | + | assert.LessOrEqual(t, peak.Load(), int32(2)) | |
| 1277 | + | for i := range results { | |
| 1278 | + | require.NoError(t, errs[i]) | |
| 1279 | + | assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef()) | |
| 1280 | + | } | |
| 1281 | + | }) | |
| 1282 | + | ||
| 1283 | + | t.Run("deadline", func(t *testing.T) { | |
| 1284 | + | remote := newFakeRemote(t) | |
| 1285 | + | remote.m.ExpectedCalls = nil | |
| 1286 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1287 | + | func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) { | |
| 1288 | + | <-ctx.Done() | |
| 1289 | + | return nil, ctx.Err() | |
| 1290 | + | }).Maybe() | |
| 1291 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond }) | |
| 1292 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1293 | + | job := c.startJob(runner) | |
| 1294 | + | c.heartbeat(runner, job) | |
| 1295 | + | start := time.Now() | |
| 1296 | + | _, err := c.resolveErr(runner, job, "slow/action@v1") | |
| 1297 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded") | |
| 1298 | + | assert.Less(t, time.Since(start), 5*time.Second) | |
| 1299 | + | // The job goes on: the next call is answered. | |
| 1300 | + | ok, err := c.resolveErr(runner, job, "a/b@"+shaMain) | |
| 1301 | + | require.NoError(t, err) | |
| 1302 | + | assert.Empty(t, ok[0].GetError()) | |
| 1303 | + | }) | |
| 1304 | + | } | |
| 1305 | + | ||
| 1306 | + | // mergeWorkflow runs on main only, so that the branch that carries it runs | |
| 1307 | + | // nothing until it is merged. | |
| 1308 | + | const mergeWorkflow = `on: | |
| 1309 | + | push: | |
| 1310 | + | branches: [main] | |
| 1311 | + | jobs: | |
| 1312 | + | j: | |
| 1313 | + | runs-on: ubuntu-latest | |
| 1314 | + | steps: | |
| 1315 | + | - uses: owner/private-action@v1 | |
| 1316 | + | ` | |
| 1317 | + | ||
| 1318 | + | // GO-R1: a merge runs the workflow with the rights of the principal that | |
| 1319 | + | // merged, not the owner's: a token limited to a repository that merges a | |
| 1320 | + | // workflow using a private action of another repository is told "repository not | |
| 1321 | + | // found", while the owner merging the same workflow gets the action. The actor | |
| 1322 | + | // shown on the run stays the owner, as GitHub shows it. | |
| 1323 | + | func TestMergeResolvesActionsWithTheRightsOfTheMerger(t *testing.T) { | |
| 1324 | + | merge := func(c *ciHarness, as func(mr int64, head string) error) (*pb.Job, *pb.Run) { | |
| 1325 | + | c.t.Helper() | |
| 1326 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1327 | + | c.push("main", map[string]string{"README": "x\n"}, "") | |
| 1328 | + | feat := c.push("feat", map[string]string{".github/workflows/m.yml": mergeWorkflow}, "") | |
| 1329 | + | mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "CI", SourceBranch: "feat"})) | |
| 1330 | + | require.NoError(c.t, err) | |
| 1331 | + | require.Empty(c.t, c.runs(), "nothing runs before the merge") | |
| 1332 | + | require.NoError(c.t, as(mr.Msg.GetMergeRequest().GetNumber(), feat)) | |
| 1333 | + | job := runner.await() | |
| 1334 | + | c.heartbeat(runner, job) | |
| 1335 | + | return job, c.runs()[0] | |
| 1336 | + | } | |
| 1337 | + | setup := func(t *testing.T) *ciHarness { | |
| 1338 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 1339 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 1340 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 1341 | + | return c | |
| 1342 | + | } | |
| 1343 | + | asToken := func(c *ciHarness, secret string) func(int64, string) error { | |
| 1344 | + | return func(n int64, head string) error { | |
| 1345 | + | _, err := c.api(secret).mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head})) | |
| 1346 | + | return err | |
| 1347 | + | } | |
| 1348 | + | } | |
| 1349 | + | ||
| 1350 | + | t.Run("a token limited to the repository", func(t *testing.T) { | |
| 1351 | + | c := setup(t) | |
| 1352 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj") | |
| 1353 | + | job, run := merge(c, asToken(c, secret)) | |
| 1354 | + | assert.Equal(t, "owner", run.GetActor(), "GitHub shows the owner as the actor of a merge") | |
| 1355 | + | got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1") | |
| 1356 | + | assert.Equal(t, "Unable to resolve action `owner/private-action@v1`, repository not found", got[0].GetError()) | |
| 1357 | + | assert.Empty(t, got[0].GetToken()) | |
| 1358 | + | }) | |
| 1359 | + | ||
| 1360 | + | t.Run("a token that lists both repositories", func(t *testing.T) { | |
| 1361 | + | c := setup(t) | |
| 1362 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj", "private-action") | |
| 1363 | + | job, _ := merge(c, asToken(c, secret)) | |
| 1364 | + | got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1") | |
| 1365 | + | require.Empty(t, got[0].GetError()) | |
| 1366 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 1367 | + | }) | |
| 1368 | + | ||
| 1369 | + | t.Run("the owner", func(t *testing.T) { | |
| 1370 | + | c := setup(t) | |
| 1371 | + | job, run := merge(c, func(n int64, head string) error { | |
| 1372 | + | id, err := domain.ParseObjectID(head) | |
| 1373 | + | require.NoError(t, err) | |
| 1374 | + | _, err = c.forge.MergeRequests.Merge(c.ctx, domain.Owner(), c.h("proj"), n, ports.MergeParams{HeadID: id}) | |
| 1375 | + | return err | |
| 1376 | + | }) | |
| 1377 | + | assert.Equal(t, "owner", run.GetActor()) | |
| 1378 | + | got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1") | |
| 1379 | + | require.Empty(t, got[0].GetError()) | |
| 1380 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 1381 | + | assert.True(t, domain.IsRepoToken(got[0].GetToken())) | |
| 1382 | + | }) | |
| 1383 | + | ||
| 1384 | + | t.Run("the admin token", func(t *testing.T) { | |
| 1385 | + | c := setup(t) | |
| 1386 | + | job, _ := merge(c, asToken(c, adminToken)) | |
| 1387 | + | got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1") | |
| 1388 | + | require.Empty(t, got[0].GetError()) | |
| 1389 | + | }) | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | // GO-R4: a repository of this server that the pusher cannot read never falls | |
| 1393 | + | // through to github.com under the same name, at the first resolution and when | |
| 1394 | + | // the answer comes from a stored one; only a repository that does not exist | |
| 1395 | + | // here is github.com's. | |
| 1396 | + | func TestAnActionRepositoryOfTheServerNeverFallsThroughToGitHub(t *testing.T) { | |
| 1397 | + | const ref = "owner/b@v1" | |
| 1398 | + | notFound := "Unable to resolve action `owner/b@v1`, repository not found" | |
| 1399 | + | limited := func(c *ciHarness) string { | |
| 1400 | + | s, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 1401 | + | return s | |
| 1402 | + | } | |
| 1403 | + | setup := func(t *testing.T) (*ciHarness, *fakeRemote, *fakeRunner) { | |
| 1404 | + | remote := newFakeRemote(t) | |
| 1405 | + | remote.set("owner/b", ptrRef("refs/tags/v1", shaOther)) // the repository of the same name on github.com | |
| 1406 | + | c := newExecHarness(t, remote, nil) | |
| 1407 | + | return c, remote, c.registerRunner("alpha", "ubuntu-latest") | |
| 1408 | + | } | |
| 1409 | + | // run pushes the workflow (first) or a new commit as the secret and returns the | |
| 1410 | + | // job the push made. | |
| 1411 | + | run := func(c *ciHarness, runner *fakeRunner, secret string, files map[string]string) *pb.Job { | |
| 1412 | + | c.t.Helper() | |
| 1413 | + | c.pushAs(secret, "main", files) | |
| 1414 | + | job := runner.await() | |
| 1415 | + | c.heartbeat(runner, job) | |
| 1416 | + | return job | |
| 1417 | + | } | |
| 1418 | + | resolve := func(c *ciHarness, job *pb.Job) *pb.ResolvedAction { | |
| 1419 | + | return c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, ref)[0] | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | t.Run("first resolution", func(t *testing.T) { | |
| 1423 | + | c, remote, runner := setup(t) | |
| 1424 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1425 | + | c.mustGit(c.repoDir("b"), "tag", "v1") | |
| 1426 | + | job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1427 | + | got := resolve(c, job) | |
| 1428 | + | assert.Equal(t, notFound, got.GetError(), "the same text as for a repository that is not there") | |
| 1429 | + | assert.Empty(t, got.GetResolvedSha()) | |
| 1430 | + | assert.Empty(t, got.GetTarballUrl()) | |
| 1431 | + | assert.Empty(t, got.GetToken()) | |
| 1432 | + | assert.False(t, remote.asked("owner/b"), "github.com is never asked about a name the server holds") | |
| 1433 | + | // And not the second time, from the same attempt. | |
| 1434 | + | assert.Equal(t, notFound, resolve(c, job).GetError()) | |
| 1435 | + | assert.False(t, remote.asked("owner/b")) | |
| 1436 | + | }) | |
| 1437 | + | ||
| 1438 | + | t.Run("a pusher who reads it, then one who does not, on the same ref", func(t *testing.T) { | |
| 1439 | + | c, remote, runner := setup(t) | |
| 1440 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1441 | + | c.mustGit(c.repoDir("b"), "tag", "v1") | |
| 1442 | + | bSha := c.bareGit("b", "rev-parse", "HEAD") | |
| 1443 | + | ||
| 1444 | + | first := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1445 | + | got := resolve(c, first) | |
| 1446 | + | require.Empty(t, got.GetError()) | |
| 1447 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource()) | |
| 1448 | + | assert.Equal(t, bSha, got.GetResolvedSha()) | |
| 1449 | + | ||
| 1450 | + | second := run(c, runner, limited(c), map[string]string{"two.txt": "2\n"}) | |
| 1451 | + | got = resolve(c, second) | |
| 1452 | + | assert.Equal(t, notFound, got.GetError(), "another run, another pusher: its own answer") | |
| 1453 | + | assert.Empty(t, got.GetToken()) | |
| 1454 | + | assert.False(t, remote.asked("owner/b")) | |
| 1455 | + | // The first run still resolves. | |
| 1456 | + | assert.Equal(t, bSha, resolve(c, first).GetResolvedSha()) | |
| 1457 | + | }) | |
| 1458 | + | ||
| 1459 | + | t.Run("a pusher who does not read it, then one who does, on the same ref", func(t *testing.T) { | |
| 1460 | + | c, remote, runner := setup(t) | |
| 1461 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1462 | + | c.mustGit(c.repoDir("b"), "tag", "v1") | |
| 1463 | + | bSha := c.bareGit("b", "rev-parse", "HEAD") | |
| 1464 | + | ||
| 1465 | + | first := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1466 | + | assert.Equal(t, notFound, resolve(c, first).GetError()) | |
| 1467 | + | ||
| 1468 | + | second := run(c, runner, adminToken, map[string]string{"two.txt": "2\n"}) | |
| 1469 | + | got := resolve(c, second) | |
| 1470 | + | require.Empty(t, got.GetError(), "the refusal of the first run is not kept for the second") | |
| 1471 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource()) | |
| 1472 | + | assert.Equal(t, bSha, got.GetResolvedSha()) | |
| 1473 | + | assert.NotEqual(t, strings.ToLower(shaOther), got.GetResolvedSha(), "not github.com's commit") | |
| 1474 | + | assert.False(t, remote.asked("owner/b")) | |
| 1475 | + | assert.Equal(t, notFound, resolve(c, first).GetError(), "and the first run is still refused") | |
| 1476 | + | }) | |
| 1477 | + | ||
| 1478 | + | t.Run("a stored resolution from github.com, then the repository appears here", func(t *testing.T) { | |
| 1479 | + | c, remote, runner := setup(t) | |
| 1480 | + | job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1481 | + | before := resolve(c, job) | |
| 1482 | + | require.Empty(t, before.GetError(), "no such repository here: github.com answers") | |
| 1483 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource()) | |
| 1484 | + | assert.Equal(t, strings.ToLower(shaOther), before.GetResolvedSha()) | |
| 1485 | + | require.Equal(t, 1, remote.count()) | |
| 1486 | + | ||
| 1487 | + | // The name is taken on the server, privately. | |
| 1488 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1489 | + | after := resolve(c, job) | |
| 1490 | + | assert.Equal(t, notFound, after.GetError(), "the stored github.com answer is not served for a repository the pusher may not read") | |
| 1491 | + | assert.Empty(t, after.GetResolvedSha()) | |
| 1492 | + | assert.Empty(t, after.GetTarballUrl()) | |
| 1493 | + | assert.Equal(t, 1, remote.count(), "and github.com was not asked again") | |
| 1494 | + | }) | |
| 1495 | + | ||
| 1496 | + | t.Run("a stored resolution from github.com, a pusher who reads the new repository", func(t *testing.T) { | |
| 1497 | + | c, _, runner := setup(t) | |
| 1498 | + | job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1499 | + | before := resolve(c, job) | |
| 1500 | + | require.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource()) | |
| 1501 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1502 | + | after := resolve(c, job) | |
| 1503 | + | require.Empty(t, after.GetError()) | |
| 1504 | + | assert.Equal(t, before.GetResolvedSha(), after.GetResolvedSha(), "stable for the attempt") | |
| 1505 | + | assert.Empty(t, after.GetToken(), "no archive token for a commit of github.com") | |
| 1506 | + | }) | |
| 1507 | + | ||
| 1508 | + | t.Run("a stored resolution from the server, then the repository goes", func(t *testing.T) { | |
| 1509 | + | c, remote, runner := setup(t) | |
| 1510 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1511 | + | c.mustGit(c.repoDir("b"), "tag", "v1") | |
| 1512 | + | job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1513 | + | require.Empty(t, resolve(c, job).GetError()) | |
| 1514 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("b"))) | |
| 1515 | + | got := resolve(c, job) | |
| 1516 | + | assert.Equal(t, notFound, got.GetError(), "there is nothing left to serve the stored commit from") | |
| 1517 | + | assert.Empty(t, got.GetToken()) | |
| 1518 | + | assert.False(t, remote.asked("owner/b")) | |
| 1519 | + | }) | |
| 1520 | + | ||
| 1521 | + | t.Run("a repository that does not exist here is github.com's", func(t *testing.T) { | |
| 1522 | + | c, remote, runner := setup(t) | |
| 1523 | + | job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1524 | + | got := resolve(c, job) | |
| 1525 | + | require.Empty(t, got.GetError()) | |
| 1526 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got.GetSource()) | |
| 1527 | + | assert.True(t, remote.asked("owner/b")) | |
| 1528 | + | }) | |
| 1529 | + | ||
| 1530 | + | t.Run("the owner's case does not open a way around", func(t *testing.T) { | |
| 1531 | + | c, remote, runner := setup(t) | |
| 1532 | + | c.seed("b", domain.VisibilityPrivate, "action.yml") | |
| 1533 | + | c.mustGit(c.repoDir("b"), "tag", "v1") | |
| 1534 | + | job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 1535 | + | for _, u := range []string{"OWNER/b@v1", "Owner/B@v1", "owner/B@v1"} { | |
| 1536 | + | got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, u)[0] | |
| 1537 | + | assert.Equal(t, "Unable to resolve action `"+strings.TrimSuffix(u, "@v1")+"@v1`, repository not found", got.GetError(), u) | |
| 1538 | + | } | |
| 1539 | + | assert.Zero(t, remote.count()) | |
| 1540 | + | }) | |
| 1541 | + | } | |
M internal/app/ci_flow_test.go +3 -2
| @@ -324,13 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | |||
| 324 | 324 | jobs: | |
| 325 | 325 | j: | |
| 326 | 326 | runs-on: ubuntu-latest | |
| 327 | + | environment: production | |
| 327 | 328 | steps: | |
| 328 | - | - uses: actions/checkout@v4 | |
| 329 | + | - run: echo | |
| 329 | 330 | `}, "") | |
| 330 | 331 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 331 | 332 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 332 | 333 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 333 | - | assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError()) | |
| 334 | + | assert.Equal(t, "not supported yet: environment", j.GetError()) | |
| 334 | 335 | } | |
| 335 | 336 | ||
| 336 | 337 | // A job for a label no runner has is "waiting for a runner" and fails after the | |
M internal/app/ci_parity_test.go +46 -6
| @@ -307,20 +307,50 @@ jobs: | |||
| 307 | 307 | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | - | // A step timeout stays refused as long as the runner has none. | |
| 311 | - | func TestCIStepTimeoutIsStillRefused(t *testing.T) { | |
| 310 | + | // CI-2: planning runs what CI-1 refused (uses, container, services, a step | |
| 311 | + | // timeout) and still refuses job-level uses, environment and concurrency. | |
| 312 | + | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | |
| 312 | 313 | c := newCIHarness(t, nil) | |
| 313 | - | c.registerRunner("alpha", "ubuntu-latest") | |
| 314 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 314 | 315 | c.push("main", map[string]string{".github/workflows/s.yml": `on: push | |
| 315 | 316 | jobs: | |
| 316 | 317 | j: | |
| 317 | 318 | runs-on: ubuntu-latest | |
| 319 | + | container: alpine:3 | |
| 320 | + | services: | |
| 321 | + | db: | |
| 322 | + | image: postgres:16 | |
| 318 | 323 | steps: | |
| 324 | + | - uses: actions/checkout@v4 | |
| 319 | 325 | - timeout-minutes: 1 | |
| 320 | 326 | run: echo | |
| 321 | 327 | `}, "") | |
| 322 | - | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 323 | - | assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError()) | |
| 328 | + | job := runner.await() | |
| 329 | + | assert.Equal(t, "j", job.GetJobKey()) | |
| 330 | + | ||
| 331 | + | for _, tc := range []struct{ name, job, want string }{ | |
| 332 | + | {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"}, | |
| 333 | + | {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"}, | |
| 334 | + | {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"}, | |
| 335 | + | } { | |
| 336 | + | t.Run(tc.name, func(t *testing.T) { | |
| 337 | + | body := "on: push\njobs:\n r:\n " + tc.job + "\n" | |
| 338 | + | c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "") | |
| 339 | + | var got string | |
| 340 | + | c.waitFor(tc.name+" fails", func() bool { | |
| 341 | + | for _, r := range c.runs() { | |
| 342 | + | if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED { | |
| 343 | + | if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 { | |
| 344 | + | got = jobs[0].GetError() | |
| 345 | + | return true | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return false | |
| 350 | + | }) | |
| 351 | + | assert.Equal(t, tc.want, got) | |
| 352 | + | }) | |
| 353 | + | } | |
| 324 | 354 | } | |
| 325 | 355 | ||
| 326 | 356 | // A failed job with continue-on-error does not fail the run, keeps its own | |
| @@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) { | |||
| 416 | 446 | "/owner/proj": "/proj", | |
| 417 | 447 | "/owner/proj/commit/" + sha: "/proj/commit/" + sha, | |
| 418 | 448 | "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1", | |
| 419 | - | "/owner/proj.git/info/refs": "/proj.git/info/refs", | |
| 420 | 449 | } { | |
| 421 | 450 | res := b.get(path, nil) | |
| 422 | 451 | assert.Equal(t, http.StatusPermanentRedirect, res.status, path) | |
| 423 | 452 | assert.Equal(t, to, res.hdr.Get("Location"), path) | |
| 424 | 453 | } | |
| 454 | + | // The git URLs of the owner are served by git over HTTP, not redirected | |
| 455 | + | // (checkout clones <server>/<owner>/<repo>): the private repository asks | |
| 456 | + | // for credentials, a dumb request has no route. | |
| 457 | + | for path, want := range map[string]int{ | |
| 458 | + | "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |
| 459 | + | "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |
| 460 | + | "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound, | |
| 461 | + | "/owner/proj.git/info/refs": http.StatusNotFound, | |
| 462 | + | } { | |
| 463 | + | assert.Equal(t, want, b.get(path, nil).status, path) | |
| 464 | + | } | |
| 425 | 465 | assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner") | |
| 426 | 466 | assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page") | |
| 427 | 467 | assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status) | |
M internal/app/cli_e2e_test.go +1 -1
| @@ -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", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, 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", "ci.actions"}, 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) | |
M internal/app/forge.go +36 -3
| @@ -20,6 +20,8 @@ import ( | |||
| 20 | 20 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 21 | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 22 | 22 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 23 | + | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 24 | + | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 23 | 25 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 24 | 26 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 25 | 27 | "bnema.dev/ermite/internal/adapters/rpc" | |
| @@ -50,11 +52,25 @@ type options struct { | |||
| 50 | 52 | eventBuffer int | |
| 51 | 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 52 | 54 | revalidateEvery time.Duration | |
| 55 | + | // archivesPerClient overrides the archives one client downloads at once | |
| 56 | + | // when positive. | |
| 57 | + | archivesPerClient int | |
| 58 | + | // remote replaces the lister of github.com refs (tests never reach the | |
| 59 | + | // network). | |
| 60 | + | remoteLister ports.GitRemote | |
| 53 | 61 | // ci overrides the settings of the CI engine (tests shorten its | |
| 54 | 62 | // durations). | |
| 55 | 63 | ci func(*usecase.CIConfig) | |
| 56 | 64 | } | |
| 57 | 65 | ||
| 66 | + | // remote returns the lister of github.com refs: the test's, else def. | |
| 67 | + | func (o options) remote(def ports.GitRemote) ports.GitRemote { | |
| 68 | + | if o.remoteLister != nil { | |
| 69 | + | return o.remoteLister | |
| 70 | + | } | |
| 71 | + | return def | |
| 72 | + | } | |
| 73 | + | ||
| 58 | 74 | // forge is the wired server: every adapter and use case, ready to serve. | |
| 59 | 75 | type forge struct { | |
| 60 | 76 | cfg config.Config | |
| @@ -161,7 +177,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 161 | 177 | Msg("repositories reconciled") | |
| 162 | 178 | ||
| 163 | 179 | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) | |
| 164 | - | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | |
| 180 | + | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI()) | |
| 165 | 181 | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) | |
| 166 | 182 | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | |
| 167 | 183 | ||
| @@ -199,7 +215,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 199 | 215 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | |
| 200 | 216 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 201 | 217 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 202 | - | PollTimeout: cfg.CIPollTimeout, | |
| 218 | + | PollTimeout: cfg.CIPollTimeout, Archive: git, | |
| 219 | + | Remote: opts.remote(gitremote.New(gitremote.Config{ | |
| 220 | + | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 221 | + | Proxy: gitremote.ProxyEnv(os.Getenv), | |
| 222 | + | })), | |
| 223 | + | Tokens: store.Tokens(), | |
| 203 | 224 | } | |
| 204 | 225 | if opts.ci != nil { | |
| 205 | 226 | opts.ci(&ciCfg) | |
| @@ -279,15 +300,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 279 | 300 | Repos: f.Repos, | |
| 280 | 301 | Pushes: f.Pushes, | |
| 281 | 302 | Backend: git, | |
| 303 | + | Owner: cfg.Owner, | |
| 282 | 304 | Hooks: f.hooks, | |
| 283 | 305 | TrustedProxies: cfg.TrustedProxies, | |
| 284 | 306 | Limiter: limiter, | |
| 285 | 307 | MaxBodyBytes: maxBody, | |
| 286 | 308 | }) | |
| 287 | - | routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns)) | |
| 309 | + | routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns)) | |
| 288 | 310 | for _, p := range githttp.Patterns { | |
| 289 | 311 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 290 | 312 | } | |
| 313 | + | // The owner form of the clone URL (github.repository), which GitHub's | |
| 314 | + | // actions use. config.Owner follows the rules of repository names, so it is | |
| 315 | + | // a literal segment that never overlaps /-/. | |
| 316 | + | for _, p := range githttp.OwnerPatterns(cfg.Owner) { | |
| 317 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 318 | + | } | |
| 319 | + | // The REST subset the actions call on github.api_url. | |
| 320 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient}) | |
| 321 | + | for _, p := range githubapi.Patterns { | |
| 322 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | |
| 323 | + | } | |
| 291 | 324 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | |
| 292 | 325 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | |
| 293 | 326 | site, err := newWeb(cfg, f, limiter) | |
M internal/app/web_passkey_test.go +2 -2
| @@ -188,7 +188,7 @@ func TestPasskeyEndToEnd(t *testing.T) { | |||
| 188 | 188 | assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID) | |
| 189 | 189 | assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded") | |
| 190 | 190 | ||
| 191 | - | rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t) | |
| 191 | + | rm := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID).ok(t) | |
| 192 | 192 | assert.Contains(t, rm.stderr, "every browser is signed out") | |
| 193 | 193 | for _, tok := range []string{token, ck.Value} { | |
| 194 | 194 | assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone") | |
| @@ -197,7 +197,7 @@ func TestPasskeyEndToEnd(t *testing.T) { | |||
| 197 | 197 | res = p.login() | |
| 198 | 198 | assert.Equal(t, http.StatusUnauthorized, res.status, res.body) | |
| 199 | 199 | assert.Empty(t, res.hdr.Values("Set-Cookie")) | |
| 200 | - | again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID) | |
| 200 | + | again := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID) | |
| 201 | 201 | require.Error(t, again.err) | |
| 202 | 202 | assert.Contains(t, again.err.Error(), "not found") | |
| 203 | 203 | } | |
M internal/domain/ci.go +3 -0
| @@ -191,6 +191,9 @@ type CIRunSource struct { | |||
| 191 | 191 | Repo RepoName | |
| 192 | 192 | WorkflowYAML string | |
| 193 | 193 | GithubJSON string | |
| 194 | + | // Pusher is who pushed the commit: what the actions of other repositories | |
| 195 | + | // of the server may be resolved on behalf of. | |
| 196 | + | Pusher CIPusher | |
| 194 | 197 | } | |
| 195 | 198 | ||
| 196 | 199 | // CIStep is a step of a job with where its lines are in the job log. | |
A internal/domain/ci_token.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // RepoTokenPrefix starts the secret of a job repository token: the | |
| 11 | + | // "github.token" of a job, or the token of one action archive. Unlike the | |
| 12 | + | // stored tokens (TokenSecretPrefix) it is never created, listed or revoked by | |
| 13 | + | // a person: the CI engine issues it with a job and revokes it with the job. | |
| 14 | + | const RepoTokenPrefix = "ermitegh_" | |
| 15 | + | ||
| 16 | + | // IsRepoToken reports whether s has the shape of a job repository token. | |
| 17 | + | func IsRepoToken(s string) bool { return hasTokenShape(s, RepoTokenPrefix) } | |
| 18 | + | ||
| 19 | + | // repoTokenIDPrefix starts the TokenID of the principal of a job repository | |
| 20 | + | // token, so that it never collides with the ID of a stored token. | |
| 21 | + | const repoTokenIDPrefix = "job:" | |
| 22 | + | ||
| 23 | + | // IsRepoTokenID reports whether id is the TokenID of the principal of a job | |
| 24 | + | // repository token (see RepoTokenPrincipal). | |
| 25 | + | func IsRepoTokenID(id string) bool { return strings.HasPrefix(id, repoTokenIDPrefix) } | |
| 26 | + | ||
| 27 | + | // CIRepoToken is a job repository token: it reads one repository, until | |
| 28 | + | // ExpiresAt or the end of its job. The secret is never stored, only its hash. | |
| 29 | + | type CIRepoToken struct { | |
| 30 | + | // ID identifies the token; it is not secret. | |
| 31 | + | ID string | |
| 32 | + | Hash TokenHash | |
| 33 | + | // Repo is the one repository the token reads. | |
| 34 | + | Repo RepoName | |
| 35 | + | // RunID, RunAttempt and JobID name the job it belongs to. | |
| 36 | + | RunID int64 | |
| 37 | + | RunAttempt int32 | |
| 38 | + | JobID string | |
| 39 | + | // Archive is set for the token of an action archive, unset for the job's | |
| 40 | + | // own token ("github.token"). An archive token downloads the commit Sha of | |
| 41 | + | // Repo and nothing else. | |
| 42 | + | Archive bool | |
| 43 | + | // Sha is the commit an archive token is bound to; empty for the job's own. | |
| 44 | + | Sha string | |
| 45 | + | ExpiresAt time.Time | |
| 46 | + | CreatedAt time.Time | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Expired reports whether the token is no longer valid at now. | |
| 50 | + | func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) } | |
| 51 | + | ||
| 52 | + | // RepoTokenGrace is how long a job repository token outlives the deadline of | |
| 53 | + | // its job at the most (a safety net: it is revoked when the job ends). | |
| 54 | + | const RepoTokenGrace = 5 * time.Minute | |
| 55 | + | ||
| 56 | + | // RepoTokenPrincipal returns the principal of the holder of a job repository | |
| 57 | + | // token. | |
| 58 | + | // | |
| 59 | + | // The job's own token is shaped like a stored token (the scope repo:read on one | |
| 60 | + | // repository, an expiry), so that every authorization rule of the forge applies | |
| 61 | + | // to it unchanged: it reads that repository, even when private, and writes | |
| 62 | + | // nothing. | |
| 63 | + | // | |
| 64 | + | // An archive token holds no scope at all: it is the grant to download one | |
| 65 | + | // commit of one repository as an archive, which only the archive endpoint | |
| 66 | + | // reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and | |
| 67 | + | // the rest of the API see a caller with no rights, so a private repository | |
| 68 | + | // does not show itself to it. | |
| 69 | + | // | |
| 70 | + | // Both have a TokenID that starts with "job:" and matches no stored token. | |
| 71 | + | func RepoTokenPrincipal(t CIRepoToken) Principal { | |
| 72 | + | if t.Archive { | |
| 73 | + | return Principal{ | |
| 74 | + | kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt, | |
| 75 | + | archiveRepo: t.Repo, archiveSha: t.Sha, | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return TokenPrincipal(Token{ | |
| 79 | + | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | |
| 80 | + | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // CIPusherKind tells who pushed the commit of a run. | |
| 85 | + | type CIPusherKind string | |
| 86 | + | ||
| 87 | + | // Pusher kinds. The zero value is a run stored before the pusher was recorded. | |
| 88 | + | const ( | |
| 89 | + | CIPusherNone CIPusherKind = "" | |
| 90 | + | CIPusherRoot CIPusherKind = "root" | |
| 91 | + | CIPusherOwner CIPusherKind = "owner" | |
| 92 | + | CIPusherToken CIPusherKind = "token" | |
| 93 | + | ) | |
| 94 | + | ||
| 95 | + | // CIPusher is who pushed the commit of a run, as recorded with the run. A | |
| 96 | + | // token is recorded by its ID only: what it may read is looked up when it is | |
| 97 | + | // needed, so a token revoked since no longer reads anything on behalf of the | |
| 98 | + | // run. | |
| 99 | + | type CIPusher struct { | |
| 100 | + | Kind CIPusherKind | |
| 101 | + | TokenID string | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // PusherOf records the pusher p. Anonymous callers, which cannot push, and the | |
| 105 | + | // holders of job tokens, which cannot either, are recorded as nobody. | |
| 106 | + | func PusherOf(p Principal) CIPusher { | |
| 107 | + | switch { | |
| 108 | + | case p.IsOwner(): | |
| 109 | + | return CIPusher{Kind: CIPusherOwner} | |
| 110 | + | case p.IsRoot(): | |
| 111 | + | return CIPusher{Kind: CIPusherRoot} | |
| 112 | + | case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()): | |
| 113 | + | return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()} | |
| 114 | + | } | |
| 115 | + | return CIPusher{} | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // CIActionSource says where an action was found. | |
| 119 | + | type CIActionSource int | |
| 120 | + | ||
| 121 | + | // Action sources, with the numbers of ActionSource in the protocol. | |
| 122 | + | const ( | |
| 123 | + | CIActionGitHub CIActionSource = 1 | |
| 124 | + | CIActionErmite CIActionSource = 2 | |
| 125 | + | ) | |
| 126 | + | ||
| 127 | + | // CIActionResolution is the resolution of an action reference for one run | |
| 128 | + | // attempt: every job of the attempt gets the same commit. | |
| 129 | + | type CIActionResolution struct { | |
| 130 | + | RunID int64 | |
| 131 | + | RunAttempt int32 | |
| 132 | + | // Key is "owner/repo@ref", lower-cased. | |
| 133 | + | Key string | |
| 134 | + | NameWithOwner string | |
| 135 | + | ResolvedSHA string | |
| 136 | + | Source CIActionSource | |
| 137 | + | } | |
A internal/domain/ci_token_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestIsRepoToken(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | ok := domain.RepoTokenPrefix + strings.Repeat("aB3-_", 8) + "xyz" | |
| 18 | + | assert.True(t, domain.IsRepoToken(ok)) | |
| 19 | + | for _, bad := range []string{ | |
| 20 | + | "", domain.RepoTokenPrefix, ok + "x", ok[:len(ok)-1], strings.Replace(ok, "ermitegh_", "ermitejob_", 1), | |
| 21 | + | domain.RepoTokenPrefix + strings.Repeat("a", 42) + "=", domain.RepoTokenPrefix + strings.Repeat("a", 42) + " ", | |
| 22 | + | domain.TokenSecretPrefix + strings.Repeat("a", 43), | |
| 23 | + | } { | |
| 24 | + | assert.False(t, domain.IsRepoToken(bad), bad) | |
| 25 | + | } | |
| 26 | + | assert.False(t, domain.IsJobToken(ok), "the two kinds of secret are never mistaken") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) { | |
| 30 | + | t.Parallel() | |
| 31 | + | repo, _ := domain.ParseRepoName("proj") | |
| 32 | + | other, _ := domain.ParseRepoName("other") | |
| 33 | + | exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) | |
| 34 | + | p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab12", Repo: repo, ExpiresAt: exp}) | |
| 35 | + | ||
| 36 | + | assert.True(t, p.Allows(domain.ScopeRepoRead, repo)) | |
| 37 | + | assert.False(t, p.Allows(domain.ScopeRepoRead, other)) | |
| 38 | + | for _, s := range domain.AllScopes() { | |
| 39 | + | if s != domain.ScopeRepoRead { | |
| 40 | + | assert.False(t, p.Allows(s, repo), s) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | assert.False(t, p.IsRoot()) | |
| 44 | + | assert.False(t, p.HasOwnerRights()) | |
| 45 | + | assert.False(t, p.IsAnonymous()) | |
| 46 | + | assert.Equal(t, exp, p.ExpiresAt()) | |
| 47 | + | assert.True(t, domain.IsRepoTokenID(p.TokenID())) | |
| 48 | + | assert.False(t, domain.IsRepoTokenID("0123456789abcdef"), "a stored token id is 16 hex characters") | |
| 49 | + | ||
| 50 | + | tok := domain.CIRepoToken{ExpiresAt: exp} | |
| 51 | + | assert.False(t, tok.Expired(exp.Add(-time.Second))) | |
| 52 | + | assert.True(t, tok.Expired(exp), "the expiry instant is already over") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | repo, _ := domain.ParseRepoName("action") | |
| 58 | + | sha := strings.Repeat("a", 40) | |
| 59 | + | exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) | |
| 60 | + | p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp}) | |
| 61 | + | ||
| 62 | + | gr, gs, ok := p.ArchiveGrant() | |
| 63 | + | assert.True(t, ok) | |
| 64 | + | assert.Equal(t, repo, gr) | |
| 65 | + | assert.Equal(t, sha, gs) | |
| 66 | + | for _, s := range domain.AllScopes() { | |
| 67 | + | assert.False(t, p.HasScope(s), s) | |
| 68 | + | assert.False(t, p.Allows(s, repo), s) | |
| 69 | + | } | |
| 70 | + | assert.False(t, p.IsRoot()) | |
| 71 | + | assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read") | |
| 72 | + | assert.False(t, p.HasOwnerRights()) | |
| 73 | + | assert.Equal(t, exp, p.ExpiresAt()) | |
| 74 | + | assert.True(t, domain.IsRepoTokenID(p.TokenID())) | |
| 75 | + | assert.Equal(t, "archive", p.Label()) | |
| 76 | + | ||
| 77 | + | own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp}) | |
| 78 | + | _, _, ok = own.ArchiveGrant() | |
| 79 | + | assert.False(t, ok, "the job's own token has no grant, it has the scope") | |
| 80 | + | for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} { | |
| 81 | + | _, _, ok = other.ArchiveGrant() | |
| 82 | + | assert.False(t, ok) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPusherOf(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | repo, _ := domain.ParseRepoName("proj") | |
| 89 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root())) | |
| 90 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner())) | |
| 91 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"}, | |
| 92 | + | domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}}))) | |
| 93 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous())) | |
| 94 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestOwnerMatches(t *testing.T) { | |
| 98 | + | t.Parallel() | |
| 99 | + | assert.True(t, domain.OwnerMatches("acme", "acme")) | |
| 100 | + | assert.True(t, domain.OwnerMatches("acme", "ACME")) | |
| 101 | + | assert.True(t, domain.OwnerMatches("acme", "Acme")) | |
| 102 | + | assert.False(t, domain.OwnerMatches("acme", "acme2")) | |
| 103 | + | assert.False(t, domain.OwnerMatches("acme", "")) | |
| 104 | + | assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing") | |
| 105 | + | assert.False(t, domain.OwnerMatches("", "acme")) | |
| 106 | + | } | |
M internal/domain/errors.go +4 -0
| @@ -36,6 +36,10 @@ var ( | |||
| 36 | 36 | // ErrResourceExhausted means a limit of the server is reached, for example | |
| 37 | 37 | // the number of event watchers; the caller may retry later. | |
| 38 | 38 | ErrResourceExhausted = errors.New("resource exhausted") | |
| 39 | + | // ErrUnavailable means something the operation depends on, outside the | |
| 40 | + | // forge (github.com for an action), could not be reached; the caller may | |
| 41 | + | // retry later. | |
| 42 | + | ErrUnavailable = errors.New("unavailable") | |
| 39 | 43 | ) | |
| 40 | 44 | ||
| 41 | 45 | // invalidErrors are the errors that mean a request was malformed. | |
M internal/domain/name.go +9 -0
| @@ -101,3 +101,12 @@ func nameProblem(s string) string { | |||
| 101 | 101 | } | |
| 102 | 102 | return "" | |
| 103 | 103 | } | |
| 104 | + | ||
| 105 | + | // OwnerMatches reports whether name is the owner of the forge, as GitHub | |
| 106 | + | // matches the owner of a repository: without regard to case. It is the one | |
| 107 | + | // rule for the owner segment of the Git URLs, of the REST subset and of the | |
| 108 | + | // references of actions. configured is ERMITE_OWNER, which is lower case (it | |
| 109 | + | // follows the rules of repository names); an empty one matches nothing. | |
| 110 | + | func OwnerMatches(configured, name string) bool { | |
| 111 | + | return configured != "" && strings.EqualFold(configured, name) | |
| 112 | + | } | |
M internal/domain/principal.go +20 -0
| @@ -17,6 +17,10 @@ const ( | |||
| 17 | 17 | kindRoot | |
| 18 | 18 | kindToken | |
| 19 | 19 | kindOwner | |
| 20 | + | // kindArchive is the holder of the archive token of an action: it holds no | |
| 21 | + | // scope at all, only a grant to download one commit of one repository (see | |
| 22 | + | // ArchivePrincipal). | |
| 23 | + | kindArchive | |
| 20 | 24 | ) | |
| 21 | 25 | ||
| 22 | 26 | // Principal is the authenticated identity of a caller: nobody, the root | |
| @@ -31,6 +35,9 @@ type Principal struct { | |||
| 31 | 35 | branchPrefixes []string | |
| 32 | 36 | expiresAt time.Time | |
| 33 | 37 | label string | |
| 38 | + | // archiveRepo and archiveSha are the grant of a kindArchive principal. | |
| 39 | + | archiveRepo RepoName | |
| 40 | + | archiveSha string | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | // Anonymous returns the principal of a caller without credentials. | |
| @@ -67,6 +74,8 @@ func (p Principal) Label() string { | |||
| 67 | 74 | return "owner" | |
| 68 | 75 | case kindToken: | |
| 69 | 76 | return "token" | |
| 77 | + | case kindArchive: | |
| 78 | + | return "archive" | |
| 70 | 79 | } | |
| 71 | 80 | return "anonymous" | |
| 72 | 81 | } | |
| @@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool { | |||
| 187 | 196 | // TokenLabel returns the label of the stored token, or "" for other | |
| 188 | 197 | // principals. | |
| 189 | 198 | func (p Principal) TokenLabel() string { return p.label } | |
| 199 | + | ||
| 200 | + | // ArchiveGrant returns the one commit of one repository that p may download as | |
| 201 | + | // an archive, and ok true, when p is the holder of an archive token. Only the | |
| 202 | + | // archive endpoint of the REST subset reads it: for everything else an archive | |
| 203 | + | // principal holds no scope, so it reads what an anonymous caller reads. | |
| 204 | + | func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) { | |
| 205 | + | if p.kind != kindArchive { | |
| 206 | + | return RepoName{}, "", false | |
| 207 | + | } | |
| 208 | + | return p.archiveRepo, p.archiveSha, true | |
| 209 | + | } | |
M internal/ports/ci.go +77 -5
| @@ -25,10 +25,35 @@ type CIRunDraft struct { | |||
| 25 | 25 | // Checks are derived from the jobs and upserted without a precondition. | |
| 26 | 26 | // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the | |
| 27 | 27 | // repository no longer exists. | |
| 28 | + | // | |
| 29 | + | // RepoTokens are job repository tokens created with the batch (they must not | |
| 30 | + | // exist yet), RevokeJobs the jobs whose repository tokens are deleted by it, | |
| 31 | + | // and Resolutions the action resolutions of a run attempt to keep. They are | |
| 32 | + | // written under the lock of the run that owns them. | |
| 28 | 33 | type CIWrite struct { | |
| 29 | - | Runs []domain.CIRun | |
| 30 | - | Jobs []domain.CIJob | |
| 31 | - | Checks []domain.CICheck | |
| 34 | + | Runs []domain.CIRun | |
| 35 | + | Jobs []domain.CIJob | |
| 36 | + | Checks []domain.CICheck | |
| 37 | + | RepoTokens []domain.CIRepoToken | |
| 38 | + | RevokeJobs []CIJobRef | |
| 39 | + | Resolutions []domain.CIActionResolution | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CIJobRef names one execution of a job. | |
| 43 | + | type CIJobRef struct { | |
| 44 | + | RunID int64 | |
| 45 | + | RunAttempt int32 | |
| 46 | + | JobID string | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // CIRepoTokenStore reads the job repository tokens, for the authentication of | |
| 50 | + | // a request. They are written by CIStore.Apply. *kvstore.CI implements it. | |
| 51 | + | type CIRepoTokenStore interface { | |
| 52 | + | // FindRepoTokenByHash returns the token whose secret has that hash, or | |
| 53 | + | // domain.ErrNotFound. | |
| 54 | + | FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) | |
| 55 | + | // GetRepoToken returns the token with that id, or domain.ErrNotFound. | |
| 56 | + | GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) | |
| 32 | 57 | } | |
| 33 | 58 | ||
| 34 | 59 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | |
| @@ -46,6 +71,9 @@ type CIStore interface { | |||
| 46 | 71 | Apply(ctx context.Context, w CIWrite) (version uint64, err error) | |
| 47 | 72 | // GetRun returns the run, or domain.ErrNotFound. | |
| 48 | 73 | GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) | |
| 74 | + | // GetActionResolution returns the resolution kept for the key in the run | |
| 75 | + | // attempt, or domain.ErrNotFound. | |
| 76 | + | GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error) | |
| 49 | 77 | // GetRunSource returns what the run was planned from, or | |
| 50 | 78 | // domain.ErrNotFound. It is how a run is found from its id alone. | |
| 51 | 79 | GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) | |
| @@ -165,6 +193,11 @@ type CILogStore interface { | |||
| 165 | 193 | // errors never reach the push. | |
| 166 | 194 | type WorkflowTrigger interface { | |
| 167 | 195 | OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) | |
| 196 | + | // OnMerge is OnPush for the move of a target branch by a merge. GitHub shows | |
| 197 | + | // the owner as the actor of the push of a merge, whoever merged; but the | |
| 198 | + | // rights the run's actions are resolved with are those of merger, the | |
| 199 | + | // principal that merged, never the owner's. | |
| 200 | + | OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) | |
| 168 | 201 | } | |
| 169 | 202 | ||
| 170 | 203 | // CIGit is what planning needs from Git beyond RepoReader and MergeGit. | |
| @@ -200,8 +233,10 @@ type RegisteredRunner struct { | |||
| 200 | 233 | ||
| 201 | 234 | // RunnerJob is a job given to a runner. | |
| 202 | 235 | type RunnerJob struct { | |
| 203 | - | Job domain.CIJob | |
| 204 | - | Token string | |
| 236 | + | Job domain.CIJob | |
| 237 | + | Token string | |
| 238 | + | // GithubToken is the job repository token, the "github.token" of the job. | |
| 239 | + | GithubToken string | |
| 205 | 240 | Repo domain.RepoName | |
| 206 | 241 | Sha, Ref string | |
| 207 | 242 | WorkflowPath string | |
| @@ -247,6 +282,38 @@ type CompleteJobResult struct { | |||
| 247 | 282 | Applied bool | |
| 248 | 283 | } | |
| 249 | 284 | ||
| 285 | + | // ResolvedAction is the resolution of one "uses" of ResolveActions: where to | |
| 286 | + | // download the action, or Error (then only Ref is set). | |
| 287 | + | type ResolvedAction struct { | |
| 288 | + | Ref string | |
| 289 | + | NameWithOwner string | |
| 290 | + | Path string | |
| 291 | + | ResolvedSHA string | |
| 292 | + | TarballURL string | |
| 293 | + | Token string | |
| 294 | + | Source domain.CIActionSource | |
| 295 | + | Error string | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // RemoteRef is a ref of a remote repository, as `git ls-remote` lists it. | |
| 299 | + | type RemoteRef struct { | |
| 300 | + | // Name is the full ref name ("refs/heads/main", "refs/tags/v1"). | |
| 301 | + | Name string | |
| 302 | + | // ID is the object the ref points to (the tag object of an annotated tag). | |
| 303 | + | ID string | |
| 304 | + | // Peeled is the commit an annotated tag points to; empty for other refs. | |
| 305 | + | Peeled string | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // GitRemote lists the refs of a repository on github.com, with no | |
| 309 | + | // credentials. It returns domain.ErrNotFound for a repository that does not | |
| 310 | + | // exist or needs credentials (GitHub answers alike), and an error matching | |
| 311 | + | // domain.ErrUnavailable when it cannot tell (network, timeout, too large an | |
| 312 | + | // answer). | |
| 313 | + | type GitRemote interface { | |
| 314 | + | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | |
| 315 | + | } | |
| 316 | + | ||
| 250 | 317 | // JobSignal is one message of WatchJob. | |
| 251 | 318 | type JobSignal struct { | |
| 252 | 319 | Keepalive bool | |
| @@ -278,6 +345,11 @@ type RunnerService interface { | |||
| 278 | 345 | // then, because a client's call returns only when they arrive. | |
| 279 | 346 | WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error | |
| 280 | 347 | CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error) | |
| 348 | + | // ResolveActions resolves the "uses" of the job's steps, one answer per | |
| 349 | + | // reference in the same order. A reference that cannot be resolved is not | |
| 350 | + | // an error: its answer carries Error. A local ("./") or Docker | |
| 351 | + | // ("docker://") reference, or more than 100, is domain.ErrInvalid. | |
| 352 | + | ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error) | |
| 281 | 353 | } | |
| 282 | 354 | ||
| 283 | 355 | // JobView is a job as owners see it. | |
A internal/ports/mocks/CIRepoTokenStore.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 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 | + | // NewCIRepoTokenStore creates a new instance of CIRepoTokenStore. 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 NewCIRepoTokenStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CIRepoTokenStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CIRepoTokenStore{} | |
| 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 | + | // CIRepoTokenStore is an autogenerated mock type for the CIRepoTokenStore type | |
| 38 | + | type CIRepoTokenStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CIRepoTokenStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CIRepoTokenStore) EXPECT() *CIRepoTokenStore_Expecter { | |
| 47 | + | return &CIRepoTokenStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // FindRepoTokenByHash provides a mock function for the type CIRepoTokenStore | |
| 51 | + | func (_mock *CIRepoTokenStore) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) { | |
| 52 | + | ret := _mock.Called(ctx, hash) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for FindRepoTokenByHash") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.CIRepoToken | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.CIRepoToken, error)); ok { | |
| 61 | + | return returnFunc(ctx, hash) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.CIRepoToken); ok { | |
| 64 | + | r0 = returnFunc(ctx, hash) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.CIRepoToken) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, hash) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // CIRepoTokenStore_FindRepoTokenByHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRepoTokenByHash' | |
| 77 | + | type CIRepoTokenStore_FindRepoTokenByHash_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // FindRepoTokenByHash is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - hash domain.TokenHash | |
| 84 | + | func (_e *CIRepoTokenStore_Expecter) FindRepoTokenByHash(ctx any, hash any) *CIRepoTokenStore_FindRepoTokenByHash_Call { | |
| 85 | + | return &CIRepoTokenStore_FindRepoTokenByHash_Call{Call: _e.mock.On("FindRepoTokenByHash", ctx, hash)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *CIRepoTokenStore_FindRepoTokenByHash_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | var arg1 domain.TokenHash | |
| 95 | + | if args[1] != nil { | |
| 96 | + | arg1 = args[1].(domain.TokenHash) | |
| 97 | + | } | |
| 98 | + | run( | |
| 99 | + | arg0, | |
| 100 | + | arg1, | |
| 101 | + | ) | |
| 102 | + | }) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) Return(cIRepoToken domain.CIRepoToken, err error) *CIRepoTokenStore_FindRepoTokenByHash_Call { | |
| 107 | + | _c.Call.Return(cIRepoToken, err) | |
| 108 | + | return _c | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error)) *CIRepoTokenStore_FindRepoTokenByHash_Call { | |
| 112 | + | _c.Call.Return(run) | |
| 113 | + | return _c | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // GetRepoToken provides a mock function for the type CIRepoTokenStore | |
| 117 | + | func (_mock *CIRepoTokenStore) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) { | |
| 118 | + | ret := _mock.Called(ctx, id) | |
| 119 | + | ||
| 120 | + | if len(ret) == 0 { | |
| 121 | + | panic("no return value specified for GetRepoToken") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | var r0 domain.CIRepoToken | |
| 125 | + | var r1 error | |
| 126 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRepoToken, error)); ok { | |
| 127 | + | return returnFunc(ctx, id) | |
| 128 | + | } | |
| 129 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRepoToken); ok { | |
| 130 | + | r0 = returnFunc(ctx, id) | |
| 131 | + | } else { | |
| 132 | + | r0 = ret.Get(0).(domain.CIRepoToken) | |
| 133 | + | } | |
| 134 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 135 | + | r1 = returnFunc(ctx, id) | |
| 136 | + | } else { | |
| 137 | + | r1 = ret.Error(1) | |
| 138 | + | } | |
| 139 | + | return r0, r1 | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // CIRepoTokenStore_GetRepoToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoToken' | |
| 143 | + | type CIRepoTokenStore_GetRepoToken_Call struct { | |
| 144 | + | *mock.Call | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // GetRepoToken is a helper method to define mock.On call | |
| 148 | + | // - ctx context.Context | |
| 149 | + | // - id string | |
| 150 | + | func (_e *CIRepoTokenStore_Expecter) GetRepoToken(ctx any, id any) *CIRepoTokenStore_GetRepoToken_Call { | |
| 151 | + | return &CIRepoTokenStore_GetRepoToken_Call{Call: _e.mock.On("GetRepoToken", ctx, id)} | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (_c *CIRepoTokenStore_GetRepoToken_Call) Run(run func(ctx context.Context, id string)) *CIRepoTokenStore_GetRepoToken_Call { | |
| 155 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 156 | + | var arg0 context.Context | |
| 157 | + | if args[0] != nil { | |
| 158 | + | arg0 = args[0].(context.Context) | |
| 159 | + | } | |
| 160 | + | var arg1 string | |
| 161 | + | if args[1] != nil { | |
| 162 | + | arg1 = args[1].(string) | |
| 163 | + | } | |
| 164 | + | run( | |
| 165 | + | arg0, | |
| 166 | + | arg1, | |
| 167 | + | ) | |
| 168 | + | }) | |
| 169 | + | return _c | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *CIRepoTokenStore_GetRepoToken_Call) Return(cIRepoToken domain.CIRepoToken, err error) *CIRepoTokenStore_GetRepoToken_Call { | |
| 173 | + | _c.Call.Return(cIRepoToken, err) | |
| 174 | + | return _c | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *CIRepoTokenStore_GetRepoToken_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRepoToken, error)) *CIRepoTokenStore_GetRepoToken_Call { | |
| 178 | + | _c.Call.Return(run) | |
| 179 | + | return _c | |
| 180 | + | } | |
M internal/ports/mocks/CIStore.go +78 -0
| @@ -405,6 +405,84 @@ func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Cont | |||
| 405 | 405 | return _c | |
| 406 | 406 | } | |
| 407 | 407 | ||
| 408 | + | // GetActionResolution provides a mock function for the type CIStore | |
| 409 | + | func (_mock *CIStore) GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error) { | |
| 410 | + | ret := _mock.Called(ctx, runID, runAttempt, key) | |
| 411 | + | ||
| 412 | + | if len(ret) == 0 { | |
| 413 | + | panic("no return value specified for GetActionResolution") | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | var r0 domain.CIActionResolution | |
| 417 | + | var r1 error | |
| 418 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) (domain.CIActionResolution, error)); ok { | |
| 419 | + | return returnFunc(ctx, runID, runAttempt, key) | |
| 420 | + | } | |
| 421 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) domain.CIActionResolution); ok { | |
| 422 | + | r0 = returnFunc(ctx, runID, runAttempt, key) | |
| 423 | + | } else { | |
| 424 | + | r0 = ret.Get(0).(domain.CIActionResolution) | |
| 425 | + | } | |
| 426 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok { | |
| 427 | + | r1 = returnFunc(ctx, runID, runAttempt, key) | |
| 428 | + | } else { | |
| 429 | + | r1 = ret.Error(1) | |
| 430 | + | } | |
| 431 | + | return r0, r1 | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // CIStore_GetActionResolution_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActionResolution' | |
| 435 | + | type CIStore_GetActionResolution_Call struct { | |
| 436 | + | *mock.Call | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // GetActionResolution is a helper method to define mock.On call | |
| 440 | + | // - ctx context.Context | |
| 441 | + | // - runID int64 | |
| 442 | + | // - runAttempt int32 | |
| 443 | + | // - key string | |
| 444 | + | func (_e *CIStore_Expecter) GetActionResolution(ctx any, runID any, runAttempt any, key any) *CIStore_GetActionResolution_Call { | |
| 445 | + | return &CIStore_GetActionResolution_Call{Call: _e.mock.On("GetActionResolution", ctx, runID, runAttempt, key)} | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *CIStore_GetActionResolution_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32, key string)) *CIStore_GetActionResolution_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_GetActionResolution_Call) Return(cIActionResolution domain.CIActionResolution, err error) *CIStore_GetActionResolution_Call { | |
| 477 | + | _c.Call.Return(cIActionResolution, err) | |
| 478 | + | return _c | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func (_c *CIStore_GetActionResolution_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error)) *CIStore_GetActionResolution_Call { | |
| 482 | + | _c.Call.Return(run) | |
| 483 | + | return _c | |
| 484 | + | } | |
| 485 | + | ||
| 408 | 486 | // GetJob provides a mock function for the type CIStore | |
| 409 | 487 | func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | |
| 410 | 488 | ret := _mock.Called(ctx, runID, runAttempt, jobID) | |
A internal/ports/mocks/GitArchive.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 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 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewGitArchive creates a new instance of GitArchive. 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 NewGitArchive(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *GitArchive { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &GitArchive{} | |
| 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 | + | // GitArchive is an autogenerated mock type for the GitArchive type | |
| 39 | + | type GitArchive struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type GitArchive_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *GitArchive) EXPECT() *GitArchive_Expecter { | |
| 48 | + | return &GitArchive_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Archive provides a mock function for the type GitArchive | |
| 52 | + | func (_mock *GitArchive) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | |
| 53 | + | ret := _mock.Called(ctx, repo, commit, prefix, w) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Archive") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, string, io.Writer) error); ok { | |
| 61 | + | r0 = returnFunc(ctx, repo, commit, prefix, w) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // GitArchive_Archive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Archive' | |
| 69 | + | type GitArchive_Archive_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Archive is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - repo domain.RepoName | |
| 76 | + | // - commit domain.ObjectID | |
| 77 | + | // - prefix string | |
| 78 | + | // - w io.Writer | |
| 79 | + | func (_e *GitArchive_Expecter) Archive(ctx any, repo any, commit any, prefix any, w any) *GitArchive_Archive_Call { | |
| 80 | + | return &GitArchive_Archive_Call{Call: _e.mock.On("Archive", ctx, repo, commit, prefix, w)} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *GitArchive_Archive_Call) Run(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer)) *GitArchive_Archive_Call { | |
| 84 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 85 | + | var arg0 context.Context | |
| 86 | + | if args[0] != nil { | |
| 87 | + | arg0 = args[0].(context.Context) | |
| 88 | + | } | |
| 89 | + | var arg1 domain.RepoName | |
| 90 | + | if args[1] != nil { | |
| 91 | + | arg1 = args[1].(domain.RepoName) | |
| 92 | + | } | |
| 93 | + | var arg2 domain.ObjectID | |
| 94 | + | if args[2] != nil { | |
| 95 | + | arg2 = args[2].(domain.ObjectID) | |
| 96 | + | } | |
| 97 | + | var arg3 string | |
| 98 | + | if args[3] != nil { | |
| 99 | + | arg3 = args[3].(string) | |
| 100 | + | } | |
| 101 | + | var arg4 io.Writer | |
| 102 | + | if args[4] != nil { | |
| 103 | + | arg4 = args[4].(io.Writer) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | arg2, | |
| 109 | + | arg3, | |
| 110 | + | arg4, | |
| 111 | + | ) | |
| 112 | + | }) | |
| 113 | + | return _c | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (_c *GitArchive_Archive_Call) Return(err error) *GitArchive_Archive_Call { | |
| 117 | + | _c.Call.Return(err) | |
| 118 | + | return _c | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (_c *GitArchive_Archive_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error) *GitArchive_Archive_Call { | |
| 122 | + | _c.Call.Return(run) | |
| 123 | + | return _c | |
| 124 | + | } | |
A internal/ports/mocks/GitHubAPIService.go +372 -0
| @@ -0,0 +1,372 @@ | |||
| 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 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewGitHubAPIService creates a new instance of GitHubAPIService. 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 NewGitHubAPIService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *GitHubAPIService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &GitHubAPIService{} | |
| 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 | + | // GitHubAPIService is an autogenerated mock type for the GitHubAPIService type | |
| 39 | + | type GitHubAPIService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type GitHubAPIService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *GitHubAPIService) EXPECT() *GitHubAPIService_Expecter { | |
| 48 | + | return &GitHubAPIService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // GetRepo provides a mock function for the type GitHubAPIService | |
| 52 | + | func (_mock *GitHubAPIService) GetRepo(ctx context.Context, p domain.Principal, owner string, repo string) ([]byte, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, owner, repo) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for GetRepo") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 []byte | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) ([]byte, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, owner, repo) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) []byte); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, owner, repo) | |
| 66 | + | } else { | |
| 67 | + | if ret.Get(0) != nil { | |
| 68 | + | r0 = ret.Get(0).([]byte) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string) error); ok { | |
| 72 | + | r1 = returnFunc(ctx, p, owner, repo) | |
| 73 | + | } else { | |
| 74 | + | r1 = ret.Error(1) | |
| 75 | + | } | |
| 76 | + | return r0, r1 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // GitHubAPIService_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo' | |
| 80 | + | type GitHubAPIService_GetRepo_Call struct { | |
| 81 | + | *mock.Call | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // GetRepo is a helper method to define mock.On call | |
| 85 | + | // - ctx context.Context | |
| 86 | + | // - p domain.Principal | |
| 87 | + | // - owner string | |
| 88 | + | // - repo string | |
| 89 | + | func (_e *GitHubAPIService_Expecter) GetRepo(ctx any, p any, owner any, repo any) *GitHubAPIService_GetRepo_Call { | |
| 90 | + | return &GitHubAPIService_GetRepo_Call{Call: _e.mock.On("GetRepo", ctx, p, owner, repo)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *GitHubAPIService_GetRepo_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string)) *GitHubAPIService_GetRepo_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 domain.Principal | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(domain.Principal) | |
| 102 | + | } | |
| 103 | + | var arg2 string | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(string) | |
| 106 | + | } | |
| 107 | + | var arg3 string | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(string) | |
| 110 | + | } | |
| 111 | + | run( | |
| 112 | + | arg0, | |
| 113 | + | arg1, | |
| 114 | + | arg2, | |
| 115 | + | arg3, | |
| 116 | + | ) | |
| 117 | + | }) | |
| 118 | + | return _c | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (_c *GitHubAPIService_GetRepo_Call) Return(bytes []byte, err error) *GitHubAPIService_GetRepo_Call { | |
| 122 | + | _c.Call.Return(bytes, err) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *GitHubAPIService_GetRepo_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string) ([]byte, error)) *GitHubAPIService_GetRepo_Call { | |
| 127 | + | _c.Call.Return(run) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // HashAlgorithm provides a mock function for the type GitHubAPIService | |
| 132 | + | func (_mock *GitHubAPIService) HashAlgorithm(ctx context.Context, p domain.Principal, owner string, repo string) (string, error) { | |
| 133 | + | ret := _mock.Called(ctx, p, owner, repo) | |
| 134 | + | ||
| 135 | + | if len(ret) == 0 { | |
| 136 | + | panic("no return value specified for HashAlgorithm") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | var r0 string | |
| 140 | + | var r1 error | |
| 141 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) (string, error)); ok { | |
| 142 | + | return returnFunc(ctx, p, owner, repo) | |
| 143 | + | } | |
| 144 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) string); ok { | |
| 145 | + | r0 = returnFunc(ctx, p, owner, repo) | |
| 146 | + | } else { | |
| 147 | + | r0 = ret.Get(0).(string) | |
| 148 | + | } | |
| 149 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string) error); ok { | |
| 150 | + | r1 = returnFunc(ctx, p, owner, repo) | |
| 151 | + | } else { | |
| 152 | + | r1 = ret.Error(1) | |
| 153 | + | } | |
| 154 | + | return r0, r1 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // GitHubAPIService_HashAlgorithm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HashAlgorithm' | |
| 158 | + | type GitHubAPIService_HashAlgorithm_Call struct { | |
| 159 | + | *mock.Call | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // HashAlgorithm is a helper method to define mock.On call | |
| 163 | + | // - ctx context.Context | |
| 164 | + | // - p domain.Principal | |
| 165 | + | // - owner string | |
| 166 | + | // - repo string | |
| 167 | + | func (_e *GitHubAPIService_Expecter) HashAlgorithm(ctx any, p any, owner any, repo any) *GitHubAPIService_HashAlgorithm_Call { | |
| 168 | + | return &GitHubAPIService_HashAlgorithm_Call{Call: _e.mock.On("HashAlgorithm", ctx, p, owner, repo)} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *GitHubAPIService_HashAlgorithm_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string)) *GitHubAPIService_HashAlgorithm_Call { | |
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 173 | + | var arg0 context.Context | |
| 174 | + | if args[0] != nil { | |
| 175 | + | arg0 = args[0].(context.Context) | |
| 176 | + | } | |
| 177 | + | var arg1 domain.Principal | |
| 178 | + | if args[1] != nil { | |
| 179 | + | arg1 = args[1].(domain.Principal) | |
| 180 | + | } | |
| 181 | + | var arg2 string | |
| 182 | + | if args[2] != nil { | |
| 183 | + | arg2 = args[2].(string) | |
| 184 | + | } | |
| 185 | + | var arg3 string | |
| 186 | + | if args[3] != nil { | |
| 187 | + | arg3 = args[3].(string) | |
| 188 | + | } | |
| 189 | + | run( | |
| 190 | + | arg0, | |
| 191 | + | arg1, | |
| 192 | + | arg2, | |
| 193 | + | arg3, | |
| 194 | + | ) | |
| 195 | + | }) | |
| 196 | + | return _c | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (_c *GitHubAPIService_HashAlgorithm_Call) Return(s string, err error) *GitHubAPIService_HashAlgorithm_Call { | |
| 200 | + | _c.Call.Return(s, err) | |
| 201 | + | return _c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (_c *GitHubAPIService_HashAlgorithm_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string) (string, error)) *GitHubAPIService_HashAlgorithm_Call { | |
| 205 | + | _c.Call.Return(run) | |
| 206 | + | return _c | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // Tarball provides a mock function for the type GitHubAPIService | |
| 210 | + | func (_mock *GitHubAPIService) Tarball(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer) error { | |
| 211 | + | ret := _mock.Called(ctx, p, owner, repo, ref, begin) | |
| 212 | + | ||
| 213 | + | if len(ret) == 0 { | |
| 214 | + | panic("no return value specified for Tarball") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | var r0 error | |
| 218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string, func(filename string) io.Writer) error); ok { | |
| 219 | + | r0 = returnFunc(ctx, p, owner, repo, ref, begin) | |
| 220 | + | } else { | |
| 221 | + | r0 = ret.Error(0) | |
| 222 | + | } | |
| 223 | + | return r0 | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // GitHubAPIService_Tarball_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tarball' | |
| 227 | + | type GitHubAPIService_Tarball_Call struct { | |
| 228 | + | *mock.Call | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Tarball is a helper method to define mock.On call | |
| 232 | + | // - ctx context.Context | |
| 233 | + | // - p domain.Principal | |
| 234 | + | // - owner string | |
| 235 | + | // - repo string | |
| 236 | + | // - ref string | |
| 237 | + | // - begin func(filename string) io.Writer | |
| 238 | + | func (_e *GitHubAPIService_Expecter) Tarball(ctx any, p any, owner any, repo any, ref any, begin any) *GitHubAPIService_Tarball_Call { | |
| 239 | + | return &GitHubAPIService_Tarball_Call{Call: _e.mock.On("Tarball", ctx, p, owner, repo, ref, begin)} | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (_c *GitHubAPIService_Tarball_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer)) *GitHubAPIService_Tarball_Call { | |
| 243 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 244 | + | var arg0 context.Context | |
| 245 | + | if args[0] != nil { | |
| 246 | + | arg0 = args[0].(context.Context) | |
| 247 | + | } | |
| 248 | + | var arg1 domain.Principal | |
| 249 | + | if args[1] != nil { | |
| 250 | + | arg1 = args[1].(domain.Principal) | |
| 251 | + | } | |
| 252 | + | var arg2 string | |
| 253 | + | if args[2] != nil { | |
| 254 | + | arg2 = args[2].(string) | |
| 255 | + | } | |
| 256 | + | var arg3 string | |
| 257 | + | if args[3] != nil { | |
| 258 | + | arg3 = args[3].(string) | |
| 259 | + | } | |
| 260 | + | var arg4 string | |
| 261 | + | if args[4] != nil { | |
| 262 | + | arg4 = args[4].(string) | |
| 263 | + | } | |
| 264 | + | var arg5 func(filename string) io.Writer | |
| 265 | + | if args[5] != nil { | |
| 266 | + | arg5 = args[5].(func(filename string) io.Writer) | |
| 267 | + | } | |
| 268 | + | run( | |
| 269 | + | arg0, | |
| 270 | + | arg1, | |
| 271 | + | arg2, | |
| 272 | + | arg3, | |
| 273 | + | arg4, | |
| 274 | + | arg5, | |
| 275 | + | ) | |
| 276 | + | }) | |
| 277 | + | return _c | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (_c *GitHubAPIService_Tarball_Call) Return(err error) *GitHubAPIService_Tarball_Call { | |
| 281 | + | _c.Call.Return(err) | |
| 282 | + | return _c | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer) error) *GitHubAPIService_Tarball_Call { | |
| 286 | + | _c.Call.Return(run) | |
| 287 | + | return _c | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // TarballName provides a mock function for the type GitHubAPIService | |
| 291 | + | func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) { | |
| 292 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |
| 293 | + | ||
| 294 | + | if len(ret) == 0 { | |
| 295 | + | panic("no return value specified for TarballName") | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | var r0 string | |
| 299 | + | var r1 error | |
| 300 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok { | |
| 301 | + | return returnFunc(ctx, p, owner, repo, ref) | |
| 302 | + | } | |
| 303 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok { | |
| 304 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |
| 305 | + | } else { | |
| 306 | + | r0 = ret.Get(0).(string) | |
| 307 | + | } | |
| 308 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok { | |
| 309 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |
| 310 | + | } else { | |
| 311 | + | r1 = ret.Error(1) | |
| 312 | + | } | |
| 313 | + | return r0, r1 | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName' | |
| 317 | + | type GitHubAPIService_TarballName_Call struct { | |
| 318 | + | *mock.Call | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // TarballName is a helper method to define mock.On call | |
| 322 | + | // - ctx context.Context | |
| 323 | + | // - p domain.Principal | |
| 324 | + | // - owner string | |
| 325 | + | // - repo string | |
| 326 | + | // - ref string | |
| 327 | + | func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call { | |
| 328 | + | return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)} | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call { | |
| 332 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 333 | + | var arg0 context.Context | |
| 334 | + | if args[0] != nil { | |
| 335 | + | arg0 = args[0].(context.Context) | |
| 336 | + | } | |
| 337 | + | var arg1 domain.Principal | |
| 338 | + | if args[1] != nil { | |
| 339 | + | arg1 = args[1].(domain.Principal) | |
| 340 | + | } | |
| 341 | + | var arg2 string | |
| 342 | + | if args[2] != nil { | |
| 343 | + | arg2 = args[2].(string) | |
| 344 | + | } | |
| 345 | + | var arg3 string | |
| 346 | + | if args[3] != nil { | |
| 347 | + | arg3 = args[3].(string) | |
| 348 | + | } | |
| 349 | + | var arg4 string | |
| 350 | + | if args[4] != nil { | |
| 351 | + | arg4 = args[4].(string) | |
| 352 | + | } | |
| 353 | + | run( | |
| 354 | + | arg0, | |
| 355 | + | arg1, | |
| 356 | + | arg2, | |
| 357 | + | arg3, | |
| 358 | + | arg4, | |
| 359 | + | ) | |
| 360 | + | }) | |
| 361 | + | return _c | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call { | |
| 365 | + | _c.Call.Return(s, err) | |
| 366 | + | return _c | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call { | |
| 370 | + | _c.Call.Return(run) | |
| 371 | + | return _c | |
| 372 | + | } | |
A internal/ports/mocks/GitRemote.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/ports" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewGitRemote creates a new instance of GitRemote. 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 NewGitRemote(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *GitRemote { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &GitRemote{} | |
| 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 | + | // GitRemote is an autogenerated mock type for the GitRemote type | |
| 38 | + | type GitRemote struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type GitRemote_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *GitRemote) EXPECT() *GitRemote_Expecter { | |
| 47 | + | return &GitRemote_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ListRefs provides a mock function for the type GitRemote | |
| 51 | + | func (_mock *GitRemote) ListRefs(ctx context.Context, owner string, repo string) ([]ports.RemoteRef, error) { | |
| 52 | + | ret := _mock.Called(ctx, owner, repo) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for ListRefs") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []ports.RemoteRef | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]ports.RemoteRef, error)); ok { | |
| 61 | + | return returnFunc(ctx, owner, repo) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []ports.RemoteRef); ok { | |
| 64 | + | r0 = returnFunc(ctx, owner, repo) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]ports.RemoteRef) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, owner, repo) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // GitRemote_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs' | |
| 79 | + | type GitRemote_ListRefs_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ListRefs is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - owner string | |
| 86 | + | // - repo string | |
| 87 | + | func (_e *GitRemote_Expecter) ListRefs(ctx any, owner any, repo any) *GitRemote_ListRefs_Call { | |
| 88 | + | return &GitRemote_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx, owner, repo)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *GitRemote_ListRefs_Call) Run(run func(ctx context.Context, owner string, repo string)) *GitRemote_ListRefs_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 string | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(string) | |
| 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 *GitRemote_ListRefs_Call) Return(remoteRefs []ports.RemoteRef, err error) *GitRemote_ListRefs_Call { | |
| 115 | + | _c.Call.Return(remoteRefs, err) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *GitRemote_ListRefs_Call) RunAndReturn(run func(ctx context.Context, owner string, repo string) ([]ports.RemoteRef, error)) *GitRemote_ListRefs_Call { | |
| 120 | + | _c.Call.Return(run) | |
| 121 | + | return _c | |
| 122 | + | } | |
M internal/ports/mocks/RunnerService.go +80 -0
| @@ -417,6 +417,86 @@ func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context | |||
| 417 | 417 | return _c | |
| 418 | 418 | } | |
| 419 | 419 | ||
| 420 | + | // ResolveActions provides a mock function for the type RunnerService | |
| 421 | + | func (_mock *RunnerService) ResolveActions(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |
| 422 | + | ret := _mock.Called(ctx, secret, jobID, uses) | |
| 423 | + | ||
| 424 | + | if len(ret) == 0 { | |
| 425 | + | panic("no return value specified for ResolveActions") | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | var r0 []ports.ResolvedAction | |
| 429 | + | var r1 error | |
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) ([]ports.ResolvedAction, error)); ok { | |
| 431 | + | return returnFunc(ctx, secret, jobID, uses) | |
| 432 | + | } | |
| 433 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) []ports.ResolvedAction); ok { | |
| 434 | + | r0 = returnFunc(ctx, secret, jobID, uses) | |
| 435 | + | } else { | |
| 436 | + | if ret.Get(0) != nil { | |
| 437 | + | r0 = ret.Get(0).([]ports.ResolvedAction) | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok { | |
| 441 | + | r1 = returnFunc(ctx, secret, jobID, uses) | |
| 442 | + | } else { | |
| 443 | + | r1 = ret.Error(1) | |
| 444 | + | } | |
| 445 | + | return r0, r1 | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // RunnerService_ResolveActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveActions' | |
| 449 | + | type RunnerService_ResolveActions_Call struct { | |
| 450 | + | *mock.Call | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // ResolveActions is a helper method to define mock.On call | |
| 454 | + | // - ctx context.Context | |
| 455 | + | // - secret string | |
| 456 | + | // - jobID string | |
| 457 | + | // - uses []string | |
| 458 | + | func (_e *RunnerService_Expecter) ResolveActions(ctx any, secret any, jobID any, uses any) *RunnerService_ResolveActions_Call { | |
| 459 | + | return &RunnerService_ResolveActions_Call{Call: _e.mock.On("ResolveActions", ctx, secret, jobID, uses)} | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (_c *RunnerService_ResolveActions_Call) Run(run func(ctx context.Context, secret string, jobID string, uses []string)) *RunnerService_ResolveActions_Call { | |
| 463 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 464 | + | var arg0 context.Context | |
| 465 | + | if args[0] != nil { | |
| 466 | + | arg0 = args[0].(context.Context) | |
| 467 | + | } | |
| 468 | + | var arg1 string | |
| 469 | + | if args[1] != nil { | |
| 470 | + | arg1 = args[1].(string) | |
| 471 | + | } | |
| 472 | + | var arg2 string | |
| 473 | + | if args[2] != nil { | |
| 474 | + | arg2 = args[2].(string) | |
| 475 | + | } | |
| 476 | + | var arg3 []string | |
| 477 | + | if args[3] != nil { | |
| 478 | + | arg3 = args[3].([]string) | |
| 479 | + | } | |
| 480 | + | run( | |
| 481 | + | arg0, | |
| 482 | + | arg1, | |
| 483 | + | arg2, | |
| 484 | + | arg3, | |
| 485 | + | ) | |
| 486 | + | }) | |
| 487 | + | return _c | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (_c *RunnerService_ResolveActions_Call) Return(resolvedActions []ports.ResolvedAction, err error) *RunnerService_ResolveActions_Call { | |
| 491 | + | _c.Call.Return(resolvedActions, err) | |
| 492 | + | return _c | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func (_c *RunnerService_ResolveActions_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error)) *RunnerService_ResolveActions_Call { | |
| 496 | + | _c.Call.Return(run) | |
| 497 | + | return _c | |
| 498 | + | } | |
| 499 | + | ||
| 420 | 500 | // UpdateJob provides a mock function for the type RunnerService | |
| 421 | 501 | func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error { | |
| 422 | 502 | ret := _mock.Called(ctx, secret, jobID, steps) | |
M internal/ports/mocks/WorkflowTrigger.go +58 -0
| @@ -47,6 +47,64 @@ func (_m *WorkflowTrigger) EXPECT() *WorkflowTrigger_Expecter { | |||
| 47 | 47 | return &WorkflowTrigger_Expecter{mock: &_m.Mock} | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | // OnMerge provides a mock function for the type WorkflowTrigger | |
| 51 | + | func (_mock *WorkflowTrigger) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) { | |
| 52 | + | _mock.Called(ctx, repo, updates, merger) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // WorkflowTrigger_OnMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerge' | |
| 57 | + | type WorkflowTrigger_OnMerge_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // OnMerge is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - repo domain.RepoName | |
| 64 | + | // - updates []domain.RefUpdate | |
| 65 | + | // - merger domain.Principal | |
| 66 | + | func (_e *WorkflowTrigger_Expecter) OnMerge(ctx any, repo any, updates any, merger any) *WorkflowTrigger_OnMerge_Call { | |
| 67 | + | return &WorkflowTrigger_OnMerge_Call{Call: _e.mock.On("OnMerge", ctx, repo, updates, merger)} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *WorkflowTrigger_OnMerge_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal)) *WorkflowTrigger_OnMerge_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_OnMerge_Call) Return() *WorkflowTrigger_OnMerge_Call { | |
| 99 | + | _c.Call.Return() | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *WorkflowTrigger_OnMerge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal)) *WorkflowTrigger_OnMerge_Call { | |
| 104 | + | _c.Run(run) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 50 | 108 | // OnPush provides a mock function for the type WorkflowTrigger | |
| 51 | 109 | func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) { | |
| 52 | 110 | _mock.Called(ctx, repo, updates, pusher) | |
M internal/ports/ports.go +8 -0
| @@ -200,6 +200,14 @@ type GitBackend interface { | |||
| 200 | 200 | HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error) | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | + | // GitArchive writes the archive of a commit. | |
| 204 | + | type GitArchive interface { | |
| 205 | + | // Archive writes the tree of the commit as a gzip-compressed tar to w, all | |
| 206 | + | // the paths below the directory prefix. It writes nothing and returns | |
| 207 | + | // domain.ErrNotFound when the repository or the commit does not exist. | |
| 208 | + | Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error | |
| 209 | + | } | |
| 210 | + | ||
| 203 | 211 | // TokenStore keeps the stored tokens and the index from secret hash to token. | |
| 204 | 212 | // The secret itself is never stored. | |
| 205 | 213 | type TokenStore interface { | |
M internal/ports/services.go +23 -0
| @@ -55,6 +55,29 @@ type TokenService interface { | |||
| 55 | 55 | Revoke(ctx context.Context, p domain.Principal, id string) error | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | // GitHubAPIService is the subset of GitHub's REST API that ermite serves under | |
| 59 | + | // /-/github/api, for the actions that run on it (ADR 0025, CI-2). owner is the | |
| 60 | + | // owner of the URL: only ERMITE_OWNER names repositories, any other owner is | |
| 61 | + | // domain.ErrNotFound, as is a repository the principal may not read. | |
| 62 | + | // *usecase.CI implements it. | |
| 63 | + | type GitHubAPIService interface { | |
| 64 | + | // GetRepo returns the JSON of GET /repos/{owner}/{repo}. | |
| 65 | + | GetRepo(ctx context.Context, p domain.Principal, owner, repo string) ([]byte, error) | |
| 66 | + | // HashAlgorithm returns the object format of the repository, "sha1". | |
| 67 | + | HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error) | |
| 68 | + | // Tarball resolves ref (a branch, a tag or a commit id; empty means the | |
| 69 | + | // default branch) and writes the gzip-compressed tar of the commit to the | |
| 70 | + | // writer that begin returns; the archive token of an action (see | |
| 71 | + | // domain.RepoTokenPrincipal) gets the commit it was issued for and no other. | |
| 72 | + | // begin is called once, with the file name of the | |
| 73 | + | // archive, before the first byte and not at all when the ref does not | |
| 74 | + | // resolve (domain.ErrNotFound). | |
| 75 | + | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | |
| 76 | + | // TarballName is Tarball for a HEAD request: the same access and the same | |
| 77 | + | // resolution of ref, then the file name, without producing the archive. | |
| 78 | + | TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error) | |
| 79 | + | } | |
| 80 | + | ||
| 58 | 81 | // BrowseService is the read side of a repository's content. *usecase.Browse | |
| 59 | 82 | // implements it. | |
| 60 | 83 | type BrowseService interface { | |
M internal/usecase/access.go +6 -0
| @@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool { | |||
| 20 | 20 | // canReadAs is canRead for a repository known by its name and visibility only, | |
| 21 | 21 | // such as one that no longer exists. | |
| 22 | 22 | func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool { | |
| 23 | + | if _, _, archive := p.ArchiveGrant(); archive { | |
| 24 | + | // The holder of an archive token downloads one commit through the | |
| 25 | + | // archive endpoint, which checks its grant; everywhere else it reads | |
| 26 | + | // nothing, not even a public repository (domain.RepoTokenPrincipal). | |
| 27 | + | return false | |
| 28 | + | } | |
| 23 | 29 | if vis == domain.VisibilityPublic { | |
| 24 | 30 | return true | |
| 25 | 31 | } | |
M internal/usecase/ci.go +54 -15
| @@ -29,6 +29,11 @@ const ( | |||
| 29 | 29 | DefaultCIKeepalive = 30 * time.Second | |
| 30 | 30 | // DefaultCITriggerQueue is the size of the queue of pushes to plan. | |
| 31 | 31 | DefaultCITriggerQueue = 256 | |
| 32 | + | // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions | |
| 33 | + | // runs at once, whatever the number of jobs that resolve. | |
| 34 | + | DefaultCIRemoteConcurrency = 4 | |
| 35 | + | // DefaultCIResolveTimeout bounds one ResolveActions call. | |
| 36 | + | DefaultCIResolveTimeout = 60 * time.Second | |
| 32 | 37 | // MaxCIChecks is the most checks ListChecks returns. | |
| 33 | 38 | MaxCIChecks = 1000 | |
| 34 | 39 | // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log | |
| @@ -56,6 +61,20 @@ type CIConfig struct { | |||
| 56 | 61 | Clock ports.Clock | |
| 57 | 62 | // Auth re-validates the token of a log stream; nil skips it. | |
| 58 | 63 | Auth ports.Revalidator | |
| 64 | + | // Remote lists the refs of github.com repositories for ResolveActions; nil | |
| 65 | + | // answers every github.com action with unavailable. | |
| 66 | + | Remote ports.GitRemote | |
| 67 | + | // Archive serves the tarballs of the REST subset. | |
| 68 | + | Archive ports.GitArchive | |
| 69 | + | // Tokens finds the stored token of the pusher of a run, to ask what it may | |
| 70 | + | // read when an action of another repository is resolved for the run. Nil | |
| 71 | + | // reads nothing for a token (fail closed). | |
| 72 | + | Tokens ports.TokenStore | |
| 73 | + | // RemoteConcurrency bounds the git ls-remote processes at once (default | |
| 74 | + | // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call | |
| 75 | + | // (default DefaultCIResolveTimeout). | |
| 76 | + | RemoteConcurrency int | |
| 77 | + | ResolveTimeout time.Duration | |
| 59 | 78 | // Owner is the owner login of the github context. | |
| 60 | 79 | Owner string | |
| 61 | 80 | // PublicURL is the base URL of the instance (github.server_url). | |
| @@ -90,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig { | |||
| 90 | 109 | def(&c.TimeoutSlack, domain.CITimeoutSlack) | |
| 91 | 110 | def(&c.QueueTimeout, domain.CIQueueTimeout) | |
| 92 | 111 | def(&c.Retention, domain.CIRetentionDays*24*time.Hour) | |
| 112 | + | if c.RemoteConcurrency <= 0 { | |
| 113 | + | c.RemoteConcurrency = DefaultCIRemoteConcurrency | |
| 114 | + | } | |
| 115 | + | def(&c.ResolveTimeout, DefaultCIResolveTimeout) | |
| 93 | 116 | if c.TriggerQueue <= 0 { | |
| 94 | 117 | c.TriggerQueue = DefaultCITriggerQueue | |
| 95 | 118 | } | |
| @@ -112,19 +135,25 @@ type CI struct { | |||
| 112 | 135 | ctx context.Context | |
| 113 | 136 | wg sync.WaitGroup | |
| 114 | 137 | ||
| 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 | |
| 138 | + | remoteSlots chan struct{} // slots of the git ls-remote processes | |
| 139 | + | locks sync.Map // run id -> *sync.Mutex | |
| 140 | + | assign sync.Mutex | |
| 141 | + | jobs notifier // woken when a job changes: key is the job reference | |
| 142 | + | pending notifier // woken when a job may be ready or a runner was removed | |
| 143 | + | flows *wfCache | |
| 120 | 144 | ||
| 121 | 145 | mu sync.Mutex | |
| 122 | 146 | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 123 | 147 | seen map[string]time.Time // runner id -> last seen | |
| 124 | 148 | polls map[string]pollRecord | |
| 125 | - | streams int | |
| 126 | - | perClient map[string]int | |
| 127 | - | closed bool | |
| 149 | + | // archiveTokens are the secrets of the tokens given for the archives of | |
| 150 | + | // ermite actions, per job reference then "<repository>@<sha>", so that a job | |
| 151 | + | // that resolves the same commit twice gets one token (memory only: a | |
| 152 | + | // restart issues new ones). | |
| 153 | + | archiveTokens map[string]map[string]string | |
| 154 | + | streams int | |
| 155 | + | perClient map[string]int | |
| 156 | + | closed bool | |
| 128 | 157 | } | |
| 129 | 158 | ||
| 130 | 159 | var ( | |
| @@ -137,10 +166,11 @@ var ( | |||
| 137 | 166 | // pollRecord remembers the assignment a poll made, for the retry of a lost | |
| 138 | 167 | // response (FetchJobRequest.poll_id). The token is kept in memory only. | |
| 139 | 168 | type pollRecord struct { | |
| 140 | - | pollID string | |
| 141 | - | ref string | |
| 142 | - | token string | |
| 143 | - | hash domain.TokenHash | |
| 169 | + | pollID string | |
| 170 | + | ref string | |
| 171 | + | token string | |
| 172 | + | ghToken string | |
| 173 | + | hash domain.TokenHash | |
| 144 | 174 | } | |
| 145 | 175 | ||
| 146 | 176 | // NewCI builds the CI use cases. ctx carries the logger; its cancellation is | |
| @@ -153,9 +183,10 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 153 | 183 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 154 | 184 | return &CI{ | |
| 155 | 185 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | |
| 156 | - | ctx: run, stop: stop, flows: newWfCache(64), | |
| 186 | + | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), | |
| 157 | 187 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| 158 | - | perClient: map[string]int{}, | |
| 188 | + | archiveTokens: map[string]map[string]string{}, | |
| 189 | + | perClient: map[string]int{}, | |
| 159 | 190 | } | |
| 160 | 191 | } | |
| 161 | 192 | ||
| @@ -258,6 +289,12 @@ type runState struct { | |||
| 258 | 289 | // chk marks jobs whose check must be written even when the job itself did | |
| 259 | 290 | // not change (a rerun moves every check to the new attempt). | |
| 260 | 291 | checks map[string]bool | |
| 292 | + | // tokens are the job repository tokens to create, revoke the jobs whose | |
| 293 | + | // tokens are deleted, with the next flush. | |
| 294 | + | tokens []domain.CIRepoToken | |
| 295 | + | revoke []ports.CIJobRef | |
| 296 | + | // resolutions are the action resolutions to keep with the next flush. | |
| 297 | + | resolutions []domain.CIActionResolution | |
| 261 | 298 | } | |
| 262 | 299 | ||
| 263 | 300 | // withRun locks the run, loads it with the jobs of attempt (0 for the latest) | |
| @@ -341,7 +378,8 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 341 | 378 | if rs.runDirty { | |
| 342 | 379 | w.Runs = []domain.CIRun{rs.run} | |
| 343 | 380 | } | |
| 344 | - | if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 { | |
| 381 | + | w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions | |
| 382 | + | if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 && len(w.Resolutions) == 0 { | |
| 345 | 383 | return nil | |
| 346 | 384 | } | |
| 347 | 385 | v, err := c.cfg.Store.Apply(ctx, w) | |
| @@ -356,6 +394,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 356 | 394 | } | |
| 357 | 395 | changedRun := rs.runDirty | |
| 358 | 396 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | |
| 397 | + | rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil | |
| 359 | 398 | ||
| 360 | 399 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | |
| 361 | 400 | if err != nil { | |
A internal/usecase/ci_actions.go +500 -0
| @@ -0,0 +1,500 @@ | |||
| 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 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ResolveActions turns the "uses" of a job into where to download the actions | |
| 17 | + | // (ADR 0025, CI-2), as GitHub's action download info does: ermite resolves, the | |
| 18 | + | // runner downloads from the sandbox. | |
| 19 | + | // | |
| 20 | + | // - owner = ERMITE_OWNER and the repository exists on ermite: ermite wins (as | |
| 21 | + | // the actions of a GitHub Enterprise Server do). The ref is a tag, else a | |
| 22 | + | // branch, else a full commit id that exists; the archive is served by the | |
| 23 | + | // REST subset and downloaded with a read token for that repository only. | |
| 24 | + | // - anything else: github.com. The ref is resolved with `git ls-remote` (tag, | |
| 25 | + | // then branch, the peeled commit of an annotated tag); a full commit id is | |
| 26 | + | // returned as it is, without checking. The archive is codeload's, with no | |
| 27 | + | // token. | |
| 28 | + | // | |
| 29 | + | // The commit a ref resolves to is kept for the run attempt: every job and every | |
| 30 | + | // call of the attempt gets the same one, the first that was stored. A | |
| 31 | + | // reference that cannot be resolved is not an error of the call: its answer | |
| 32 | + | // carries GitHub's message. | |
| 33 | + | func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |
| 34 | + | out, err := c.resolveActions(ctx, secret, jobID, uses) | |
| 35 | + | if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) { | |
| 36 | + | // Our own deadline, not the caller's: github.com (or the disk) was too | |
| 37 | + | // slow. The runner may retry, or fail the job. | |
| 38 | + | return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout) | |
| 39 | + | } | |
| 40 | + | return out, err | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |
| 44 | + | ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout) | |
| 45 | + | defer cancel() | |
| 46 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | if j.Ended() { | |
| 51 | + | return nil, endedError(j) | |
| 52 | + | } | |
| 53 | + | if len(uses) > maxActionRefs { | |
| 54 | + | return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs) | |
| 55 | + | } | |
| 56 | + | parsed := make([]actionRef, len(uses)) | |
| 57 | + | for i, u := range uses { | |
| 58 | + | if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") { | |
| 59 | + | return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u) | |
| 60 | + | } | |
| 61 | + | if strings.HasPrefix(u, "docker://") { | |
| 62 | + | return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u) | |
| 63 | + | } | |
| 64 | + | parsed[i] = parseActionRef(u) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | src, err := c.cfg.Store.GetRunSource(ctx, j.RunID) | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | by := c.onBehalfOf(src) | |
| 72 | + | ||
| 73 | + | // Resolve what the attempt has not resolved yet, outside the lock of the | |
| 74 | + | // run: a ls-remote takes seconds and must not hold up the job's calls. | |
| 75 | + | found := map[string]domain.CIActionResolution{} | |
| 76 | + | fails := map[string]string{} | |
| 77 | + | remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots) | |
| 78 | + | for _, p := range parsed { | |
| 79 | + | if p.err != "" { | |
| 80 | + | continue | |
| 81 | + | } | |
| 82 | + | if _, done := found[p.key()]; done { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | if _, bad := fails[p.key()]; bad { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil { | |
| 89 | + | found[p.key()] = res | |
| 90 | + | continue | |
| 91 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | res, msg, err := c.resolveAction(ctx, j, p, remote, by) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | if msg != "" { | |
| 99 | + | fails[p.key()] = msg | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | found[p.key()] = res | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | out := make([]ports.ResolvedAction, len(parsed)) | |
| 106 | + | err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 107 | + | if cur.Ended() { | |
| 108 | + | return endedError(*cur) | |
| 109 | + | } | |
| 110 | + | rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt) | |
| 111 | + | if err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | own := rs.find(cur.ID) | |
| 115 | + | if own == nil { | |
| 116 | + | return domain.ErrNotFound | |
| 117 | + | } | |
| 118 | + | for i, p := range parsed { | |
| 119 | + | out[i] = ports.ResolvedAction{Ref: p.uses} | |
| 120 | + | if p.err != "" { | |
| 121 | + | out[i].Error = p.err | |
| 122 | + | continue | |
| 123 | + | } | |
| 124 | + | if msg, bad := fails[p.key()]; bad { | |
| 125 | + | out[i].Error = msg | |
| 126 | + | continue | |
| 127 | + | } | |
| 128 | + | // The first resolution stored wins, ours or a concurrent job's. | |
| 129 | + | res, ok := found[p.key()] | |
| 130 | + | if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil { | |
| 131 | + | res, ok = cached, true | |
| 132 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 133 | + | return err | |
| 134 | + | } else if ok { | |
| 135 | + | rs.resolutions = append(rs.resolutions, res) | |
| 136 | + | found[p.key()] = res // two refs of the call with the same key | |
| 137 | + | } | |
| 138 | + | if !ok { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | ra, err := c.answer(ctx, rs, own, p, res, by) | |
| 142 | + | if err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | out[i] = ra | |
| 146 | + | } | |
| 147 | + | if err := rs.flush(ctx); err != nil { | |
| 148 | + | // The tokens were not stored: forget their secrets. | |
| 149 | + | c.mu.Lock() | |
| 150 | + | delete(c.archiveTokens, jobRef(*cur)) | |
| 151 | + | c.mu.Unlock() | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | return nil | |
| 155 | + | }) | |
| 156 | + | if err != nil { | |
| 157 | + | return nil, err | |
| 158 | + | } | |
| 159 | + | return out, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // maxActionRefs is the most references of one ResolveActions call. | |
| 163 | + | const maxActionRefs = 100 | |
| 164 | + | ||
| 165 | + | // answer builds the answer for a resolved reference. The archive token of an | |
| 166 | + | // ermite action is bound to the commit of the answer: the one the job already | |
| 167 | + | // holds for it, else a new one, written with the next flush. Whether the pusher | |
| 168 | + | // may read the repository is asked again at each answer, not only when the | |
| 169 | + | // commit was resolved: a token revoked since, or a repository made private, | |
| 170 | + | // stops the answers. | |
| 171 | + | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) { | |
| 172 | + | ra := ports.ResolvedAction{ | |
| 173 | + | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | |
| 174 | + | } | |
| 175 | + | notFound := ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)} | |
| 176 | + | ||
| 177 | + | // A repository of this server that the pusher cannot read is never served | |
| 178 | + | // from anywhere else: not from github.com under the same name, whatever | |
| 179 | + | // source an earlier resolution of the attempt stored. It answers as a | |
| 180 | + | // repository that does not exist. (When it does not exist on this server, a | |
| 181 | + | // stored ermite resolution has nothing to serve either.) | |
| 182 | + | repo, onErmite, err := c.ermiteRepoOf(ctx, p) | |
| 183 | + | if err != nil { | |
| 184 | + | return ports.ResolvedAction{}, err | |
| 185 | + | } | |
| 186 | + | if onErmite { | |
| 187 | + | if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil { | |
| 188 | + | return ports.ResolvedAction{}, err | |
| 189 | + | } else if !ok { | |
| 190 | + | return notFound, nil | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | switch res.Source { | |
| 194 | + | case domain.CIActionGitHub: | |
| 195 | + | // The case the workflow wrote: the stored resolution is shared by | |
| 196 | + | // spellings that differ only by case, and github.com ignores it. | |
| 197 | + | ra.NameWithOwner = p.owner + "/" + p.repo | |
| 198 | + | ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA | |
| 199 | + | case domain.CIActionErmite: | |
| 200 | + | if !onErmite { | |
| 201 | + | return notFound, nil | |
| 202 | + | } | |
| 203 | + | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | |
| 204 | + | ra.Token = c.archiveToken(rs, j, repo.Name, res.ResolvedSHA) | |
| 205 | + | } | |
| 206 | + | return ra, nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // archiveToken returns the secret of the token of the job for the archive of | |
| 210 | + | // the commit sha of repo, issuing it when the job has none. The token downloads | |
| 211 | + | // that archive and nothing else (domain.RepoTokenPrincipal). | |
| 212 | + | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string { | |
| 213 | + | ref, key := jobRef(*j), repo.String()+"@"+sha | |
| 214 | + | c.mu.Lock() | |
| 215 | + | tok, ok := c.archiveTokens[ref][key] | |
| 216 | + | c.mu.Unlock() | |
| 217 | + | if ok { | |
| 218 | + | return tok | |
| 219 | + | } | |
| 220 | + | tok = rs.issueRepoTokenFor(j, repo, sha) | |
| 221 | + | c.mu.Lock() | |
| 222 | + | if c.archiveTokens[ref] == nil { | |
| 223 | + | c.archiveTokens[ref] = map[string]string{} | |
| 224 | + | } | |
| 225 | + | c.archiveTokens[ref][key] = tok | |
| 226 | + | c.mu.Unlock() | |
| 227 | + | return tok | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // ermiteRepoOf finds the repository of this server that a reference names: the | |
| 231 | + | // owner is ERMITE_OWNER (without regard to case) and the repository exists. ok | |
| 232 | + | // is false for any other reference, which github.com serves. It is the one | |
| 233 | + | // place that decides the source of an action, for a first resolution and for the | |
| 234 | + | // answer from a stored one. | |
| 235 | + | func (c *CI) ermiteRepoOf(ctx context.Context, a actionRef) (repo domain.Repo, ok bool, err error) { | |
| 236 | + | if !domain.OwnerMatches(c.cfg.Owner, a.owner) { | |
| 237 | + | return domain.Repo{}, false, nil | |
| 238 | + | } | |
| 239 | + | name, perr := domain.ParseRepoName(strings.ToLower(a.repo)) | |
| 240 | + | if perr != nil { | |
| 241 | + | return domain.Repo{}, false, nil // not a name ermite can hold | |
| 242 | + | } | |
| 243 | + | repo, err = c.cfg.Meta.Get(ctx, name) | |
| 244 | + | switch { | |
| 245 | + | case errors.Is(err, domain.ErrNotFound): | |
| 246 | + | return domain.Repo{}, false, nil | |
| 247 | + | case err != nil: | |
| 248 | + | return domain.Repo{}, false, err | |
| 249 | + | } | |
| 250 | + | return repo, true, nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // pusherAccess is who the actions of a run are resolved on behalf of: the | |
| 254 | + | // pusher recorded with the run. | |
| 255 | + | type pusherAccess struct{ pusher domain.CIPusher } | |
| 256 | + | ||
| 257 | + | func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} } | |
| 258 | + | ||
| 259 | + | // pusherCanRead tells whether the pusher of a run may read repo, with the rule | |
| 260 | + | // of the forge (canRead) applied to the principal the pusher is now: the root | |
| 261 | + | // token and the owner's session read everything; a stored token is looked up | |
| 262 | + | // again, so a token revoked or expired since reads nothing, and its scopes and | |
| 263 | + | // repository list apply; a run that records no pusher reads what an anonymous | |
| 264 | + | // caller reads. The repository of the run itself needs no check: the job reads | |
| 265 | + | // it with its own token. | |
| 266 | + | func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) { | |
| 267 | + | if repo.Name == runRepo { | |
| 268 | + | return true, nil | |
| 269 | + | } | |
| 270 | + | var p domain.Principal | |
| 271 | + | switch by.pusher.Kind { | |
| 272 | + | case domain.CIPusherRoot: | |
| 273 | + | p = domain.Root() | |
| 274 | + | case domain.CIPusherOwner: | |
| 275 | + | p = domain.Owner() | |
| 276 | + | case domain.CIPusherToken: | |
| 277 | + | if c.cfg.Tokens == nil { | |
| 278 | + | return false, nil | |
| 279 | + | } | |
| 280 | + | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | |
| 281 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 282 | + | return false, nil | |
| 283 | + | } | |
| 284 | + | if err != nil { | |
| 285 | + | return false, err | |
| 286 | + | } | |
| 287 | + | if tok.Expired(c.now()) { | |
| 288 | + | return false, nil | |
| 289 | + | } | |
| 290 | + | p = domain.TokenPrincipal(tok) | |
| 291 | + | default: | |
| 292 | + | p = domain.Anonymous() | |
| 293 | + | } | |
| 294 | + | return canRead(p, repo), nil | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // actionRef is a parsed "uses": owner/repo[/path]@ref. | |
| 298 | + | type actionRef struct { | |
| 299 | + | uses, owner, repo, path, ref string | |
| 300 | + | // err is GitHub's message for a reference of the wrong shape. | |
| 301 | + | err string | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // key identifies the resolution: GitHub names are case-insensitive, refs are | |
| 305 | + | // not. | |
| 306 | + | func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref } | |
| 307 | + | ||
| 308 | + | // parseActionRef splits a "uses" of a repository action. Only the shape | |
| 309 | + | // owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and | |
| 310 | + | // ignores empty segments (WorkflowTemplateConverter.cs), so do we. | |
| 311 | + | func parseActionRef(uses string) actionRef { | |
| 312 | + | a := actionRef{uses: uses} | |
| 313 | + | left, ref, ok := strings.Cut(uses, "@") | |
| 314 | + | segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' }) | |
| 315 | + | if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" { | |
| 316 | + | a.err = errActionFormat(uses) | |
| 317 | + | return a | |
| 318 | + | } | |
| 319 | + | for _, s := range segs[2:] { | |
| 320 | + | if s == "." || s == ".." { | |
| 321 | + | a.err = errActionFormat(uses) | |
| 322 | + | return a | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | if !validActionName(segs[0]) || !validActionName(segs[1]) { | |
| 326 | + | a.err = errActionFormat(uses) | |
| 327 | + | return a | |
| 328 | + | } | |
| 329 | + | a.owner, a.repo, a.ref = segs[0], segs[1], ref | |
| 330 | + | a.path = strings.Join(segs[2:], "/") | |
| 331 | + | return a | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // validActionName reports whether s can be an owner or a repository name on | |
| 335 | + | // GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of | |
| 336 | + | // dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a | |
| 337 | + | // URL, a path or a command. | |
| 338 | + | func validActionName(s string) bool { | |
| 339 | + | if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" { | |
| 340 | + | return false | |
| 341 | + | } | |
| 342 | + | for _, r := range s { | |
| 343 | + | switch { | |
| 344 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 345 | + | default: | |
| 346 | + | return false | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return true | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // --- GitHub's texts --- | |
| 353 | + | // | |
| 354 | + | // All the messages a reference can fail with are here. The first is the | |
| 355 | + | // runner's own parser text; the two others are the ones GitHub gives for an | |
| 356 | + | // unknown version and an unknown repository, to be confirmed by recording | |
| 357 | + | // against github.com (ci2-execution plan, phase A). | |
| 358 | + | ||
| 359 | + | func errActionFormat(uses string) string { | |
| 360 | + | return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'" | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func errUnresolvedRef(a actionRef) string { | |
| 364 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`" | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func errUnresolvedRepo(a actionRef) string { | |
| 368 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found" | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // resolveAction resolves one reference. msg is the message of a reference that | |
| 372 | + | // does not resolve; err is a failure of the call. | |
| 373 | + | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) { | |
| 374 | + | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | |
| 375 | + | repo, onErmite, err := c.ermiteRepoOf(ctx, a) | |
| 376 | + | if err != nil { | |
| 377 | + | return res, "", err | |
| 378 | + | } | |
| 379 | + | if onErmite { | |
| 380 | + | // The repository exists here, so github.com never answers for it. A | |
| 381 | + | // repository the pusher may not read answers as one that does not exist, | |
| 382 | + | // before any ref is looked at, so that the texts leak nothing about it. | |
| 383 | + | if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil { | |
| 384 | + | return res, "", err | |
| 385 | + | } else if !ok { | |
| 386 | + | return res, errUnresolvedRepo(a), nil | |
| 387 | + | } | |
| 388 | + | sha, found, err := c.resolveOnErmite(ctx, repo.Name, a.ref) | |
| 389 | + | if err != nil { | |
| 390 | + | return res, "", err | |
| 391 | + | } | |
| 392 | + | if !found { | |
| 393 | + | return res, errUnresolvedRef(a), nil | |
| 394 | + | } | |
| 395 | + | res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+repo.Name.String(), sha, domain.CIActionErmite | |
| 396 | + | return res, "", nil | |
| 397 | + | } | |
| 398 | + | res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub | |
| 399 | + | if low := strings.ToLower(a.ref); isHex40(low) { | |
| 400 | + | res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download | |
| 401 | + | return res, "", nil | |
| 402 | + | } | |
| 403 | + | refs, err := remote.list(ctx, a.owner, a.repo) | |
| 404 | + | switch { | |
| 405 | + | case errors.Is(err, domain.ErrNotFound): | |
| 406 | + | return res, errUnresolvedRepo(a), nil | |
| 407 | + | case err != nil: | |
| 408 | + | return res, "", err | |
| 409 | + | } | |
| 410 | + | sha := pickRemoteRef(refs, a.ref) | |
| 411 | + | if sha == "" { | |
| 412 | + | return res, errUnresolvedRef(a), nil | |
| 413 | + | } | |
| 414 | + | res.ResolvedSHA = sha | |
| 415 | + | return res, "", nil | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a | |
| 419 | + | // remote. An annotated tag gives its peeled commit. | |
| 420 | + | func pickRemoteRef(refs []ports.RemoteRef, name string) string { | |
| 421 | + | for _, prefix := range []string{"refs/tags/", "refs/heads/"} { | |
| 422 | + | for _, r := range refs { | |
| 423 | + | if r.Name != prefix+name { | |
| 424 | + | continue | |
| 425 | + | } | |
| 426 | + | if r.Peeled != "" { | |
| 427 | + | return r.Peeled | |
| 428 | + | } | |
| 429 | + | return r.ID | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | return "" | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // resolveOnErmite resolves a ref on a repository of this server: a tag, else | |
| 436 | + | // a branch, else a full commit id that exists. | |
| 437 | + | func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) { | |
| 438 | + | refs, err := c.cfg.Reader.ListRefs(ctx, repo) | |
| 439 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 440 | + | return "", false, err | |
| 441 | + | } | |
| 442 | + | for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} { | |
| 443 | + | i := slices.IndexFunc(refs, func(r domain.Ref) bool { | |
| 444 | + | return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero() | |
| 445 | + | }) | |
| 446 | + | if i >= 0 { | |
| 447 | + | return refs[i].CommitID.String(), true, nil | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | if id, perr := domain.ParseObjectID(ref); perr == nil { | |
| 451 | + | ok, err := c.cfg.Git.HasCommit(ctx, repo, id) | |
| 452 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 453 | + | return "", false, err | |
| 454 | + | } | |
| 455 | + | if ok { | |
| 456 | + | return id.String(), true, nil | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | return "", false, nil | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // remoteRefs lists the refs of a remote once per repository within a call. | |
| 463 | + | type remoteRefs struct { | |
| 464 | + | remote ports.GitRemote | |
| 465 | + | slots chan struct{} | |
| 466 | + | seen map[string]remoteList | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | type remoteList struct { | |
| 470 | + | refs []ports.RemoteRef | |
| 471 | + | err error | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs { | |
| 475 | + | return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}} | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 479 | + | if r.remote == nil { | |
| 480 | + | return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable) | |
| 481 | + | } | |
| 482 | + | key := strings.ToLower(owner + "/" + repo) | |
| 483 | + | if l, ok := r.seen[key]; ok { | |
| 484 | + | return l.refs, l.err | |
| 485 | + | } | |
| 486 | + | // At most a few ls-remote at once on the whole server, whatever the number | |
| 487 | + | // of jobs resolving: a process each, and github.com is slow to answer. | |
| 488 | + | select { | |
| 489 | + | case r.slots <- struct{}{}: | |
| 490 | + | defer func() { <-r.slots }() | |
| 491 | + | case <-ctx.Done(): | |
| 492 | + | return nil, ctx.Err() | |
| 493 | + | } | |
| 494 | + | refs, err := r.remote.ListRefs(ctx, owner, repo) | |
| 495 | + | if ctx.Err() != nil { | |
| 496 | + | return nil, ctx.Err() | |
| 497 | + | } | |
| 498 | + | r.seen[key] = remoteList{refs, err} | |
| 499 | + | return refs, err | |
| 500 | + | } | |
A internal/usecase/ci_actions_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestParseActionRef(t *testing.T) { | |
| 15 | + | t.Parallel() | |
| 16 | + | tests := []struct { | |
| 17 | + | uses string | |
| 18 | + | owner, repo, path, ref, fail string | |
| 19 | + | }{ | |
| 20 | + | {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"}, | |
| 21 | + | {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"}, | |
| 22 | + | {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"}, | |
| 23 | + | {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators | |
| 24 | + | {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments | |
| 25 | + | {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"}, | |
| 26 | + | {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"}, | |
| 27 | + | {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"}, | |
| 28 | + | {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"}, | |
| 29 | + | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | |
| 30 | + | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | |
| 31 | + | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | |
| 32 | + | {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"}, | |
| 33 | + | {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"}, | |
| 34 | + | {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"}, | |
| 35 | + | {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"}, | |
| 36 | + | {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"}, | |
| 37 | + | {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"}, | |
| 38 | + | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.uses, func(t *testing.T) { | |
| 42 | + | t.Parallel() | |
| 43 | + | a := parseActionRef(tt.uses) | |
| 44 | + | assert.Equal(t, tt.fail, a.err) | |
| 45 | + | if tt.fail == "" { | |
| 46 | + | assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref}) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive") | |
| 51 | + | assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestPickRemoteRef(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | refs := []ports.RemoteRef{ | |
| 57 | + | {Name: "refs/heads/v1", ID: "b1"}, | |
| 58 | + | {Name: "refs/tags/v1", ID: "t1"}, | |
| 59 | + | {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"}, | |
| 60 | + | {Name: "refs/heads/main", ID: "m"}, | |
| 61 | + | } | |
| 62 | + | assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch") | |
| 63 | + | assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag") | |
| 64 | + | assert.Equal(t, "m", pickRemoteRef(refs, "main")) | |
| 65 | + | assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only") | |
| 66 | + | assert.Empty(t, pickRemoteRef(refs, "nope")) | |
| 67 | + | assert.Empty(t, pickRemoteRef(nil, "v1")) | |
| 68 | + | } | |
A internal/usecase/ci_githubapi.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // The REST subset (ADR 0025, CI-2): what the actions that run on ermite call | |
| 17 | + | // on github.api_url. The access rule is the forge's: a repository is read by | |
| 18 | + | // anyone when public, and with repo:read when private, which the principal of a | |
| 19 | + | // job repository token holds on the repository of its run (and the archive | |
| 20 | + | // tokens on theirs). The holder of an archive token reads none of it but its | |
| 21 | + | // own archive. | |
| 22 | + | ||
| 23 | + | var _ ports.GitHubAPIService = (*CI)(nil) | |
| 24 | + | ||
| 25 | + | // githubRepo looks the repository up as GitHub names it, owner/repo. Another | |
| 26 | + | // owner, a name that is not one and a repository the caller cannot read are all | |
| 27 | + | // not found. | |
| 28 | + | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | |
| 29 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { | |
| 30 | + | return domain.Repo{}, domain.ErrNotFound | |
| 31 | + | } | |
| 32 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 33 | + | if err != nil { | |
| 34 | + | return domain.Repo{}, domain.ErrNotFound | |
| 35 | + | } | |
| 36 | + | return c.readableRepo(ctx, p, rn) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // GetRepo returns GET /repos/{owner}/{repo}. | |
| 40 | + | // | |
| 41 | + | // The object is the repository of the push event (repoObj: real values where | |
| 42 | + | // ermite has them, GitHub's for a repository without the feature elsewhere) | |
| 43 | + | // with the REST differences: dates are ISO 8601 strings, and the permissions of | |
| 44 | + | // the caller are given, as GitHub does for an authenticated request. | |
| 45 | + | func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) { | |
| 46 | + | repo, err := c.githubRepo(ctx, p, owner, name) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name) | |
| 51 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 52 | + | return nil, domain.ErrNotFound | |
| 53 | + | } | |
| 54 | + | if err != nil { | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | if branch == "" { | |
| 58 | + | branch = "main" | |
| 59 | + | } | |
| 60 | + | u := c.urls(repo) | |
| 61 | + | created, pushed := repo.CreatedAt, repo.PushedAt | |
| 62 | + | obj := c.repoObj(repo, u, branch, created) | |
| 63 | + | obj["created_at"] = created.UTC().Format(timeLayout) | |
| 64 | + | if pushed.IsZero() { | |
| 65 | + | pushed = created | |
| 66 | + | } | |
| 67 | + | obj["pushed_at"] = pushed.UTC().Format(timeLayout) | |
| 68 | + | obj["updated_at"] = pushed.UTC().Format(timeLayout) | |
| 69 | + | delete(obj, "master_branch") // push event only | |
| 70 | + | delete(obj, "stargazers") | |
| 71 | + | delete(obj, "watchers") | |
| 72 | + | delete(obj, "forks") | |
| 73 | + | delete(obj, "open_issues") | |
| 74 | + | for k, v := range map[string]any{ | |
| 75 | + | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | |
| 76 | + | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | |
| 77 | + | "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, | |
| 78 | + | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | |
| 79 | + | } { | |
| 80 | + | obj[k] = v | |
| 81 | + | } | |
| 82 | + | if !p.IsAnonymous() { | |
| 83 | + | write := canWrite(p, repo.Name) | |
| 84 | + | obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true} | |
| 85 | + | } | |
| 86 | + | return json.Marshal(obj) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // timeLayout is the ISO 8601 form of GitHub's REST dates. | |
| 90 | + | const timeLayout = "2006-01-02T15:04:05Z" | |
| 91 | + | ||
| 92 | + | // HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks | |
| 93 | + | // it before it runs git init. ermite repositories are SHA-1. | |
| 94 | + | func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) { | |
| 95 | + | if _, err := c.githubRepo(ctx, p, owner, name); err != nil { | |
| 96 | + | return "", err | |
| 97 | + | } | |
| 98 | + | return "sha1", nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // archiveTarget finds the repository and the commit of an archive request and | |
| 102 | + | // checks the access to it. A reader of the repository (the job's own token, any | |
| 103 | + | // principal with repo:read, anyone for a public one) gets any ref. The holder of | |
| 104 | + | // an archive token (domain.RepoTokenPrincipal) gets its commit of its | |
| 105 | + | // repository and nothing else: the ref may be anything that resolves to exactly | |
| 106 | + | // that commit, and every other answer is not found. | |
| 107 | + | func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) { | |
| 108 | + | var repo domain.Repo | |
| 109 | + | grantRepo, grantSha, granted := p.ArchiveGrant() | |
| 110 | + | if granted { | |
| 111 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { | |
| 112 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 113 | + | } | |
| 114 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 115 | + | if err != nil || rn != grantRepo { | |
| 116 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 117 | + | } | |
| 118 | + | if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil { | |
| 119 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 120 | + | } | |
| 121 | + | } else { | |
| 122 | + | var err error | |
| 123 | + | if repo, err = c.githubRepo(ctx, p, owner, name); err != nil { | |
| 124 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | rev, err := domain.ParseRevision(ref) | |
| 128 | + | if err != nil { | |
| 129 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 130 | + | } | |
| 131 | + | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | |
| 132 | + | if err != nil { | |
| 133 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 134 | + | } | |
| 135 | + | if granted && res.CommitID.String() != grantSha { | |
| 136 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 137 | + | } | |
| 138 | + | return repo, res.CommitID, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string { | |
| 142 | + | return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7] | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // TarballName answers a HEAD of the archive: the access and the ref are | |
| 146 | + | // checked as for the archive, and the name of the file is returned, without | |
| 147 | + | // running git archive. | |
| 148 | + | func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) { | |
| 149 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |
| 150 | + | if err != nil { | |
| 151 | + | return "", err | |
| 152 | + | } | |
| 153 | + | return c.archiveDir(repo, commit) + ".tar.gz", nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Tarball streams the archive of a ref as `git archive` makes it, in one | |
| 157 | + | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |
| 158 | + | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |
| 159 | + | // GitHub names it. | |
| 160 | + | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |
| 161 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |
| 162 | + | if err != nil { | |
| 163 | + | return err | |
| 164 | + | } | |
| 165 | + | dir := c.archiveDir(repo, commit) | |
| 166 | + | return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // lazyWriter calls begin at the first write, so that nothing is sent before git | |
| 170 | + | // produced a byte. | |
| 171 | + | type lazyWriter struct { | |
| 172 | + | begin func() io.Writer | |
| 173 | + | w io.Writer | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (l *lazyWriter) Write(b []byte) (int, error) { | |
| 177 | + | if l.w == nil { | |
| 178 | + | l.w = l.begin() | |
| 179 | + | } | |
| 180 | + | return l.w.Write(b) | |
| 181 | + | } | |
M internal/usecase/ci_plan.go +5 -17
| @@ -52,32 +52,19 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error | |||
| 52 | 52 | return wf, nil | |
| 53 | 53 | } | |
| 54 | 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). | |
| 55 | + | // unsupported names the feature of a job that ermite does not run yet, or "". | |
| 56 | + | // Such a job fails with "not supported yet: <feature>" instead of running | |
| 57 | + | // wrong. CI-2 runs steps with uses:, job container:, services: and step | |
| 58 | + | // timeout-minutes (ADR 0025); what stays is CI-3. | |
| 58 | 59 | func unsupported(j *workflow.Job) string { | |
| 59 | 60 | switch { | |
| 60 | 61 | case j.IsReusable(): | |
| 61 | 62 | 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 | 63 | case j.Environment != nil: | |
| 67 | 64 | return "environment" | |
| 68 | 65 | case j.Concurrency != nil: | |
| 69 | 66 | return "concurrency" | |
| 70 | 67 | } | |
| 71 | - | for _, s := range j.Steps { | |
| 72 | - | switch { | |
| 73 | - | case s.Uses != nil: | |
| 74 | - | return "uses: " + s.Uses.Value | |
| 75 | - | case s.TimeoutMinutes != nil: | |
| 76 | - | // The runner refuses it too (executor.notSupported): it has no | |
| 77 | - | // step timeout yet. | |
| 78 | - | return "timeout-minutes on a step" | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | 68 | return "" | |
| 82 | 69 | } | |
| 83 | 70 | ||
| @@ -253,6 +240,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||
| 253 | 240 | if j.HasToken() { | |
| 254 | 241 | j.TokenValidUntil = now.Add(domain.CITokenGrace) | |
| 255 | 242 | } | |
| 243 | + | rs.revokeTokens(j) | |
| 256 | 244 | rs.touch(j) | |
| 257 | 245 | rs.c.mu.Lock() | |
| 258 | 246 | delete(rs.c.heartbeats, jobRef(*j)) | |
A internal/usecase/ci_repotoken.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "slices" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Job repository tokens (ADR 0025, CI-2). A job gets one at its assignment: | |
| 15 | + | // "ermitegh_" and 32 random bytes, stored hashed, read-only on the repository | |
| 16 | + | // of its run. Auth.Authenticate resolves it to a principal shaped like a | |
| 17 | + | // stored token, so git over HTTP, the API and the REST subset need no rule of | |
| 18 | + | // their own. It is written in the batch of the assignment, deleted in the | |
| 19 | + | // batch that ends the job (CompleteJob, a cancellation that concludes, a lost | |
| 20 | + | // runner, a timeout, the queue and log limits) or that takes the assignment | |
| 21 | + | // back, and with the run when the run is deleted. Its expiry is the deadline of | |
| 22 | + | // the job plus a few minutes, whatever happens. | |
| 23 | + | ||
| 24 | + | func newRepoTokenID() string { | |
| 25 | + | var raw [8]byte | |
| 26 | + | _, _ = rand.Read(raw[:]) | |
| 27 | + | return hex.EncodeToString(raw[:]) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // repoTokenExpiry is when a token of the job expires at the latest: the | |
| 31 | + | // deadline of the job and the grace. | |
| 32 | + | func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) { | |
| 33 | + | expires.ExpiresAt = c.deadline(j).Add(domain.RepoTokenGrace) | |
| 34 | + | return expires | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // issueRepoToken adds a repository token for j to the next flush and returns | |
| 38 | + | // its secret. j must be assigned (its AssignedAt is set). | |
| 39 | + | func (rs *runState) issueRepoToken(j *domain.CIJob) string { | |
| 40 | + | return rs.issueRepoTokenFor(j, rs.run.Repo, "") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes | |
| 44 | + | // it the archive token of that commit of repo, which downloads that archive and | |
| 45 | + | // nothing else. | |
| 46 | + | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string { | |
| 47 | + | c := rs.c | |
| 48 | + | now := c.now() | |
| 49 | + | at := *j | |
| 50 | + | if at.AssignedAt.IsZero() { | |
| 51 | + | at.AssignedAt = now | |
| 52 | + | } | |
| 53 | + | secret := newToken(domain.RepoTokenPrefix) | |
| 54 | + | t := c.repoTokenExpiry(at) | |
| 55 | + | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | |
| 56 | + | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now | |
| 57 | + | rs.tokens = append(rs.tokens, t) | |
| 58 | + | return secret | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // revokeTokens deletes, with the next flush, every repository token of j, and | |
| 62 | + | // forgets the archive tokens kept in memory. | |
| 63 | + | func (rs *runState) revokeTokens(j *domain.CIJob) { | |
| 64 | + | ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID} | |
| 65 | + | if !slices.Contains(rs.revoke, ref) { | |
| 66 | + | rs.revoke = append(rs.revoke, ref) | |
| 67 | + | } | |
| 68 | + | rs.c.mu.Lock() | |
| 69 | + | delete(rs.c.archiveTokens, jobRef(*j)) | |
| 70 | + | rs.c.mu.Unlock() | |
| 71 | + | } | |
M internal/usecase/ci_runner.go +6 -5
| @@ -207,16 +207,16 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri | |||
| 207 | 207 | if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID { | |
| 208 | 208 | return errNoJob | |
| 209 | 209 | } | |
| 210 | - | out = c.runnerJob(rs, *j, rec.token) | |
| 210 | + | out = c.runnerJob(rs, *j, rec.token, rec.ghToken) | |
| 211 | 211 | return nil | |
| 212 | 212 | }) | |
| 213 | 213 | return out, err == nil && out != nil | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | // runnerJob is the job as the runner gets it. | |
| 217 | - | func (c *CI) runnerJob(rs *runState, j domain.CIJob, token string) *ports.RunnerJob { | |
| 217 | + | func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob { | |
| 218 | 218 | return &ports.RunnerJob{ | |
| 219 | - | Job: j, Token: token, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | |
| 219 | + | Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | |
| 220 | 220 | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | |
| 221 | 221 | } | |
| 222 | 222 | } | |
| @@ -241,6 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str | |||
| 241 | 241 | return errNoJob | |
| 242 | 242 | } | |
| 243 | 243 | token := newToken(domain.JobTokenPrefix) | |
| 244 | + | ghToken := rs.issueRepoToken(j) | |
| 244 | 245 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | |
| 245 | 246 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | |
| 246 | 247 | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | |
| @@ -248,10 +249,10 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str | |||
| 248 | 249 | if err := rs.flush(ctx); err != nil { | |
| 249 | 250 | return err | |
| 250 | 251 | } | |
| 251 | - | out = c.runnerJob(rs, *j, token) | |
| 252 | + | out = c.runnerJob(rs, *j, token, ghToken) | |
| 252 | 253 | if pollID != "" { | |
| 253 | 254 | c.mu.Lock() | |
| 254 | - | c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, hash: j.TokenHash} | |
| 255 | + | c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash} | |
| 255 | 256 | c.mu.Unlock() | |
| 256 | 257 | } | |
| 257 | 258 | return nil | |
M internal/usecase/ci_trigger.go +17 -3
| @@ -36,6 +36,7 @@ type ciTrigger struct { | |||
| 36 | 36 | repo domain.RepoName | |
| 37 | 37 | updates []domain.RefUpdate | |
| 38 | 38 | actor string | |
| 39 | + | pusher domain.CIPusher | |
| 39 | 40 | at time.Time | |
| 40 | 41 | log zerowrap.Logger | |
| 41 | 42 | } | |
| @@ -44,6 +45,19 @@ type ciTrigger struct { | |||
| 44 | 45 | // a worker plans them. A full queue drops the trigger, logged at error: a push | |
| 45 | 46 | // is never slowed down or failed by CI. | |
| 46 | 47 | func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) { | |
| 48 | + | c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher)) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // OnMerge queues the move of a target branch by a merge. The actor of the run is | |
| 52 | + | // the owner, as GitHub shows; the pusher recorded with it, whose rights the | |
| 53 | + | // actions of other repositories are resolved with, is the principal that merged: | |
| 54 | + | // a token limited to some repositories does not gain the owner's reach by | |
| 55 | + | // merging. | |
| 56 | + | func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) { | |
| 57 | + | c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher) { | |
| 47 | 61 | var kept []domain.RefUpdate | |
| 48 | 62 | for _, u := range updates { | |
| 49 | 63 | if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) { | |
| @@ -53,7 +67,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain. | |||
| 53 | 67 | if len(kept) == 0 { | |
| 54 | 68 | return | |
| 55 | 69 | } | |
| 56 | - | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 70 | + | t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 57 | 71 | c.mu.Lock() | |
| 58 | 72 | closed := c.closed | |
| 59 | 73 | c.mu.Unlock() | |
| @@ -384,7 +398,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||
| 384 | 398 | CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| 385 | 399 | } | |
| 386 | 400 | return ports.CIRunDraft{ | |
| 387 | - | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"}, | |
| 401 | + | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher}, | |
| 388 | 402 | Checks: []domain.CICheck{{ | |
| 389 | 403 | ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted, | |
| 390 | 404 | Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| @@ -436,7 +450,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||
| 436 | 450 | run.Name, run.Title = runName, runName | |
| 437 | 451 | } | |
| 438 | 452 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | |
| 439 | - | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, | |
| 453 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher, | |
| 440 | 454 | }} | |
| 441 | 455 | for i, j := range wf.Jobs { | |
| 442 | 456 | job := domain.CIJob{ | |
M internal/usecase/ci_watch.go +1 -0
| @@ -189,6 +189,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob | |||
| 189 | 189 | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | |
| 190 | 190 | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | |
| 191 | 191 | j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{} | |
| 192 | + | rs.revokeTokens(j) // the next assignment gets new ones | |
| 192 | 193 | c.forgetPoll(jobRef(*j)) | |
| 193 | 194 | if j.CancelReason != domain.CICancelNone { | |
| 194 | 195 | // Cancelled (by the owner, fail-fast or the time budget) before the | |
M internal/usecase/mergerequests.go +3 -2
| @@ -814,8 +814,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 814 | 814 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| 815 | 815 | }) | |
| 816 | 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()) | |
| 817 | + | // GitHub runs the "push" workflows of a merge, with the owner as actor; | |
| 818 | + | // the run is trusted with what the merging principal may read. | |
| 819 | + | m.ci.OnMerge(ctx, name, updates[:1], p) | |
| 819 | 820 | } | |
| 820 | 821 | return mr, deleted, nil | |
| 821 | 822 | } | |
M internal/usecase/tokens.go +58 -0
| @@ -273,6 +273,9 @@ type Auth struct { | |||
| 273 | 273 | root domain.TokenHash | |
| 274 | 274 | tokens ports.TokenStore | |
| 275 | 275 | clock ports.Clock | |
| 276 | + | // repoTokens resolves the job repository tokens (domain.RepoTokenPrefix); | |
| 277 | + | // nil when CI is not wired. | |
| 278 | + | repoTokens ports.CIRepoTokenStore | |
| 276 | 279 | } | |
| 277 | 280 | ||
| 278 | 281 | var ( | |
| @@ -286,6 +289,17 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo | |||
| 286 | 289 | return &Auth{root: rootHash, tokens: tokens, clock: clock} | |
| 287 | 290 | } | |
| 288 | 291 | ||
| 292 | + | // WithRepoTokens makes Authenticate accept the job repository tokens of CI | |
| 293 | + | // (the "github.token" of a job, ADR 0025). They resolve to a principal shaped | |
| 294 | + | // like a stored token (repo:read on one repository, with an expiry), so no | |
| 295 | + | // rule of the forge knows them; they are stored apart from the stored tokens, | |
| 296 | + | // so ListTokens never shows them and token:admin never manages them. Call it | |
| 297 | + | // once while wiring. | |
| 298 | + | func (a *Auth) WithRepoTokens(s ports.CIRepoTokenStore) *Auth { | |
| 299 | + | a.repoTokens = s | |
| 300 | + | return a | |
| 301 | + | } | |
| 302 | + | ||
| 289 | 303 | // Revalidate checks that the holder of a principal obtained earlier is still | |
| 290 | 304 | // authorized: root always is; a stored token must still exist and not have | |
| 291 | 305 | // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the | |
| @@ -298,6 +312,9 @@ func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error { | |||
| 298 | 312 | if p.IsAnonymous() { | |
| 299 | 313 | return domain.ErrUnauthenticated | |
| 300 | 314 | } | |
| 315 | + | if id := p.TokenID(); domain.IsRepoTokenID(id) { | |
| 316 | + | return a.revalidateRepoToken(ctx, strings.TrimPrefix(id, "job:")) | |
| 317 | + | } | |
| 301 | 318 | tok, err := a.tokens.Get(ctx, p.TokenID()) | |
| 302 | 319 | if errors.Is(err, domain.ErrNotFound) { | |
| 303 | 320 | return domain.ErrUnauthenticated | |
| @@ -325,6 +342,9 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa | |||
| 325 | 342 | if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 { | |
| 326 | 343 | return domain.Root(), nil | |
| 327 | 344 | } | |
| 345 | + | if domain.IsRepoToken(secret) { | |
| 346 | + | return a.authenticateRepoToken(ctx, h) | |
| 347 | + | } | |
| 328 | 348 | tok, err := a.tokens.FindByHash(ctx, h) | |
| 329 | 349 | if errors.Is(err, domain.ErrNotFound) { | |
| 330 | 350 | return domain.Principal{}, domain.ErrUnauthenticated | |
| @@ -344,3 +364,41 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa | |||
| 344 | 364 | } | |
| 345 | 365 | return domain.TokenPrincipal(tok), nil | |
| 346 | 366 | } | |
| 367 | + | ||
| 368 | + | // authenticateRepoToken resolves a job repository token: it exists (it is | |
| 369 | + | // deleted when its job ends, is cancelled or lost) and has not expired. | |
| 370 | + | func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (domain.Principal, error) { | |
| 371 | + | if a.repoTokens == nil { | |
| 372 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 373 | + | } | |
| 374 | + | t, err := a.repoTokens.FindRepoTokenByHash(ctx, h) | |
| 375 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 376 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 377 | + | } | |
| 378 | + | if err != nil { | |
| 379 | + | return domain.Principal{}, fmt.Errorf("find repository token: %w", err) | |
| 380 | + | } | |
| 381 | + | if t.Expired(a.clock.Now().UTC()) { | |
| 382 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 383 | + | } | |
| 384 | + | return domain.RepoTokenPrincipal(t), nil | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // revalidateRepoToken is Revalidate for the principal of a job repository | |
| 388 | + | // token. | |
| 389 | + | func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error { | |
| 390 | + | if a.repoTokens == nil { | |
| 391 | + | return domain.ErrUnauthenticated | |
| 392 | + | } | |
| 393 | + | t, err := a.repoTokens.GetRepoToken(ctx, id) | |
| 394 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 395 | + | return domain.ErrUnauthenticated | |
| 396 | + | } | |
| 397 | + | if err != nil { | |
| 398 | + | return fmt.Errorf("find repository token: %w", err) | |
| 399 | + | } | |
| 400 | + | if t.Expired(a.clock.Now().UTC()) { | |
| 401 | + | return domain.ErrUnauthenticated | |
| 402 | + | } | |
| 403 | + | return nil | |
| 404 | + | } | |
M internal/usecase/tokens_test.go +72 -0
| @@ -668,3 +668,75 @@ func TestTokensCreateBranchPrefixes(t *testing.T) { | |||
| 668 | 668 | assert.Equal(t, "mine", got[0].ID) | |
| 669 | 669 | }) | |
| 670 | 670 | } | |
| 671 | + | ||
| 672 | + | // The job repository tokens of CI: Authenticate resolves them to a principal | |
| 673 | + | // shaped like a stored token, and only while they exist and have not expired. | |
| 674 | + | func TestAuthRepoTokens(t *testing.T) { | |
| 675 | + | t.Parallel() | |
| 676 | + | ctx := context.Background() | |
| 677 | + | secret := domain.RepoTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen) | |
| 678 | + | h := domain.HashTokenSecret(secret) | |
| 679 | + | repo := name(t, "proj") | |
| 680 | + | tok := domain.CIRepoToken{ID: "id1", Hash: h, Repo: repo, ExpiresAt: now.Add(time.Minute)} | |
| 681 | + | ||
| 682 | + | t.Run("a live token reads its repository", func(t *testing.T) { | |
| 683 | + | t.Parallel() | |
| 684 | + | f := newTokenFixture(t) | |
| 685 | + | rt := mocks.NewCIRepoTokenStore(t) | |
| 686 | + | rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(tok, nil) | |
| 687 | + | f.auth.WithRepoTokens(rt) | |
| 688 | + | p, err := f.auth.Authenticate(ctx, secret) | |
| 689 | + | require.NoError(t, err) | |
| 690 | + | assert.True(t, p.Allows(domain.ScopeRepoRead, repo)) | |
| 691 | + | assert.False(t, p.Allows(domain.ScopeRepoWrite, repo)) | |
| 692 | + | assert.False(t, p.Allows(domain.ScopeTokenAdmin, repo)) | |
| 693 | + | }) | |
| 694 | + | t.Run("the stored tokens are never asked", func(t *testing.T) { | |
| 695 | + | t.Parallel() | |
| 696 | + | f := newTokenFixture(t) // its TokenStore mock fails on any call | |
| 697 | + | rt := mocks.NewCIRepoTokenStore(t) | |
| 698 | + | rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, domain.ErrNotFound) | |
| 699 | + | f.auth.WithRepoTokens(rt) | |
| 700 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 701 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked or unknown token") | |
| 702 | + | }) | |
| 703 | + | t.Run("expired", func(t *testing.T) { | |
| 704 | + | t.Parallel() | |
| 705 | + | f := newTokenFixture(t) | |
| 706 | + | rt := mocks.NewCIRepoTokenStore(t) | |
| 707 | + | old := tok | |
| 708 | + | old.ExpiresAt = now | |
| 709 | + | rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(old, nil) | |
| 710 | + | f.auth.WithRepoTokens(rt) | |
| 711 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 712 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 713 | + | }) | |
| 714 | + | t.Run("a store failure is not unauthenticated", func(t *testing.T) { | |
| 715 | + | t.Parallel() | |
| 716 | + | f := newTokenFixture(t) | |
| 717 | + | rt := mocks.NewCIRepoTokenStore(t) | |
| 718 | + | boom := errors.New("disk") | |
| 719 | + | rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, boom) | |
| 720 | + | f.auth.WithRepoTokens(rt) | |
| 721 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 722 | + | assert.ErrorIs(t, err, boom) | |
| 723 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 724 | + | }) | |
| 725 | + | t.Run("without CI wired the shape is refused", func(t *testing.T) { | |
| 726 | + | t.Parallel() | |
| 727 | + | f := newTokenFixture(t) | |
| 728 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 729 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 730 | + | }) | |
| 731 | + | t.Run("revalidate follows the token, not the stored tokens", func(t *testing.T) { | |
| 732 | + | t.Parallel() | |
| 733 | + | f := newTokenFixture(t) | |
| 734 | + | rt := mocks.NewCIRepoTokenStore(t) | |
| 735 | + | rt.EXPECT().GetRepoToken(ctx, "id1").Return(tok, nil).Once() | |
| 736 | + | rt.EXPECT().GetRepoToken(ctx, "id1").Return(domain.CIRepoToken{}, domain.ErrNotFound).Once() | |
| 737 | + | f.auth.WithRepoTokens(rt) | |
| 738 | + | p := domain.RepoTokenPrincipal(tok) | |
| 739 | + | assert.NoError(t, f.auth.Revalidate(ctx, p)) | |
| 740 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated, "revoked since") | |
| 741 | + | }) | |
| 742 | + | } | |
M proto/ermite/storage/ci.proto +40 -0
| @@ -112,6 +112,11 @@ message CIRunSource { | |||
| 112 | 112 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | |
| 113 | 113 | // the runner side knows a run by its id only. | |
| 114 | 114 | string repo = 3; | |
| 115 | + | // Who pushed the commit: "root", "owner", "token" or empty (a run stored | |
| 116 | + | // before it was recorded, or pushed by nobody). pusher_token_id is the ID of | |
| 117 | + | // the stored token for "token". What a token may read is looked up live. | |
| 118 | + | string pusher_kind = 5; | |
| 119 | + | string pusher_token_id = 6; | |
| 115 | 120 | } | |
| 116 | 121 | ||
| 117 | 122 | // CIStep is a step of a job. | |
| @@ -220,3 +225,38 @@ message CIRunner { | |||
| 220 | 225 | google.protobuf.Timestamp registered_at = 8; | |
| 221 | 226 | bytes token_hash = 9; | |
| 222 | 227 | } | |
| 228 | + | ||
| 229 | + | // CIRepoToken is a job repository token (the "github.token" of a job, or the | |
| 230 | + | // token of one action archive), stored under "ghtoken/<id>". The secret is | |
| 231 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, | |
| 232 | + | // "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a | |
| 233 | + | // job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the | |
| 234 | + | // tokens that read a repository, so that they go with it. | |
| 235 | + | message CIRepoToken { | |
| 236 | + | string id = 1; | |
| 237 | + | bytes hash = 2; | |
| 238 | + | // The one repository the token reads. | |
| 239 | + | string repo = 3; | |
| 240 | + | // The job the token belongs to: it is revoked when the job ends. | |
| 241 | + | int64 run_id = 4; | |
| 242 | + | int32 run_attempt = 5; | |
| 243 | + | string job_id = 6; | |
| 244 | + | // Set for the token of an action archive, unset for the job's own token. | |
| 245 | + | bool archive = 7; | |
| 246 | + | google.protobuf.Timestamp expires_at = 8; | |
| 247 | + | google.protobuf.Timestamp created_at = 9; | |
| 248 | + | // The commit an archive token downloads. | |
| 249 | + | string sha = 10; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // CIActionResolution is the resolution of an action reference for a run | |
| 253 | + | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | |
| 254 | + | // so that every job of the attempt gets the same commit. | |
| 255 | + | message CIActionResolution { | |
| 256 | + | // "owner/repo@ref", lower-cased owner and repository. | |
| 257 | + | string key = 1; | |
| 258 | + | string name_with_owner = 2; | |
| 259 | + | string resolved_sha = 3; | |
| 260 | + | // 1 = github.com, 2 = this server (the numbers of ActionSource). | |
| 261 | + | int32 source = 4; | |
| 262 | + | } | |