feat(ci): issue job repository tokens and resolve actions
A job gets a read-only repository token (ermitegh_ + 32 random bytes, stored hashed) at its assignment, in Job.github_token. Auth.Authenticate resolves it to a token-shaped principal (repo:read on the repository of the run, expiring at the job deadline plus 5 minutes), so no rule of the forge knows it. It is deleted in the batch that ends the job, takes the assignment back, or deletes the run, and is never listed or managed as a stored token. RunnerService.ResolveActions answers github.com actions with git ls-remote (tag, then branch, peeled commit of an annotated tag; a full id as it is) and actions of a repository of ERMITE_OWNER from ermite, with a per-job archive token. Resolutions are kept per run attempt. ls-remote is bounded and injectable (ports.GitRemote).
- commit
- 8d1000aeac61e2bba8c14b68a53fedfe6355f6cd
- author
- brice · · 2026-10-13 10:41 UTC
- parent
- bfdf4d9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +2745 -58
M gen/ermite/storage/ci.pb.go +240 -29 generated file: collapsed
M go.sum +0 -2
| @@ -1,7 +1,5 @@ | |||
| 1 | 1 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE= | |
| 2 | 2 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | - | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 h1:S7Zk7maQJoOUxY2BU4CKeAaWorDOhNXGw7sbT76Yft8= | |
| 4 | - | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 3 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8= | |
| 6 | 4 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 7 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| @@ -1,7 +1,5 @@ | |||||
| 1 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE= | 1 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE= | ||
| 2 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | 2 | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | ||
| 3 | - | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 h1:S7Zk7maQJoOUxY2BU4CKeAaWorDOhNXGw7sbT76Yft8= | |||
| 4 | - | bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |||
| 5 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8= | 3 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8= | ||
| 6 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | 4 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | ||
| 7 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | ||
A internal/adapters/gitremote/gitremote.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 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 | + | } | |
| 49 | + | ||
| 50 | + | // Remote implements ports.GitRemote over the git binary. | |
| 51 | + | type Remote struct{ cfg Config } | |
| 52 | + | ||
| 53 | + | var _ ports.GitRemote = (*Remote)(nil) | |
| 54 | + | ||
| 55 | + | // New builds a Remote. | |
| 56 | + | func New(cfg Config) *Remote { | |
| 57 | + | if cfg.BaseURL == "" { | |
| 58 | + | cfg.BaseURL = DefaultBaseURL | |
| 59 | + | } | |
| 60 | + | if cfg.Timeout <= 0 { | |
| 61 | + | cfg.Timeout = DefaultTimeout | |
| 62 | + | } | |
| 63 | + | if cfg.MaxOutput <= 0 { | |
| 64 | + | cfg.MaxOutput = DefaultMaxOutput | |
| 65 | + | } | |
| 66 | + | if cfg.AllowedProtocols == "" { | |
| 67 | + | cfg.AllowedProtocols = "https" | |
| 68 | + | } | |
| 69 | + | return &Remote{cfg: cfg} | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // validSegment reports whether s can be an owner or a repository name on | |
| 73 | + | // GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'. | |
| 74 | + | func validSegment(s string) bool { | |
| 75 | + | if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | for _, r := range s { | |
| 79 | + | switch { | |
| 80 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 81 | + | default: | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | return true | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // limitWriter keeps at most max bytes and records that more came. | |
| 89 | + | type limitWriter struct { | |
| 90 | + | buf bytes.Buffer | |
| 91 | + | max int | |
| 92 | + | exceeded bool | |
| 93 | + | cancel context.CancelFunc | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |
| 97 | + | if w.buf.Len()+len(p) > w.max { | |
| 98 | + | w.exceeded = true | |
| 99 | + | w.cancel() | |
| 100 | + | return 0, errors.New("output limit reached") | |
| 101 | + | } | |
| 102 | + | return w.buf.Write(p) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ListRefs lists the branches and tags of owner/repo, with the peeled commit | |
| 106 | + | // of the annotated tags. | |
| 107 | + | func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 108 | + | if !validSegment(owner) || !validSegment(repo) { | |
| 109 | + | return nil, domain.ErrNotFound | |
| 110 | + | } | |
| 111 | + | ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout) | |
| 112 | + | defer cancel() | |
| 113 | + | out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel} | |
| 114 | + | var stderr bytes.Buffer | |
| 115 | + | url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo | |
| 116 | + | cmd := exec.CommandContext(ctx, r.cfg.Bin, | |
| 117 | + | "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false", | |
| 118 | + | "ls-remote", "--", url, "refs/heads/*", "refs/tags/*") | |
| 119 | + | cmd.Env = []string{ | |
| 120 | + | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home, | |
| 121 | + | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | |
| 122 | + | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | |
| 123 | + | } | |
| 124 | + | cmd.Dir = r.cfg.Home | |
| 125 | + | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | |
| 126 | + | cmd.WaitDelay = 2 * time.Second | |
| 127 | + | err := cmd.Run() | |
| 128 | + | switch { | |
| 129 | + | case out.exceeded: | |
| 130 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput) | |
| 131 | + | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 132 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout) | |
| 133 | + | case ctx.Err() != nil: | |
| 134 | + | return nil, ctx.Err() | |
| 135 | + | case err != nil: | |
| 136 | + | msg := stderr.String() | |
| 137 | + | if notFound(msg) { | |
| 138 | + | return nil, domain.ErrNotFound | |
| 139 | + | } | |
| 140 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg)) | |
| 141 | + | } | |
| 142 | + | return parse(out.buf.String()), nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // notFound recognises what git says when github.com answers 404 or asks for | |
| 146 | + | // credentials, which it does for a repository that does not exist as for a | |
| 147 | + | // private one, and the prompt being disabled. | |
| 148 | + | func notFound(stderr string) bool { | |
| 149 | + | for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} { | |
| 150 | + | if strings.Contains(stderr, s) { | |
| 151 | + | return true | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | return false | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func firstLine(s string) string { | |
| 158 | + | s = strings.TrimSpace(s) | |
| 159 | + | if i := strings.IndexByte(s, '\n'); i >= 0 { | |
| 160 | + | s = s[:i] | |
| 161 | + | } | |
| 162 | + | return s | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // limitedBuffer keeps the first max bytes written and drops the rest. | |
| 166 | + | type limitedBuffer struct { | |
| 167 | + | b *bytes.Buffer | |
| 168 | + | max int | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (l *limitedBuffer) Write(p []byte) (int, error) { | |
| 172 | + | if room := l.max - l.b.Len(); room > 0 { | |
| 173 | + | l.b.Write(p[:min(room, len(p))]) | |
| 174 | + | } | |
| 175 | + | return len(p), nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives | |
| 179 | + | // the peeled commit of the annotated tag listed before it. | |
| 180 | + | func parse(out string) []ports.RemoteRef { | |
| 181 | + | var refs []ports.RemoteRef | |
| 182 | + | index := map[string]int{} | |
| 183 | + | for line := range strings.SplitSeq(out, "\n") { | |
| 184 | + | id, name, ok := strings.Cut(line, "\t") | |
| 185 | + | if !ok || len(id) != domain.ObjectIDLen { | |
| 186 | + | continue | |
| 187 | + | } | |
| 188 | + | if base, peeled := strings.CutSuffix(name, "^{}"); peeled { | |
| 189 | + | if i, ok := index[base]; ok { | |
| 190 | + | refs[i].Peeled = id | |
| 191 | + | } | |
| 192 | + | continue | |
| 193 | + | } | |
| 194 | + | index[name] = len(refs) | |
| 195 | + | refs = append(refs, ports.RemoteRef{Name: name, ID: id}) | |
| 196 | + | } | |
| 197 | + | return refs | |
| 198 | + | } | |
| @@ -0,0 +1,198 @@ | |||||
| 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 | + | } | |||
| 49 | + | ||||
| 50 | + | // Remote implements ports.GitRemote over the git binary. | |||
| 51 | + | type Remote struct{ cfg Config } | |||
| 52 | + | ||||
| 53 | + | var _ ports.GitRemote = (*Remote)(nil) | |||
| 54 | + | ||||
| 55 | + | // New builds a Remote. | |||
| 56 | + | func New(cfg Config) *Remote { | |||
| 57 | + | if cfg.BaseURL == "" { | |||
| 58 | + | cfg.BaseURL = DefaultBaseURL | |||
| 59 | + | } | |||
| 60 | + | if cfg.Timeout <= 0 { | |||
| 61 | + | cfg.Timeout = DefaultTimeout | |||
| 62 | + | } | |||
| 63 | + | if cfg.MaxOutput <= 0 { | |||
| 64 | + | cfg.MaxOutput = DefaultMaxOutput | |||
| 65 | + | } | |||
| 66 | + | if cfg.AllowedProtocols == "" { | |||
| 67 | + | cfg.AllowedProtocols = "https" | |||
| 68 | + | } | |||
| 69 | + | return &Remote{cfg: cfg} | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // validSegment reports whether s can be an owner or a repository name on | |||
| 73 | + | // GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'. | |||
| 74 | + | func validSegment(s string) bool { | |||
| 75 | + | if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | for _, r := range s { | |||
| 79 | + | switch { | |||
| 80 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |||
| 81 | + | default: | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | return true | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // limitWriter keeps at most max bytes and records that more came. | |||
| 89 | + | type limitWriter struct { | |||
| 90 | + | buf bytes.Buffer | |||
| 91 | + | max int | |||
| 92 | + | exceeded bool | |||
| 93 | + | cancel context.CancelFunc | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |||
| 97 | + | if w.buf.Len()+len(p) > w.max { | |||
| 98 | + | w.exceeded = true | |||
| 99 | + | w.cancel() | |||
| 100 | + | return 0, errors.New("output limit reached") | |||
| 101 | + | } | |||
| 102 | + | return w.buf.Write(p) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // ListRefs lists the branches and tags of owner/repo, with the peeled commit | |||
| 106 | + | // of the annotated tags. | |||
| 107 | + | func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |||
| 108 | + | if !validSegment(owner) || !validSegment(repo) { | |||
| 109 | + | return nil, domain.ErrNotFound | |||
| 110 | + | } | |||
| 111 | + | ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout) | |||
| 112 | + | defer cancel() | |||
| 113 | + | out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel} | |||
| 114 | + | var stderr bytes.Buffer | |||
| 115 | + | url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo | |||
| 116 | + | cmd := exec.CommandContext(ctx, r.cfg.Bin, | |||
| 117 | + | "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false", | |||
| 118 | + | "ls-remote", "--", url, "refs/heads/*", "refs/tags/*") | |||
| 119 | + | cmd.Env = []string{ | |||
| 120 | + | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home, | |||
| 121 | + | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | |||
| 122 | + | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | |||
| 123 | + | } | |||
| 124 | + | cmd.Dir = r.cfg.Home | |||
| 125 | + | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | |||
| 126 | + | cmd.WaitDelay = 2 * time.Second | |||
| 127 | + | err := cmd.Run() | |||
| 128 | + | switch { | |||
| 129 | + | case out.exceeded: | |||
| 130 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput) | |||
| 131 | + | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |||
| 132 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout) | |||
| 133 | + | case ctx.Err() != nil: | |||
| 134 | + | return nil, ctx.Err() | |||
| 135 | + | case err != nil: | |||
| 136 | + | msg := stderr.String() | |||
| 137 | + | if notFound(msg) { | |||
| 138 | + | return nil, domain.ErrNotFound | |||
| 139 | + | } | |||
| 140 | + | return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg)) | |||
| 141 | + | } | |||
| 142 | + | return parse(out.buf.String()), nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // notFound recognises what git says when github.com answers 404 or asks for | |||
| 146 | + | // credentials, which it does for a repository that does not exist as for a | |||
| 147 | + | // private one, and the prompt being disabled. | |||
| 148 | + | func notFound(stderr string) bool { | |||
| 149 | + | for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} { | |||
| 150 | + | if strings.Contains(stderr, s) { | |||
| 151 | + | return true | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | return false | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func firstLine(s string) string { | |||
| 158 | + | s = strings.TrimSpace(s) | |||
| 159 | + | if i := strings.IndexByte(s, '\n'); i >= 0 { | |||
| 160 | + | s = s[:i] | |||
| 161 | + | } | |||
| 162 | + | return s | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // limitedBuffer keeps the first max bytes written and drops the rest. | |||
| 166 | + | type limitedBuffer struct { | |||
| 167 | + | b *bytes.Buffer | |||
| 168 | + | max int | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (l *limitedBuffer) Write(p []byte) (int, error) { | |||
| 172 | + | if room := l.max - l.b.Len(); room > 0 { | |||
| 173 | + | l.b.Write(p[:min(room, len(p))]) | |||
| 174 | + | } | |||
| 175 | + | return len(p), nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives | |||
| 179 | + | // the peeled commit of the annotated tag listed before it. | |||
| 180 | + | func parse(out string) []ports.RemoteRef { | |||
| 181 | + | var refs []ports.RemoteRef | |||
| 182 | + | index := map[string]int{} | |||
| 183 | + | for line := range strings.SplitSeq(out, "\n") { | |||
| 184 | + | id, name, ok := strings.Cut(line, "\t") | |||
| 185 | + | if !ok || len(id) != domain.ObjectIDLen { | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | if base, peeled := strings.CutSuffix(name, "^{}"); peeled { | |||
| 189 | + | if i, ok := index[base]; ok { | |||
| 190 | + | refs[i].Peeled = id | |||
| 191 | + | } | |||
| 192 | + | continue | |||
| 193 | + | } | |||
| 194 | + | index[name] = len(refs) | |||
| 195 | + | refs = append(refs, ports.RemoteRef{Name: name, ID: id}) | |||
| 196 | + | } | |||
| 197 | + | return refs | |||
| 198 | + | } | |||
A internal/adapters/gitremote/gitremote_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 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 | + | } | |||
M internal/adapters/kvstore/ci.go +4 -0
| @@ -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 { | |
| @@ -828,6 +831,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l | |||
| 828 | 831 | // runKeys lists every key of a run except its record: source, jobs, indexes. | |
| 829 | 832 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | |
| 830 | 833 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 834 | + | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |
| 831 | 835 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 832 | 836 | keys = append(keys, slices.Clone(key)) | |
| 833 | 837 | if j, err := decodeJob(item); err == nil { | |
| @@ -37,6 +37,8 @@ import ( | |||||
| 37 | // ciready/<ready_at:020d>/<job key> job waiting for a runner | 37 | // ciready/<ready_at:020d>/<job key> job waiting for a runner | ||
| 38 | // ciactive/<job key> job assigned or running | 38 | // ciactive/<job key> job assigned or running | ||
| 39 | // jobtoken/<hash> job token (value: job key) | 39 | // jobtoken/<hash> job token (value: job key) | ||
| 40 | + | // | |||
| 41 | + | // The job repository tokens and the action resolutions are in ci_token.go. | |||
| 40 | const ( | 42 | const ( | ||
| 41 | runPrefix = "run/" | 43 | runPrefix = "run/" | ||
| 42 | runSrcPrefix = "runsrc/" | 44 | runSrcPrefix = "runsrc/" | ||
| @@ -507,6 +509,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||||
| 507 | guarded[j.Repo] = true | 509 | guarded[j.Repo] = true | ||
| 508 | } | 510 | } | ||
| 509 | } | 511 | } | ||
| 512 | + | c.stageRepoTokens(ctx, b, w, guarded) | |||
| 510 | // Checks are derived from the jobs, written in the same batch and under | 513 | // Checks are derived from the jobs, written in the same batch and under | ||
| 511 | // the lock of their run: they are upserted without a precondition. | 514 | // the lock of their run: they are upserted without a precondition. | ||
| 512 | for _, ck := range w.Checks { | 515 | for _, ck := range w.Checks { | ||
| @@ -828,6 +831,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l | |||||
| 828 | // runKeys lists every key of a run except its record: source, jobs, indexes. | 831 | // runKeys lists every key of a run except its record: source, jobs, indexes. | ||
| 829 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | 832 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | ||
| 830 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | 833 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | ||
| 834 | + | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |||
| 831 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | 835 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | ||
| 832 | keys = append(keys, slices.Clone(key)) | 836 | keys = append(keys, slices.Clone(key)) | ||
| 833 | if j, err := decodeJob(item); err == nil { | 837 | if j, err := decodeJob(item); err == nil { | ||
A internal/adapters/kvstore/ci_token.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 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> empty: the tokens of a job | |
| 26 | + | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | |
| 27 | + | // | |
| 28 | + | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | |
| 29 | + | // job token is never listed, revoked or managed as a stored token. | |
| 30 | + | const ( | |
| 31 | + | repoTokenPrefix = "ghtoken/" | |
| 32 | + | repoTokenHashPrefix = "ghtokenhash/" | |
| 33 | + | repoTokenJobPrefix = "ghtokenjob/" | |
| 34 | + | resolutionPrefix = "actres/" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | var _ ports.CIRepoTokenStore = (*CI)(nil) | |
| 38 | + | ||
| 39 | + | func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) } | |
| 40 | + | func repoTokenHashKey(h domain.TokenHash) []byte { | |
| 41 | + | return []byte(repoTokenHashPrefix + h.String()) | |
| 42 | + | } | |
| 43 | + | func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string { | |
| 44 | + | return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID) | |
| 45 | + | } | |
| 46 | + | func repoTokenJobKey(t domain.CIRepoToken) []byte { | |
| 47 | + | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | |
| 48 | + | } | |
| 49 | + | func resolutionKey(runID int64, attempt int32, key string) []byte { | |
| 50 | + | sum := sha256.Sum256([]byte(key)) | |
| 51 | + | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func encodeRepoToken(t domain.CIRepoToken) []byte { | |
| 55 | + | return mustMarshal(&storagepb.CIRepoToken{ | |
| 56 | + | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | |
| 57 | + | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func decodeRepoToken(val []byte) (domain.CIRepoToken, error) { | |
| 62 | + | var pb storagepb.CIRepoToken | |
| 63 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 64 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |
| 65 | + | } | |
| 66 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 67 | + | if err != nil { | |
| 68 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |
| 69 | + | } | |
| 70 | + | t := domain.CIRepoToken{ | |
| 71 | + | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |
| 72 | + | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 73 | + | } | |
| 74 | + | copy(t.Hash[:], pb.GetHash()) | |
| 75 | + | return t, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // stageRepoTokens stages the creation of the tokens, the deletion of the | |
| 79 | + | // tokens of the jobs to revoke, and the resolutions to keep. | |
| 80 | + | func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) { | |
| 81 | + | snap := c.db.Snapshot(ctx) | |
| 82 | + | for _, ref := range w.RevokeJobs { | |
| 83 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) { | |
| 84 | + | b.Delete(append([]byte(nil), key...)) | |
| 85 | + | id := string(item.Value) | |
| 86 | + | if id == "" { | |
| 87 | + | id = string(key[strings.LastIndexByte(string(key), '/')+1:]) | |
| 88 | + | } | |
| 89 | + | b.Delete(repoTokenKey(id)) | |
| 90 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |
| 91 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 92 | + | b.Delete(repoTokenHashKey(t.Hash)) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | for _, t := range w.RepoTokens { | |
| 98 | + | b.RequireAbsent(repoTokenKey(t.ID)) | |
| 99 | + | b.RequireAbsent(repoTokenHashKey(t.Hash)) | |
| 100 | + | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | |
| 101 | + | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | |
| 102 | + | b.Put(repoTokenJobKey(t), []byte(t.ID)) | |
| 103 | + | guarded[t.Repo] = true | |
| 104 | + | } | |
| 105 | + | for _, r := range w.Resolutions { | |
| 106 | + | b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{ | |
| 107 | + | Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2 | |
| 108 | + | })) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // FindRepoTokenByHash resolves the hash index, then reads the token. | |
| 113 | + | func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) { | |
| 114 | + | snap := c.db.Snapshot(ctx) | |
| 115 | + | idx, ok := snap.Get(ctx, repoTokenHashKey(hash)) | |
| 116 | + | if !ok { | |
| 117 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 118 | + | } | |
| 119 | + | it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value))) | |
| 120 | + | if !ok { | |
| 121 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 122 | + | } | |
| 123 | + | t, err := decodeRepoToken(it.Value) | |
| 124 | + | if err != nil { | |
| 125 | + | return domain.CIRepoToken{}, err | |
| 126 | + | } | |
| 127 | + | if t.Hash != hash { | |
| 128 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 129 | + | } | |
| 130 | + | return t, nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // GetRepoToken reads ghtoken/<id>. | |
| 134 | + | func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) { | |
| 135 | + | if !validTokenID(id) { | |
| 136 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 137 | + | } | |
| 138 | + | it, ok := c.db.Get(ctx, repoTokenKey(id)) | |
| 139 | + | if !ok { | |
| 140 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |
| 141 | + | } | |
| 142 | + | return decodeRepoToken(it.Value) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // GetActionResolution reads the resolution kept for the key. | |
| 146 | + | func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) { | |
| 147 | + | it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key)) | |
| 148 | + | if !ok { | |
| 149 | + | return domain.CIActionResolution{}, domain.ErrNotFound | |
| 150 | + | } | |
| 151 | + | var pb storagepb.CIActionResolution | |
| 152 | + | if err := proto.Unmarshal(it.Value, &pb); err != nil { | |
| 153 | + | return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err) | |
| 154 | + | } | |
| 155 | + | return domain.CIActionResolution{ | |
| 156 | + | RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(), | |
| 157 | + | ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()), | |
| 158 | + | }, nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // repoTokenKeys lists the keys of the repository tokens and action | |
| 162 | + | // resolutions of a run, for its deletion. | |
| 163 | + | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | |
| 164 | + | var keys [][]byte | |
| 165 | + | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | |
| 166 | + | keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value))) | |
| 167 | + | if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok { | |
| 168 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 169 | + | keys = append(keys, repoTokenHashKey(t.Hash)) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | |
| 174 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 175 | + | } | |
| 176 | + | return keys | |
| 177 | + | } | |
| @@ -0,0 +1,177 @@ | |||||
| 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> empty: the tokens of a job | |||
| 26 | + | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | |||
| 27 | + | // | |||
| 28 | + | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | |||
| 29 | + | // job token is never listed, revoked or managed as a stored token. | |||
| 30 | + | const ( | |||
| 31 | + | repoTokenPrefix = "ghtoken/" | |||
| 32 | + | repoTokenHashPrefix = "ghtokenhash/" | |||
| 33 | + | repoTokenJobPrefix = "ghtokenjob/" | |||
| 34 | + | resolutionPrefix = "actres/" | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | var _ ports.CIRepoTokenStore = (*CI)(nil) | |||
| 38 | + | ||||
| 39 | + | func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) } | |||
| 40 | + | func repoTokenHashKey(h domain.TokenHash) []byte { | |||
| 41 | + | return []byte(repoTokenHashPrefix + h.String()) | |||
| 42 | + | } | |||
| 43 | + | func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string { | |||
| 44 | + | return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID) | |||
| 45 | + | } | |||
| 46 | + | func repoTokenJobKey(t domain.CIRepoToken) []byte { | |||
| 47 | + | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | |||
| 48 | + | } | |||
| 49 | + | func resolutionKey(runID int64, attempt int32, key string) []byte { | |||
| 50 | + | sum := sha256.Sum256([]byte(key)) | |||
| 51 | + | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func encodeRepoToken(t domain.CIRepoToken) []byte { | |||
| 55 | + | return mustMarshal(&storagepb.CIRepoToken{ | |||
| 56 | + | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | |||
| 57 | + | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func decodeRepoToken(val []byte) (domain.CIRepoToken, error) { | |||
| 62 | + | var pb storagepb.CIRepoToken | |||
| 63 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |||
| 64 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |||
| 65 | + | } | |||
| 66 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err) | |||
| 69 | + | } | |||
| 70 | + | t := domain.CIRepoToken{ | |||
| 71 | + | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |||
| 72 | + | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | |||
| 73 | + | } | |||
| 74 | + | copy(t.Hash[:], pb.GetHash()) | |||
| 75 | + | return t, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // stageRepoTokens stages the creation of the tokens, the deletion of the | |||
| 79 | + | // tokens of the jobs to revoke, and the resolutions to keep. | |||
| 80 | + | func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) { | |||
| 81 | + | snap := c.db.Snapshot(ctx) | |||
| 82 | + | for _, ref := range w.RevokeJobs { | |||
| 83 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) { | |||
| 84 | + | b.Delete(append([]byte(nil), key...)) | |||
| 85 | + | id := string(item.Value) | |||
| 86 | + | if id == "" { | |||
| 87 | + | id = string(key[strings.LastIndexByte(string(key), '/')+1:]) | |||
| 88 | + | } | |||
| 89 | + | b.Delete(repoTokenKey(id)) | |||
| 90 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |||
| 91 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |||
| 92 | + | b.Delete(repoTokenHashKey(t.Hash)) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | for _, t := range w.RepoTokens { | |||
| 98 | + | b.RequireAbsent(repoTokenKey(t.ID)) | |||
| 99 | + | b.RequireAbsent(repoTokenHashKey(t.Hash)) | |||
| 100 | + | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | |||
| 101 | + | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | |||
| 102 | + | b.Put(repoTokenJobKey(t), []byte(t.ID)) | |||
| 103 | + | guarded[t.Repo] = true | |||
| 104 | + | } | |||
| 105 | + | for _, r := range w.Resolutions { | |||
| 106 | + | b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{ | |||
| 107 | + | Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2 | |||
| 108 | + | })) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // FindRepoTokenByHash resolves the hash index, then reads the token. | |||
| 113 | + | func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) { | |||
| 114 | + | snap := c.db.Snapshot(ctx) | |||
| 115 | + | idx, ok := snap.Get(ctx, repoTokenHashKey(hash)) | |||
| 116 | + | if !ok { | |||
| 117 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |||
| 118 | + | } | |||
| 119 | + | it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value))) | |||
| 120 | + | if !ok { | |||
| 121 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |||
| 122 | + | } | |||
| 123 | + | t, err := decodeRepoToken(it.Value) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return domain.CIRepoToken{}, err | |||
| 126 | + | } | |||
| 127 | + | if t.Hash != hash { | |||
| 128 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |||
| 129 | + | } | |||
| 130 | + | return t, nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // GetRepoToken reads ghtoken/<id>. | |||
| 134 | + | func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) { | |||
| 135 | + | if !validTokenID(id) { | |||
| 136 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |||
| 137 | + | } | |||
| 138 | + | it, ok := c.db.Get(ctx, repoTokenKey(id)) | |||
| 139 | + | if !ok { | |||
| 140 | + | return domain.CIRepoToken{}, domain.ErrNotFound | |||
| 141 | + | } | |||
| 142 | + | return decodeRepoToken(it.Value) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // GetActionResolution reads the resolution kept for the key. | |||
| 146 | + | func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) { | |||
| 147 | + | it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key)) | |||
| 148 | + | if !ok { | |||
| 149 | + | return domain.CIActionResolution{}, domain.ErrNotFound | |||
| 150 | + | } | |||
| 151 | + | var pb storagepb.CIActionResolution | |||
| 152 | + | if err := proto.Unmarshal(it.Value, &pb); err != nil { | |||
| 153 | + | return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err) | |||
| 154 | + | } | |||
| 155 | + | return domain.CIActionResolution{ | |||
| 156 | + | RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(), | |||
| 157 | + | ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()), | |||
| 158 | + | }, nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // repoTokenKeys lists the keys of the repository tokens and action | |||
| 162 | + | // resolutions of a run, for its deletion. | |||
| 163 | + | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | |||
| 164 | + | var keys [][]byte | |||
| 165 | + | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | |||
| 166 | + | keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value))) | |||
| 167 | + | if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok { | |||
| 168 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |||
| 169 | + | keys = append(keys, repoTokenHashKey(t.Hash)) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | |||
| 174 | + | keys = append(keys, append([]byte(nil), key...)) | |||
| 175 | + | } | |||
| 176 | + | return keys | |||
| 177 | + | } | |||
A internal/adapters/kvstore/ci_token_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 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 | + | } | |||
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" | |
| @@ -28,7 +28,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 28 | // Features are the optional capabilities of the contract that this server | 28 | // Features are the optional capabilities of the contract that this server | ||
| 29 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 29 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 30 | // when Config.Passkeys is set. | 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 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | 33 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | ||
| 34 | const FeaturePasskeys = "passkeys" | 34 | const FeaturePasskeys = "passkeys" | ||
M internal/adapters/rpc/ci.go +16 -4
| @@ -172,10 +172,22 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb | |||
| 172 | 172 | return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | - | // ResolveActions is replaced by the real call in the commit that adds action | |
| 176 | - | // resolution. | |
| 177 | - | func (s *runnerService) ResolveActions(context.Context, *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) { | |
| 178 | - | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.ResolveActions is not implemented")) | |
| 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 | |
| 179 | 191 | } | |
| 180 | 192 | ||
| 181 | 193 | // UploadSummary is the E2 work (summaries storage): until the server lists | |
| @@ -172,10 +172,22 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb | |||||
| 172 | return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil | 172 | return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | - | // ResolveActions is replaced by the real call in the commit that adds action | 175 | + | // |
| 176 | - | // resolution. | 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(context.Context, *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) { | 177 | + | func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) { |
| 178 | - | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.ResolveActions is not implemented")) | 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 | |||
| 179 | } | 191 | } | ||
| 180 | 192 | ||||
| 181 | // UploadSummary is the E2 work (summaries storage): until the server lists | 193 | // UploadSummary is the E2 work (summaries storage): until the server lists | ||
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 { | |
| @@ -205,10 +205,25 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job { | |||||
| 205 | JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token, | 205 | JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token, | ||
| 206 | WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON, | 206 | WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON, | ||
| 207 | Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes, | 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 | func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | 227 | func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | ||
| 213 | out := make([]ports.StepUpdate, 0, len(steps)) | 228 | out := make([]ports.StepUpdate, 0, len(steps)) | ||
| 214 | for _, s := range steps { | 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 | |
| @@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) { | |||||
| 55 | return connect.CodeAborted, true | 55 | return connect.CodeAborted, true | ||
| 56 | case errors.Is(err, domain.ErrResourceExhausted): | 56 | case errors.Is(err, domain.ErrResourceExhausted): | ||
| 57 | return connect.CodeResourceExhausted, true | 57 | return connect.CodeResourceExhausted, true | ||
| 58 | + | case errors.Is(err, domain.ErrUnavailable): | |||
| 59 | + | return connect.CodeUnavailable, true | |||
| 58 | } | 60 | } | ||
| 59 | if domain.IsInvalid(err) { | 61 | if domain.IsInvalid(err) { | ||
| 60 | return connect.CodeInvalidArgument, true | 62 | return connect.CodeInvalidArgument, true | ||
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 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 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 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
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) | |
| @@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 326 | ver := decode[versionOut](t, res.stdout) | 326 | ver := decode[versionOut](t, res.stdout) | ||
| 327 | assert.Equal(t, c.url, ver.Server.URL) | 327 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 328 | assert.NotEmpty(t, ver.Server.Version) | 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 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 330 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 331 | stdout, stderr, code = c.bin(t, secret, "version") | 331 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 332 | require.Equal(t, 0, code, stderr) | 332 | require.Equal(t, 0, code, stderr) | ||
A internal/domain/ci_token.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 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"). | |
| 41 | + | Archive bool | |
| 42 | + | ExpiresAt time.Time | |
| 43 | + | CreatedAt time.Time | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Expired reports whether the token is no longer valid at now. | |
| 47 | + | func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) } | |
| 48 | + | ||
| 49 | + | // RepoTokenGrace is how long a job repository token outlives the deadline of | |
| 50 | + | // its job at the most (a safety net: it is revoked when the job ends). | |
| 51 | + | const RepoTokenGrace = 5 * time.Minute | |
| 52 | + | ||
| 53 | + | // RepoTokenPrincipal returns the principal of the holder of a job repository | |
| 54 | + | // token. It is shaped like the principal of a stored token (the scope | |
| 55 | + | // repo:read on one repository, an expiry), so that every authorization rule | |
| 56 | + | // of the forge applies to it unchanged: it reads that repository, even when | |
| 57 | + | // private, and writes nothing. Its TokenID starts with "job:" and matches no | |
| 58 | + | // stored token. | |
| 59 | + | func RepoTokenPrincipal(t CIRepoToken) Principal { | |
| 60 | + | return TokenPrincipal(Token{ | |
| 61 | + | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | |
| 62 | + | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // CIActionSource says where an action was found. | |
| 67 | + | type CIActionSource int | |
| 68 | + | ||
| 69 | + | // Action sources, with the numbers of ActionSource in the protocol. | |
| 70 | + | const ( | |
| 71 | + | CIActionGitHub CIActionSource = 1 | |
| 72 | + | CIActionErmite CIActionSource = 2 | |
| 73 | + | ) | |
| 74 | + | ||
| 75 | + | // CIActionResolution is the resolution of an action reference for one run | |
| 76 | + | // attempt: every job of the attempt gets the same commit. | |
| 77 | + | type CIActionResolution struct { | |
| 78 | + | RunID int64 | |
| 79 | + | RunAttempt int32 | |
| 80 | + | // Key is "owner/repo@ref", lower-cased. | |
| 81 | + | Key string | |
| 82 | + | NameWithOwner string | |
| 83 | + | ResolvedSHA string | |
| 84 | + | Source CIActionSource | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 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"). | |||
| 41 | + | Archive bool | |||
| 42 | + | ExpiresAt time.Time | |||
| 43 | + | CreatedAt time.Time | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Expired reports whether the token is no longer valid at now. | |||
| 47 | + | func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) } | |||
| 48 | + | ||||
| 49 | + | // RepoTokenGrace is how long a job repository token outlives the deadline of | |||
| 50 | + | // its job at the most (a safety net: it is revoked when the job ends). | |||
| 51 | + | const RepoTokenGrace = 5 * time.Minute | |||
| 52 | + | ||||
| 53 | + | // RepoTokenPrincipal returns the principal of the holder of a job repository | |||
| 54 | + | // token. It is shaped like the principal of a stored token (the scope | |||
| 55 | + | // repo:read on one repository, an expiry), so that every authorization rule | |||
| 56 | + | // of the forge applies to it unchanged: it reads that repository, even when | |||
| 57 | + | // private, and writes nothing. Its TokenID starts with "job:" and matches no | |||
| 58 | + | // stored token. | |||
| 59 | + | func RepoTokenPrincipal(t CIRepoToken) Principal { | |||
| 60 | + | return TokenPrincipal(Token{ | |||
| 61 | + | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | |||
| 62 | + | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // CIActionSource says where an action was found. | |||
| 67 | + | type CIActionSource int | |||
| 68 | + | ||||
| 69 | + | // Action sources, with the numbers of ActionSource in the protocol. | |||
| 70 | + | const ( | |||
| 71 | + | CIActionGitHub CIActionSource = 1 | |||
| 72 | + | CIActionErmite CIActionSource = 2 | |||
| 73 | + | ) | |||
| 74 | + | ||||
| 75 | + | // CIActionResolution is the resolution of an action reference for one run | |||
| 76 | + | // attempt: every job of the attempt gets the same commit. | |||
| 77 | + | type CIActionResolution struct { | |||
| 78 | + | RunID int64 | |||
| 79 | + | RunAttempt int32 | |||
| 80 | + | // Key is "owner/repo@ref", lower-cased. | |||
| 81 | + | Key string | |||
| 82 | + | NameWithOwner string | |||
| 83 | + | ResolvedSHA string | |||
| 84 | + | Source CIActionSource | |||
| 85 | + | } | |||
A internal/domain/ci_token_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 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 | + | } | |||
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. | |
| @@ -36,6 +36,10 @@ var ( | |||||
| 36 | // ErrResourceExhausted means a limit of the server is reached, for example | 36 | // ErrResourceExhausted means a limit of the server is reached, for example | ||
| 37 | // the number of event watchers; the caller may retry later. | 37 | // the number of event watchers; the caller may retry later. | ||
| 38 | ErrResourceExhausted = errors.New("resource exhausted") | 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 | // invalidErrors are the errors that mean a request was malformed. | 45 | // invalidErrors are the errors that mean a request was malformed. | ||
M internal/ports/ci.go +72 -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) | |
| @@ -200,8 +228,10 @@ type RegisteredRunner struct { | |||
| 200 | 228 | ||
| 201 | 229 | // RunnerJob is a job given to a runner. | |
| 202 | 230 | type RunnerJob struct { | |
| 203 | - | Job domain.CIJob | |
| 204 | - | Token string | |
| 231 | + | Job domain.CIJob | |
| 232 | + | Token string | |
| 233 | + | // GithubToken is the job repository token, the "github.token" of the job. | |
| 234 | + | GithubToken string | |
| 205 | 235 | Repo domain.RepoName | |
| 206 | 236 | Sha, Ref string | |
| 207 | 237 | WorkflowPath string | |
| @@ -247,6 +277,38 @@ type CompleteJobResult struct { | |||
| 247 | 277 | Applied bool | |
| 248 | 278 | } | |
| 249 | 279 | ||
| 280 | + | // ResolvedAction is the resolution of one "uses" of ResolveActions: where to | |
| 281 | + | // download the action, or Error (then only Ref is set). | |
| 282 | + | type ResolvedAction struct { | |
| 283 | + | Ref string | |
| 284 | + | NameWithOwner string | |
| 285 | + | Path string | |
| 286 | + | ResolvedSHA string | |
| 287 | + | TarballURL string | |
| 288 | + | Token string | |
| 289 | + | Source domain.CIActionSource | |
| 290 | + | Error string | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // RemoteRef is a ref of a remote repository, as `git ls-remote` lists it. | |
| 294 | + | type RemoteRef struct { | |
| 295 | + | // Name is the full ref name ("refs/heads/main", "refs/tags/v1"). | |
| 296 | + | Name string | |
| 297 | + | // ID is the object the ref points to (the tag object of an annotated tag). | |
| 298 | + | ID string | |
| 299 | + | // Peeled is the commit an annotated tag points to; empty for other refs. | |
| 300 | + | Peeled string | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // GitRemote lists the refs of a repository on github.com, with no | |
| 304 | + | // credentials. It returns domain.ErrNotFound for a repository that does not | |
| 305 | + | // exist or needs credentials (GitHub answers alike), and an error matching | |
| 306 | + | // domain.ErrUnavailable when it cannot tell (network, timeout, too large an | |
| 307 | + | // answer). | |
| 308 | + | type GitRemote interface { | |
| 309 | + | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | |
| 310 | + | } | |
| 311 | + | ||
| 250 | 312 | // JobSignal is one message of WatchJob. | |
| 251 | 313 | type JobSignal struct { | |
| 252 | 314 | Keepalive bool | |
| @@ -278,6 +340,11 @@ type RunnerService interface { | |||
| 278 | 340 | // then, because a client's call returns only when they arrive. | |
| 279 | 341 | WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error | |
| 280 | 342 | CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error) | |
| 343 | + | // ResolveActions resolves the "uses" of the job's steps, one answer per | |
| 344 | + | // reference in the same order. A reference that cannot be resolved is not | |
| 345 | + | // an error: its answer carries Error. A local ("./") or Docker | |
| 346 | + | // ("docker://") reference, or more than 100, is domain.ErrInvalid. | |
| 347 | + | ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error) | |
| 281 | 348 | } | |
| 282 | 349 | ||
| 283 | 350 | // JobView is a job as owners see it. | |
| @@ -25,10 +25,35 @@ type CIRunDraft struct { | |||||
| 25 | // Checks are derived from the jobs and upserted without a precondition. | 25 | // Checks are derived from the jobs and upserted without a precondition. | ||
| 26 | // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the | 26 | // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the | ||
| 27 | // repository no longer exists. | 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 | type CIWrite struct { | 33 | type CIWrite struct { | ||
| 29 | - | Runs []domain.CIRun | 34 | + | Runs []domain.CIRun |
| 30 | - | Jobs []domain.CIJob | 35 | + | Jobs []domain.CIJob |
| 31 | - | Checks []domain.CICheck | 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 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | 59 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | ||
| @@ -46,6 +71,9 @@ type CIStore interface { | |||||
| 46 | Apply(ctx context.Context, w CIWrite) (version uint64, err error) | 71 | Apply(ctx context.Context, w CIWrite) (version uint64, err error) | ||
| 47 | // GetRun returns the run, or domain.ErrNotFound. | 72 | // GetRun returns the run, or domain.ErrNotFound. | ||
| 48 | GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) | 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 | // GetRunSource returns what the run was planned from, or | 77 | // GetRunSource returns what the run was planned from, or | ||
| 50 | // domain.ErrNotFound. It is how a run is found from its id alone. | 78 | // domain.ErrNotFound. It is how a run is found from its id alone. | ||
| 51 | GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) | 79 | GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) | ||
| @@ -200,8 +228,10 @@ type RegisteredRunner struct { | |||||
| 200 | 228 | ||||
| 201 | // RunnerJob is a job given to a runner. | 229 | // RunnerJob is a job given to a runner. | ||
| 202 | type RunnerJob struct { | 230 | type RunnerJob struct { | ||
| 203 | - | Job domain.CIJob | 231 | + | Job domain.CIJob |
| 204 | - | Token string | 232 | + | Token string |
| 233 | + | // GithubToken is the job repository token, the "github.token" of the job. | |||
| 234 | + | GithubToken string | |||
| 205 | Repo domain.RepoName | 235 | Repo domain.RepoName | ||
| 206 | Sha, Ref string | 236 | Sha, Ref string | ||
| 207 | WorkflowPath string | 237 | WorkflowPath string | ||
| @@ -247,6 +277,38 @@ type CompleteJobResult struct { | |||||
| 247 | Applied bool | 277 | Applied bool | ||
| 248 | } | 278 | } | ||
| 249 | 279 | ||||
| 280 | + | // ResolvedAction is the resolution of one "uses" of ResolveActions: where to | |||
| 281 | + | // download the action, or Error (then only Ref is set). | |||
| 282 | + | type ResolvedAction struct { | |||
| 283 | + | Ref string | |||
| 284 | + | NameWithOwner string | |||
| 285 | + | Path string | |||
| 286 | + | ResolvedSHA string | |||
| 287 | + | TarballURL string | |||
| 288 | + | Token string | |||
| 289 | + | Source domain.CIActionSource | |||
| 290 | + | Error string | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // RemoteRef is a ref of a remote repository, as `git ls-remote` lists it. | |||
| 294 | + | type RemoteRef struct { | |||
| 295 | + | // Name is the full ref name ("refs/heads/main", "refs/tags/v1"). | |||
| 296 | + | Name string | |||
| 297 | + | // ID is the object the ref points to (the tag object of an annotated tag). | |||
| 298 | + | ID string | |||
| 299 | + | // Peeled is the commit an annotated tag points to; empty for other refs. | |||
| 300 | + | Peeled string | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // GitRemote lists the refs of a repository on github.com, with no | |||
| 304 | + | // credentials. It returns domain.ErrNotFound for a repository that does not | |||
| 305 | + | // exist or needs credentials (GitHub answers alike), and an error matching | |||
| 306 | + | // domain.ErrUnavailable when it cannot tell (network, timeout, too large an | |||
| 307 | + | // answer). | |||
| 308 | + | type GitRemote interface { | |||
| 309 | + | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 250 | // JobSignal is one message of WatchJob. | 312 | // JobSignal is one message of WatchJob. | ||
| 251 | type JobSignal struct { | 313 | type JobSignal struct { | ||
| 252 | Keepalive bool | 314 | Keepalive bool | ||
| @@ -278,6 +340,11 @@ type RunnerService interface { | |||||
| 278 | // then, because a client's call returns only when they arrive. | 340 | // then, because a client's call returns only when they arrive. | ||
| 279 | WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error | 341 | WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error | ||
| 280 | CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error) | 342 | CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error) | ||
| 343 | + | // ResolveActions resolves the "uses" of the job's steps, one answer per | |||
| 344 | + | // reference in the same order. A reference that cannot be resolved is not | |||
| 345 | + | // an error: its answer carries Error. A local ("./") or Docker | |||
| 346 | + | // ("docker://") reference, or more than 100, is domain.ErrInvalid. | |||
| 347 | + | ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error) | |||
| 281 | } | 348 | } | ||
| 282 | 349 | ||||
| 283 | // JobView is a job as owners see it. | 350 | // 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 | + | } | |
| @@ -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) | |
| @@ -405,6 +405,84 @@ func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Cont | |||||
| 405 | return _c | 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 | // GetJob provides a mock function for the type CIStore | 486 | // GetJob provides a mock function for the type CIStore | ||
| 409 | func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | 487 | func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | ||
| 410 | ret := _mock.Called(ctx, runID, runAttempt, jobID) | 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 | + | } | |
| @@ -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 +288 -0
| @@ -0,0 +1,288 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,288 @@ | |||||
| 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 | + | } | |||
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 | + | } | |
| @@ -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) | |
| @@ -417,6 +417,86 @@ func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context | |||||
| 417 | return _c | 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 | // UpdateJob provides a mock function for the type RunnerService | 500 | // UpdateJob provides a mock function for the type RunnerService | ||
| 421 | func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error { | 501 | func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error { | ||
| 422 | ret := _mock.Called(ctx, secret, jobID, steps) | 502 | ret := _mock.Called(ctx, secret, jobID, steps) | ||
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 { | |
| @@ -200,6 +200,14 @@ type GitBackend interface { | |||||
| 200 | HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error) | 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 | // TokenStore keeps the stored tokens and the index from secret hash to token. | 211 | // TokenStore keeps the stored tokens and the index from secret hash to token. | ||
| 204 | // The secret itself is never stored. | 212 | // The secret itself is never stored. | ||
| 205 | type TokenStore interface { | 213 | type TokenStore interface { | ||
M internal/ports/services.go +18 -0
| @@ -55,6 +55,24 @@ 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. begin is called once, with the file name of the | |
| 71 | + | // archive, before the first byte and not at all when the ref does not | |
| 72 | + | // resolve (domain.ErrNotFound). | |
| 73 | + | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | |
| 74 | + | } | |
| 75 | + | ||
| 58 | 76 | // BrowseService is the read side of a repository's content. *usecase.Browse | |
| 59 | 77 | // implements it. | |
| 60 | 78 | type BrowseService interface { | |
| @@ -55,6 +55,24 @@ type TokenService interface { | |||||
| 55 | Revoke(ctx context.Context, p domain.Principal, id string) error | 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. begin is called once, with the file name of the | |||
| 71 | + | // archive, before the first byte and not at all when the ref does not | |||
| 72 | + | // resolve (domain.ErrNotFound). | |||
| 73 | + | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | |||
| 74 | + | } | |||
| 75 | + | ||||
| 58 | // BrowseService is the read side of a repository's content. *usecase.Browse | 76 | // BrowseService is the read side of a repository's content. *usecase.Browse | ||
| 59 | // implements it. | 77 | // implements it. | ||
| 60 | type BrowseService interface { | 78 | type BrowseService interface { | ||
M internal/usecase/ci.go +29 -9
| @@ -56,6 +56,11 @@ type CIConfig struct { | |||
| 56 | 56 | Clock ports.Clock | |
| 57 | 57 | // Auth re-validates the token of a log stream; nil skips it. | |
| 58 | 58 | Auth ports.Revalidator | |
| 59 | + | // Remote lists the refs of github.com repositories for ResolveActions; nil | |
| 60 | + | // answers every github.com action with unavailable. | |
| 61 | + | Remote ports.GitRemote | |
| 62 | + | // Archive serves the tarballs of the REST subset. | |
| 63 | + | Archive ports.GitArchive | |
| 59 | 64 | // Owner is the owner login of the github context. | |
| 60 | 65 | Owner string | |
| 61 | 66 | // PublicURL is the base URL of the instance (github.server_url). | |
| @@ -122,9 +127,14 @@ type CI struct { | |||
| 122 | 127 | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 123 | 128 | seen map[string]time.Time // runner id -> last seen | |
| 124 | 129 | polls map[string]pollRecord | |
| 125 | - | streams int | |
| 126 | - | perClient map[string]int | |
| 127 | - | closed bool | |
| 130 | + | // archiveTokens are the secrets of the tokens given for the archives of | |
| 131 | + | // ermite actions, per job reference then repository, so that a job that | |
| 132 | + | // resolves the same repository twice gets one token (memory only: a | |
| 133 | + | // restart issues new ones). | |
| 134 | + | archiveTokens map[string]map[domain.RepoName]string | |
| 135 | + | streams int | |
| 136 | + | perClient map[string]int | |
| 137 | + | closed bool | |
| 128 | 138 | } | |
| 129 | 139 | ||
| 130 | 140 | var ( | |
| @@ -137,10 +147,11 @@ var ( | |||
| 137 | 147 | // pollRecord remembers the assignment a poll made, for the retry of a lost | |
| 138 | 148 | // response (FetchJobRequest.poll_id). The token is kept in memory only. | |
| 139 | 149 | type pollRecord struct { | |
| 140 | - | pollID string | |
| 141 | - | ref string | |
| 142 | - | token string | |
| 143 | - | hash domain.TokenHash | |
| 150 | + | pollID string | |
| 151 | + | ref string | |
| 152 | + | token string | |
| 153 | + | ghToken string | |
| 154 | + | hash domain.TokenHash | |
| 144 | 155 | } | |
| 145 | 156 | ||
| 146 | 157 | // NewCI builds the CI use cases. ctx carries the logger; its cancellation is | |
| @@ -155,7 +166,8 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 155 | 166 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | |
| 156 | 167 | ctx: run, stop: stop, flows: newWfCache(64), | |
| 157 | 168 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| 158 | - | perClient: map[string]int{}, | |
| 169 | + | archiveTokens: map[string]map[domain.RepoName]string{}, | |
| 170 | + | perClient: map[string]int{}, | |
| 159 | 171 | } | |
| 160 | 172 | } | |
| 161 | 173 | ||
| @@ -258,6 +270,12 @@ type runState struct { | |||
| 258 | 270 | // chk marks jobs whose check must be written even when the job itself did | |
| 259 | 271 | // not change (a rerun moves every check to the new attempt). | |
| 260 | 272 | checks map[string]bool | |
| 273 | + | // tokens are the job repository tokens to create, revoke the jobs whose | |
| 274 | + | // tokens are deleted, with the next flush. | |
| 275 | + | tokens []domain.CIRepoToken | |
| 276 | + | revoke []ports.CIJobRef | |
| 277 | + | // resolutions are the action resolutions to keep with the next flush. | |
| 278 | + | resolutions []domain.CIActionResolution | |
| 261 | 279 | } | |
| 262 | 280 | ||
| 263 | 281 | // withRun locks the run, loads it with the jobs of attempt (0 for the latest) | |
| @@ -341,7 +359,8 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 341 | 359 | if rs.runDirty { | |
| 342 | 360 | w.Runs = []domain.CIRun{rs.run} | |
| 343 | 361 | } | |
| 344 | - | if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 { | |
| 362 | + | w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions | |
| 363 | + | 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 | 364 | return nil | |
| 346 | 365 | } | |
| 347 | 366 | v, err := c.cfg.Store.Apply(ctx, w) | |
| @@ -356,6 +375,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 356 | 375 | } | |
| 357 | 376 | changedRun := rs.runDirty | |
| 358 | 377 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | |
| 378 | + | rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil | |
| 359 | 379 | ||
| 360 | 380 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | |
| 361 | 381 | if err != nil { | |
| @@ -56,6 +56,11 @@ type CIConfig struct { | |||||
| 56 | Clock ports.Clock | 56 | Clock ports.Clock | ||
| 57 | // Auth re-validates the token of a log stream; nil skips it. | 57 | // Auth re-validates the token of a log stream; nil skips it. | ||
| 58 | Auth ports.Revalidator | 58 | Auth ports.Revalidator | ||
| 59 | + | // Remote lists the refs of github.com repositories for ResolveActions; nil | |||
| 60 | + | // answers every github.com action with unavailable. | |||
| 61 | + | Remote ports.GitRemote | |||
| 62 | + | // Archive serves the tarballs of the REST subset. | |||
| 63 | + | Archive ports.GitArchive | |||
| 59 | // Owner is the owner login of the github context. | 64 | // Owner is the owner login of the github context. | ||
| 60 | Owner string | 65 | Owner string | ||
| 61 | // PublicURL is the base URL of the instance (github.server_url). | 66 | // PublicURL is the base URL of the instance (github.server_url). | ||
| @@ -122,9 +127,14 @@ type CI struct { | |||||
| 122 | heartbeats map[string]time.Time // job reference -> last heartbeat | 127 | heartbeats map[string]time.Time // job reference -> last heartbeat | ||
| 123 | seen map[string]time.Time // runner id -> last seen | 128 | seen map[string]time.Time // runner id -> last seen | ||
| 124 | polls map[string]pollRecord | 129 | polls map[string]pollRecord | ||
| 125 | - | streams int | 130 | + | // archiveTokens are the secrets of the tokens given for the archives of |
| 126 | - | perClient map[string]int | 131 | + | // ermite actions, per job reference then repository, so that a job that |
| 127 | - | closed bool | 132 | + | // resolves the same repository twice gets one token (memory only: a |
| 133 | + | // restart issues new ones). | |||
| 134 | + | archiveTokens map[string]map[domain.RepoName]string | |||
| 135 | + | streams int | |||
| 136 | + | perClient map[string]int | |||
| 137 | + | closed bool | |||
| 128 | } | 138 | } | ||
| 129 | 139 | ||||
| 130 | var ( | 140 | var ( | ||
| @@ -137,10 +147,11 @@ var ( | |||||
| 137 | // pollRecord remembers the assignment a poll made, for the retry of a lost | 147 | // pollRecord remembers the assignment a poll made, for the retry of a lost | ||
| 138 | // response (FetchJobRequest.poll_id). The token is kept in memory only. | 148 | // response (FetchJobRequest.poll_id). The token is kept in memory only. | ||
| 139 | type pollRecord struct { | 149 | type pollRecord struct { | ||
| 140 | - | pollID string | 150 | + | pollID string |
| 141 | - | ref string | 151 | + | ref string |
| 142 | - | token string | 152 | + | token string |
| 143 | - | hash domain.TokenHash | 153 | + | ghToken string |
| 154 | + | hash domain.TokenHash | |||
| 144 | } | 155 | } | ||
| 145 | 156 | ||||
| 146 | // NewCI builds the CI use cases. ctx carries the logger; its cancellation is | 157 | // NewCI builds the CI use cases. ctx carries the logger; its cancellation is | ||
| @@ -155,7 +166,8 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||||
| 155 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | 166 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | ||
| 156 | ctx: run, stop: stop, flows: newWfCache(64), | 167 | ctx: run, stop: stop, flows: newWfCache(64), | ||
| 157 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | 168 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | ||
| 158 | - | perClient: map[string]int{}, | 169 | + | archiveTokens: map[string]map[domain.RepoName]string{}, |
| 170 | + | perClient: map[string]int{}, | |||
| 159 | } | 171 | } | ||
| 160 | } | 172 | } | ||
| 161 | 173 | ||||
| @@ -258,6 +270,12 @@ type runState struct { | |||||
| 258 | // chk marks jobs whose check must be written even when the job itself did | 270 | // chk marks jobs whose check must be written even when the job itself did | ||
| 259 | // not change (a rerun moves every check to the new attempt). | 271 | // not change (a rerun moves every check to the new attempt). | ||
| 260 | checks map[string]bool | 272 | checks map[string]bool | ||
| 273 | + | // tokens are the job repository tokens to create, revoke the jobs whose | |||
| 274 | + | // tokens are deleted, with the next flush. | |||
| 275 | + | tokens []domain.CIRepoToken | |||
| 276 | + | revoke []ports.CIJobRef | |||
| 277 | + | // resolutions are the action resolutions to keep with the next flush. | |||
| 278 | + | resolutions []domain.CIActionResolution | |||
| 261 | } | 279 | } | ||
| 262 | 280 | ||||
| 263 | // withRun locks the run, loads it with the jobs of attempt (0 for the latest) | 281 | // withRun locks the run, loads it with the jobs of attempt (0 for the latest) | ||
| @@ -341,7 +359,8 @@ func (rs *runState) flush(ctx context.Context) error { | |||||
| 341 | if rs.runDirty { | 359 | if rs.runDirty { | ||
| 342 | w.Runs = []domain.CIRun{rs.run} | 360 | w.Runs = []domain.CIRun{rs.run} | ||
| 343 | } | 361 | } | ||
| 344 | - | if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 { | 362 | + | w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions |
| 363 | + | 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 | return nil | 364 | return nil | ||
| 346 | } | 365 | } | ||
| 347 | v, err := c.cfg.Store.Apply(ctx, w) | 366 | v, err := c.cfg.Store.Apply(ctx, w) | ||
| @@ -356,6 +375,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||||
| 356 | } | 375 | } | ||
| 357 | changedRun := rs.runDirty | 376 | changedRun := rs.runDirty | ||
| 358 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | 377 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | ||
| 378 | + | rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil | |||
| 359 | 379 | ||||
| 360 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | 380 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | ||
| 361 | if err != nil { | 381 | if err != nil { | ||
A internal/usecase/ci_actions.go +359 -0
| @@ -0,0 +1,359 @@ | |||
| 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 | + | j, err := c.jobFor(ctx, secret, jobID) | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | if j.Ended() { | |
| 39 | + | return nil, endedError(j) | |
| 40 | + | } | |
| 41 | + | if len(uses) > maxActionRefs { | |
| 42 | + | return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs) | |
| 43 | + | } | |
| 44 | + | parsed := make([]actionRef, len(uses)) | |
| 45 | + | for i, u := range uses { | |
| 46 | + | if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") { | |
| 47 | + | return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u) | |
| 48 | + | } | |
| 49 | + | if strings.HasPrefix(u, "docker://") { | |
| 50 | + | return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u) | |
| 51 | + | } | |
| 52 | + | parsed[i] = parseActionRef(u) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Resolve what the attempt has not resolved yet, outside the lock of the | |
| 56 | + | // run: a ls-remote takes seconds and must not hold up the job's calls. | |
| 57 | + | found := map[string]domain.CIActionResolution{} | |
| 58 | + | fails := map[string]string{} | |
| 59 | + | remote := newRemoteRefs(c.cfg.Remote) | |
| 60 | + | for _, p := range parsed { | |
| 61 | + | if p.err != "" { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | if _, done := found[p.key()]; done { | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | if _, bad := fails[p.key()]; bad { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil { | |
| 71 | + | found[p.key()] = res | |
| 72 | + | continue | |
| 73 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | res, msg, err := c.resolveAction(ctx, j, p, remote) | |
| 77 | + | if err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | if msg != "" { | |
| 81 | + | fails[p.key()] = msg | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | found[p.key()] = res | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | out := make([]ports.ResolvedAction, len(parsed)) | |
| 88 | + | err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 89 | + | if cur.Ended() { | |
| 90 | + | return endedError(*cur) | |
| 91 | + | } | |
| 92 | + | rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt) | |
| 93 | + | if err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | own := rs.find(cur.ID) | |
| 97 | + | if own == nil { | |
| 98 | + | return domain.ErrNotFound | |
| 99 | + | } | |
| 100 | + | for i, p := range parsed { | |
| 101 | + | out[i] = ports.ResolvedAction{Ref: p.uses} | |
| 102 | + | if p.err != "" { | |
| 103 | + | out[i].Error = p.err | |
| 104 | + | continue | |
| 105 | + | } | |
| 106 | + | if msg, bad := fails[p.key()]; bad { | |
| 107 | + | out[i].Error = msg | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | // The first resolution stored wins, ours or a concurrent job's. | |
| 111 | + | res, ok := found[p.key()] | |
| 112 | + | if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil { | |
| 113 | + | res, ok = cached, true | |
| 114 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 115 | + | return err | |
| 116 | + | } else if ok { | |
| 117 | + | rs.resolutions = append(rs.resolutions, res) | |
| 118 | + | found[p.key()] = res // two refs of the call with the same key | |
| 119 | + | } | |
| 120 | + | if !ok { | |
| 121 | + | continue | |
| 122 | + | } | |
| 123 | + | ra, err := c.answer(ctx, rs, own, p, res) | |
| 124 | + | if err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | out[i] = ra | |
| 128 | + | } | |
| 129 | + | if err := rs.flush(ctx); err != nil { | |
| 130 | + | // The tokens were not stored: forget their secrets. | |
| 131 | + | c.mu.Lock() | |
| 132 | + | delete(c.archiveTokens, jobRef(*cur)) | |
| 133 | + | c.mu.Unlock() | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | return nil | |
| 137 | + | }) | |
| 138 | + | if err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } | |
| 141 | + | return out, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // maxActionRefs is the most references of one ResolveActions call. | |
| 145 | + | const maxActionRefs = 100 | |
| 146 | + | ||
| 147 | + | // answer builds the answer for a resolved reference. The archive token of an | |
| 148 | + | // ermite action is the one the job already holds for that repository, else a | |
| 149 | + | // new one, written with the next flush. | |
| 150 | + | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) { | |
| 151 | + | ra := ports.ResolvedAction{ | |
| 152 | + | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | |
| 153 | + | } | |
| 154 | + | switch res.Source { | |
| 155 | + | case domain.CIActionGitHub: | |
| 156 | + | // The case the workflow wrote: the stored resolution is shared by | |
| 157 | + | // spellings that differ only by case, and github.com ignores it. | |
| 158 | + | ra.NameWithOwner = p.owner + "/" + p.repo | |
| 159 | + | ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA | |
| 160 | + | case domain.CIActionErmite: | |
| 161 | + | name, err := domain.ParseRepoName(strings.ToLower(p.repo)) | |
| 162 | + | if err != nil { | |
| 163 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |
| 164 | + | } | |
| 165 | + | if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) { | |
| 166 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |
| 167 | + | } else if err != nil { | |
| 168 | + | return ports.ResolvedAction{}, err | |
| 169 | + | } | |
| 170 | + | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | |
| 171 | + | ra.Token = c.archiveToken(rs, j, name) | |
| 172 | + | } | |
| 173 | + | return ra, nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // archiveToken returns the secret of the read token of the job for the | |
| 177 | + | // archives of repo, issuing it when the job has none. | |
| 178 | + | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string { | |
| 179 | + | ref := jobRef(*j) | |
| 180 | + | c.mu.Lock() | |
| 181 | + | tok, ok := c.archiveTokens[ref][repo] | |
| 182 | + | c.mu.Unlock() | |
| 183 | + | if ok { | |
| 184 | + | return tok | |
| 185 | + | } | |
| 186 | + | tok = rs.issueRepoTokenFor(j, repo, true) | |
| 187 | + | c.mu.Lock() | |
| 188 | + | if c.archiveTokens[ref] == nil { | |
| 189 | + | c.archiveTokens[ref] = map[domain.RepoName]string{} | |
| 190 | + | } | |
| 191 | + | c.archiveTokens[ref][repo] = tok | |
| 192 | + | c.mu.Unlock() | |
| 193 | + | return tok | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // actionRef is a parsed "uses": owner/repo[/path]@ref. | |
| 197 | + | type actionRef struct { | |
| 198 | + | uses, owner, repo, path, ref string | |
| 199 | + | // err is GitHub's message for a reference of the wrong shape. | |
| 200 | + | err string | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // key identifies the resolution: GitHub names are case-insensitive, refs are | |
| 204 | + | // not. | |
| 205 | + | func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref } | |
| 206 | + | ||
| 207 | + | // parseActionRef splits a "uses" of a repository action. Only the shape | |
| 208 | + | // owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and | |
| 209 | + | // ignores empty segments (WorkflowTemplateConverter.cs), so do we. | |
| 210 | + | func parseActionRef(uses string) actionRef { | |
| 211 | + | a := actionRef{uses: uses} | |
| 212 | + | left, ref, ok := strings.Cut(uses, "@") | |
| 213 | + | segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' }) | |
| 214 | + | if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" { | |
| 215 | + | a.err = errActionFormat(uses) | |
| 216 | + | return a | |
| 217 | + | } | |
| 218 | + | for _, s := range segs[2:] { | |
| 219 | + | if s == "." || s == ".." { | |
| 220 | + | a.err = errActionFormat(uses) | |
| 221 | + | return a | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | a.owner, a.repo, a.ref = segs[0], segs[1], ref | |
| 225 | + | a.path = strings.Join(segs[2:], "/") | |
| 226 | + | return a | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // --- GitHub's texts --- | |
| 230 | + | // | |
| 231 | + | // All the messages a reference can fail with are here. The first is the | |
| 232 | + | // runner's own parser text; the two others are the ones GitHub gives for an | |
| 233 | + | // unknown version and an unknown repository, to be confirmed by recording | |
| 234 | + | // against github.com (ci2-execution plan, phase A). | |
| 235 | + | ||
| 236 | + | func errActionFormat(uses string) string { | |
| 237 | + | return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'" | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func errUnresolvedRef(a actionRef) string { | |
| 241 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`" | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func errUnresolvedRepo(a actionRef) string { | |
| 245 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found" | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // resolveAction resolves one reference. msg is the message of a reference that | |
| 249 | + | // does not resolve; err is a failure of the call. | |
| 250 | + | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) { | |
| 251 | + | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | |
| 252 | + | if strings.EqualFold(a.owner, c.cfg.Owner) { | |
| 253 | + | if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil { | |
| 254 | + | if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { | |
| 255 | + | sha, found, err := c.resolveOnErmite(ctx, name, a.ref) | |
| 256 | + | if err != nil { | |
| 257 | + | return res, "", err | |
| 258 | + | } | |
| 259 | + | if !found { | |
| 260 | + | return res, errUnresolvedRef(a), nil | |
| 261 | + | } | |
| 262 | + | res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite | |
| 263 | + | return res, "", nil | |
| 264 | + | } else if !errors.Is(gerr, domain.ErrNotFound) { | |
| 265 | + | return res, "", gerr | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub | |
| 270 | + | if low := strings.ToLower(a.ref); isHex40(low) { | |
| 271 | + | res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download | |
| 272 | + | return res, "", nil | |
| 273 | + | } | |
| 274 | + | refs, err := remote.list(ctx, a.owner, a.repo) | |
| 275 | + | switch { | |
| 276 | + | case errors.Is(err, domain.ErrNotFound): | |
| 277 | + | return res, errUnresolvedRepo(a), nil | |
| 278 | + | case err != nil: | |
| 279 | + | return res, "", err | |
| 280 | + | } | |
| 281 | + | sha := pickRemoteRef(refs, a.ref) | |
| 282 | + | if sha == "" { | |
| 283 | + | return res, errUnresolvedRef(a), nil | |
| 284 | + | } | |
| 285 | + | res.ResolvedSHA = sha | |
| 286 | + | return res, "", nil | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a | |
| 290 | + | // remote. An annotated tag gives its peeled commit. | |
| 291 | + | func pickRemoteRef(refs []ports.RemoteRef, name string) string { | |
| 292 | + | for _, prefix := range []string{"refs/tags/", "refs/heads/"} { | |
| 293 | + | for _, r := range refs { | |
| 294 | + | if r.Name != prefix+name { | |
| 295 | + | continue | |
| 296 | + | } | |
| 297 | + | if r.Peeled != "" { | |
| 298 | + | return r.Peeled | |
| 299 | + | } | |
| 300 | + | return r.ID | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | return "" | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // resolveOnErmite resolves a ref on a repository of this server: a tag, else | |
| 307 | + | // a branch, else a full commit id that exists. | |
| 308 | + | func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) { | |
| 309 | + | refs, err := c.cfg.Reader.ListRefs(ctx, repo) | |
| 310 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 311 | + | return "", false, err | |
| 312 | + | } | |
| 313 | + | for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} { | |
| 314 | + | i := slices.IndexFunc(refs, func(r domain.Ref) bool { | |
| 315 | + | return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero() | |
| 316 | + | }) | |
| 317 | + | if i >= 0 { | |
| 318 | + | return refs[i].CommitID.String(), true, nil | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | if id, perr := domain.ParseObjectID(ref); perr == nil { | |
| 322 | + | ok, err := c.cfg.Git.HasCommit(ctx, repo, id) | |
| 323 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 324 | + | return "", false, err | |
| 325 | + | } | |
| 326 | + | if ok { | |
| 327 | + | return id.String(), true, nil | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | return "", false, nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // remoteRefs lists the refs of a remote once per repository within a call. | |
| 334 | + | type remoteRefs struct { | |
| 335 | + | remote ports.GitRemote | |
| 336 | + | seen map[string]remoteList | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | type remoteList struct { | |
| 340 | + | refs []ports.RemoteRef | |
| 341 | + | err error | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func newRemoteRefs(r ports.GitRemote) *remoteRefs { | |
| 345 | + | return &remoteRefs{remote: r, seen: map[string]remoteList{}} | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 349 | + | if r.remote == nil { | |
| 350 | + | return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable) | |
| 351 | + | } | |
| 352 | + | key := strings.ToLower(owner + "/" + repo) | |
| 353 | + | if l, ok := r.seen[key]; ok { | |
| 354 | + | return l.refs, l.err | |
| 355 | + | } | |
| 356 | + | refs, err := r.remote.ListRefs(ctx, owner, repo) | |
| 357 | + | r.seen[key] = remoteList{refs, err} | |
| 358 | + | return refs, err | |
| 359 | + | } | |
| @@ -0,0 +1,359 @@ | |||||
| 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 | + | j, err := c.jobFor(ctx, secret, jobID) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | if j.Ended() { | |||
| 39 | + | return nil, endedError(j) | |||
| 40 | + | } | |||
| 41 | + | if len(uses) > maxActionRefs { | |||
| 42 | + | return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs) | |||
| 43 | + | } | |||
| 44 | + | parsed := make([]actionRef, len(uses)) | |||
| 45 | + | for i, u := range uses { | |||
| 46 | + | if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") { | |||
| 47 | + | return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u) | |||
| 48 | + | } | |||
| 49 | + | if strings.HasPrefix(u, "docker://") { | |||
| 50 | + | return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u) | |||
| 51 | + | } | |||
| 52 | + | parsed[i] = parseActionRef(u) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Resolve what the attempt has not resolved yet, outside the lock of the | |||
| 56 | + | // run: a ls-remote takes seconds and must not hold up the job's calls. | |||
| 57 | + | found := map[string]domain.CIActionResolution{} | |||
| 58 | + | fails := map[string]string{} | |||
| 59 | + | remote := newRemoteRefs(c.cfg.Remote) | |||
| 60 | + | for _, p := range parsed { | |||
| 61 | + | if p.err != "" { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | if _, done := found[p.key()]; done { | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | if _, bad := fails[p.key()]; bad { | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil { | |||
| 71 | + | found[p.key()] = res | |||
| 72 | + | continue | |||
| 73 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | res, msg, err := c.resolveAction(ctx, j, p, remote) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | if msg != "" { | |||
| 81 | + | fails[p.key()] = msg | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | found[p.key()] = res | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | out := make([]ports.ResolvedAction, len(parsed)) | |||
| 88 | + | err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |||
| 89 | + | if cur.Ended() { | |||
| 90 | + | return endedError(*cur) | |||
| 91 | + | } | |||
| 92 | + | rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | own := rs.find(cur.ID) | |||
| 97 | + | if own == nil { | |||
| 98 | + | return domain.ErrNotFound | |||
| 99 | + | } | |||
| 100 | + | for i, p := range parsed { | |||
| 101 | + | out[i] = ports.ResolvedAction{Ref: p.uses} | |||
| 102 | + | if p.err != "" { | |||
| 103 | + | out[i].Error = p.err | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | if msg, bad := fails[p.key()]; bad { | |||
| 107 | + | out[i].Error = msg | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | // The first resolution stored wins, ours or a concurrent job's. | |||
| 111 | + | res, ok := found[p.key()] | |||
| 112 | + | if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil { | |||
| 113 | + | res, ok = cached, true | |||
| 114 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |||
| 115 | + | return err | |||
| 116 | + | } else if ok { | |||
| 117 | + | rs.resolutions = append(rs.resolutions, res) | |||
| 118 | + | found[p.key()] = res // two refs of the call with the same key | |||
| 119 | + | } | |||
| 120 | + | if !ok { | |||
| 121 | + | continue | |||
| 122 | + | } | |||
| 123 | + | ra, err := c.answer(ctx, rs, own, p, res) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | out[i] = ra | |||
| 128 | + | } | |||
| 129 | + | if err := rs.flush(ctx); err != nil { | |||
| 130 | + | // The tokens were not stored: forget their secrets. | |||
| 131 | + | c.mu.Lock() | |||
| 132 | + | delete(c.archiveTokens, jobRef(*cur)) | |||
| 133 | + | c.mu.Unlock() | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | return nil | |||
| 137 | + | }) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return nil, err | |||
| 140 | + | } | |||
| 141 | + | return out, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // maxActionRefs is the most references of one ResolveActions call. | |||
| 145 | + | const maxActionRefs = 100 | |||
| 146 | + | ||||
| 147 | + | // answer builds the answer for a resolved reference. The archive token of an | |||
| 148 | + | // ermite action is the one the job already holds for that repository, else a | |||
| 149 | + | // new one, written with the next flush. | |||
| 150 | + | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) { | |||
| 151 | + | ra := ports.ResolvedAction{ | |||
| 152 | + | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | |||
| 153 | + | } | |||
| 154 | + | switch res.Source { | |||
| 155 | + | case domain.CIActionGitHub: | |||
| 156 | + | // The case the workflow wrote: the stored resolution is shared by | |||
| 157 | + | // spellings that differ only by case, and github.com ignores it. | |||
| 158 | + | ra.NameWithOwner = p.owner + "/" + p.repo | |||
| 159 | + | ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA | |||
| 160 | + | case domain.CIActionErmite: | |||
| 161 | + | name, err := domain.ParseRepoName(strings.ToLower(p.repo)) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |||
| 164 | + | } | |||
| 165 | + | if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) { | |||
| 166 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |||
| 167 | + | } else if err != nil { | |||
| 168 | + | return ports.ResolvedAction{}, err | |||
| 169 | + | } | |||
| 170 | + | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | |||
| 171 | + | ra.Token = c.archiveToken(rs, j, name) | |||
| 172 | + | } | |||
| 173 | + | return ra, nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // archiveToken returns the secret of the read token of the job for the | |||
| 177 | + | // archives of repo, issuing it when the job has none. | |||
| 178 | + | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string { | |||
| 179 | + | ref := jobRef(*j) | |||
| 180 | + | c.mu.Lock() | |||
| 181 | + | tok, ok := c.archiveTokens[ref][repo] | |||
| 182 | + | c.mu.Unlock() | |||
| 183 | + | if ok { | |||
| 184 | + | return tok | |||
| 185 | + | } | |||
| 186 | + | tok = rs.issueRepoTokenFor(j, repo, true) | |||
| 187 | + | c.mu.Lock() | |||
| 188 | + | if c.archiveTokens[ref] == nil { | |||
| 189 | + | c.archiveTokens[ref] = map[domain.RepoName]string{} | |||
| 190 | + | } | |||
| 191 | + | c.archiveTokens[ref][repo] = tok | |||
| 192 | + | c.mu.Unlock() | |||
| 193 | + | return tok | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // actionRef is a parsed "uses": owner/repo[/path]@ref. | |||
| 197 | + | type actionRef struct { | |||
| 198 | + | uses, owner, repo, path, ref string | |||
| 199 | + | // err is GitHub's message for a reference of the wrong shape. | |||
| 200 | + | err string | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // key identifies the resolution: GitHub names are case-insensitive, refs are | |||
| 204 | + | // not. | |||
| 205 | + | func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref } | |||
| 206 | + | ||||
| 207 | + | // parseActionRef splits a "uses" of a repository action. Only the shape | |||
| 208 | + | // owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and | |||
| 209 | + | // ignores empty segments (WorkflowTemplateConverter.cs), so do we. | |||
| 210 | + | func parseActionRef(uses string) actionRef { | |||
| 211 | + | a := actionRef{uses: uses} | |||
| 212 | + | left, ref, ok := strings.Cut(uses, "@") | |||
| 213 | + | segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' }) | |||
| 214 | + | if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" { | |||
| 215 | + | a.err = errActionFormat(uses) | |||
| 216 | + | return a | |||
| 217 | + | } | |||
| 218 | + | for _, s := range segs[2:] { | |||
| 219 | + | if s == "." || s == ".." { | |||
| 220 | + | a.err = errActionFormat(uses) | |||
| 221 | + | return a | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | a.owner, a.repo, a.ref = segs[0], segs[1], ref | |||
| 225 | + | a.path = strings.Join(segs[2:], "/") | |||
| 226 | + | return a | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // --- GitHub's texts --- | |||
| 230 | + | // | |||
| 231 | + | // All the messages a reference can fail with are here. The first is the | |||
| 232 | + | // runner's own parser text; the two others are the ones GitHub gives for an | |||
| 233 | + | // unknown version and an unknown repository, to be confirmed by recording | |||
| 234 | + | // against github.com (ci2-execution plan, phase A). | |||
| 235 | + | ||||
| 236 | + | func errActionFormat(uses string) string { | |||
| 237 | + | return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'" | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func errUnresolvedRef(a actionRef) string { | |||
| 241 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`" | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func errUnresolvedRepo(a actionRef) string { | |||
| 245 | + | return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found" | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // resolveAction resolves one reference. msg is the message of a reference that | |||
| 249 | + | // does not resolve; err is a failure of the call. | |||
| 250 | + | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) { | |||
| 251 | + | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | |||
| 252 | + | if strings.EqualFold(a.owner, c.cfg.Owner) { | |||
| 253 | + | if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil { | |||
| 254 | + | if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { | |||
| 255 | + | sha, found, err := c.resolveOnErmite(ctx, name, a.ref) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return res, "", err | |||
| 258 | + | } | |||
| 259 | + | if !found { | |||
| 260 | + | return res, errUnresolvedRef(a), nil | |||
| 261 | + | } | |||
| 262 | + | res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite | |||
| 263 | + | return res, "", nil | |||
| 264 | + | } else if !errors.Is(gerr, domain.ErrNotFound) { | |||
| 265 | + | return res, "", gerr | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub | |||
| 270 | + | if low := strings.ToLower(a.ref); isHex40(low) { | |||
| 271 | + | res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download | |||
| 272 | + | return res, "", nil | |||
| 273 | + | } | |||
| 274 | + | refs, err := remote.list(ctx, a.owner, a.repo) | |||
| 275 | + | switch { | |||
| 276 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 277 | + | return res, errUnresolvedRepo(a), nil | |||
| 278 | + | case err != nil: | |||
| 279 | + | return res, "", err | |||
| 280 | + | } | |||
| 281 | + | sha := pickRemoteRef(refs, a.ref) | |||
| 282 | + | if sha == "" { | |||
| 283 | + | return res, errUnresolvedRef(a), nil | |||
| 284 | + | } | |||
| 285 | + | res.ResolvedSHA = sha | |||
| 286 | + | return res, "", nil | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a | |||
| 290 | + | // remote. An annotated tag gives its peeled commit. | |||
| 291 | + | func pickRemoteRef(refs []ports.RemoteRef, name string) string { | |||
| 292 | + | for _, prefix := range []string{"refs/tags/", "refs/heads/"} { | |||
| 293 | + | for _, r := range refs { | |||
| 294 | + | if r.Name != prefix+name { | |||
| 295 | + | continue | |||
| 296 | + | } | |||
| 297 | + | if r.Peeled != "" { | |||
| 298 | + | return r.Peeled | |||
| 299 | + | } | |||
| 300 | + | return r.ID | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | return "" | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // resolveOnErmite resolves a ref on a repository of this server: a tag, else | |||
| 307 | + | // a branch, else a full commit id that exists. | |||
| 308 | + | func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) { | |||
| 309 | + | refs, err := c.cfg.Reader.ListRefs(ctx, repo) | |||
| 310 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 311 | + | return "", false, err | |||
| 312 | + | } | |||
| 313 | + | for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} { | |||
| 314 | + | i := slices.IndexFunc(refs, func(r domain.Ref) bool { | |||
| 315 | + | return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero() | |||
| 316 | + | }) | |||
| 317 | + | if i >= 0 { | |||
| 318 | + | return refs[i].CommitID.String(), true, nil | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | if id, perr := domain.ParseObjectID(ref); perr == nil { | |||
| 322 | + | ok, err := c.cfg.Git.HasCommit(ctx, repo, id) | |||
| 323 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 324 | + | return "", false, err | |||
| 325 | + | } | |||
| 326 | + | if ok { | |||
| 327 | + | return id.String(), true, nil | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | return "", false, nil | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // remoteRefs lists the refs of a remote once per repository within a call. | |||
| 334 | + | type remoteRefs struct { | |||
| 335 | + | remote ports.GitRemote | |||
| 336 | + | seen map[string]remoteList | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | type remoteList struct { | |||
| 340 | + | refs []ports.RemoteRef | |||
| 341 | + | err error | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func newRemoteRefs(r ports.GitRemote) *remoteRefs { | |||
| 345 | + | return &remoteRefs{remote: r, seen: map[string]remoteList{}} | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |||
| 349 | + | if r.remote == nil { | |||
| 350 | + | return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable) | |||
| 351 | + | } | |||
| 352 | + | key := strings.ToLower(owner + "/" + repo) | |||
| 353 | + | if l, ok := r.seen[key]; ok { | |||
| 354 | + | return l.refs, l.err | |||
| 355 | + | } | |||
| 356 | + | refs, err := r.remote.ListRefs(ctx, owner, repo) | |||
| 357 | + | r.seen[key] = remoteList{refs, err} | |||
| 358 | + | return refs, err | |||
| 359 | + | } | |||
A internal/usecase/ci_actions_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestParseActionRef(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | tests := []struct { | |
| 16 | + | uses string | |
| 17 | + | owner, repo, path, ref, fail string | |
| 18 | + | }{ | |
| 19 | + | {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"}, | |
| 20 | + | {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"}, | |
| 21 | + | {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"}, | |
| 22 | + | {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators | |
| 23 | + | {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments | |
| 24 | + | {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"}, | |
| 25 | + | {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"}, | |
| 26 | + | {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"}, | |
| 27 | + | {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"}, | |
| 28 | + | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | |
| 29 | + | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | |
| 30 | + | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | |
| 31 | + | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | |
| 32 | + | } | |
| 33 | + | for _, tt := range tests { | |
| 34 | + | t.Run(tt.uses, func(t *testing.T) { | |
| 35 | + | t.Parallel() | |
| 36 | + | a := parseActionRef(tt.uses) | |
| 37 | + | assert.Equal(t, tt.fail, a.err) | |
| 38 | + | if tt.fail == "" { | |
| 39 | + | assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref}) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive") | |
| 44 | + | assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestPickRemoteRef(t *testing.T) { | |
| 48 | + | t.Parallel() | |
| 49 | + | refs := []ports.RemoteRef{ | |
| 50 | + | {Name: "refs/heads/v1", ID: "b1"}, | |
| 51 | + | {Name: "refs/tags/v1", ID: "t1"}, | |
| 52 | + | {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"}, | |
| 53 | + | {Name: "refs/heads/main", ID: "m"}, | |
| 54 | + | } | |
| 55 | + | assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch") | |
| 56 | + | assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag") | |
| 57 | + | assert.Equal(t, "m", pickRemoteRef(refs, "main")) | |
| 58 | + | assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only") | |
| 59 | + | assert.Empty(t, pickRemoteRef(refs, "nope")) | |
| 60 | + | assert.Empty(t, pickRemoteRef(nil, "v1")) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestParseActionRef(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | tests := []struct { | |||
| 16 | + | uses string | |||
| 17 | + | owner, repo, path, ref, fail string | |||
| 18 | + | }{ | |||
| 19 | + | {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"}, | |||
| 20 | + | {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"}, | |||
| 21 | + | {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"}, | |||
| 22 | + | {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators | |||
| 23 | + | {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments | |||
| 24 | + | {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"}, | |||
| 25 | + | {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"}, | |||
| 26 | + | {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"}, | |||
| 27 | + | {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"}, | |||
| 28 | + | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | |||
| 29 | + | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | |||
| 30 | + | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | |||
| 31 | + | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | |||
| 32 | + | } | |||
| 33 | + | for _, tt := range tests { | |||
| 34 | + | t.Run(tt.uses, func(t *testing.T) { | |||
| 35 | + | t.Parallel() | |||
| 36 | + | a := parseActionRef(tt.uses) | |||
| 37 | + | assert.Equal(t, tt.fail, a.err) | |||
| 38 | + | if tt.fail == "" { | |||
| 39 | + | assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref}) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive") | |||
| 44 | + | assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestPickRemoteRef(t *testing.T) { | |||
| 48 | + | t.Parallel() | |||
| 49 | + | refs := []ports.RemoteRef{ | |||
| 50 | + | {Name: "refs/heads/v1", ID: "b1"}, | |||
| 51 | + | {Name: "refs/tags/v1", ID: "t1"}, | |||
| 52 | + | {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"}, | |||
| 53 | + | {Name: "refs/heads/main", ID: "m"}, | |||
| 54 | + | } | |||
| 55 | + | assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch") | |||
| 56 | + | assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag") | |||
| 57 | + | assert.Equal(t, "m", pickRemoteRef(refs, "main")) | |||
| 58 | + | assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only") | |||
| 59 | + | assert.Empty(t, pickRemoteRef(refs, "nope")) | |||
| 60 | + | assert.Empty(t, pickRemoteRef(nil, "v1")) | |||
| 61 | + | } | |||
A internal/usecase/ci_repotoken.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 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, archive bool) string { | |
| 40 | + | return rs.issueRepoTokenFor(j, rs.run.Repo, archive) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // issueRepoTokenFor is issueRepoToken for a token that reads repo. | |
| 44 | + | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string { | |
| 45 | + | c := rs.c | |
| 46 | + | now := c.now() | |
| 47 | + | at := *j | |
| 48 | + | if at.AssignedAt.IsZero() { | |
| 49 | + | at.AssignedAt = now | |
| 50 | + | } | |
| 51 | + | secret := newToken(domain.RepoTokenPrefix) | |
| 52 | + | t := c.repoTokenExpiry(at) | |
| 53 | + | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | |
| 54 | + | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now | |
| 55 | + | rs.tokens = append(rs.tokens, t) | |
| 56 | + | return secret | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // revokeTokens deletes, with the next flush, every repository token of j, and | |
| 60 | + | // forgets the archive tokens kept in memory. | |
| 61 | + | func (rs *runState) revokeTokens(j *domain.CIJob) { | |
| 62 | + | ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID} | |
| 63 | + | if !slices.Contains(rs.revoke, ref) { | |
| 64 | + | rs.revoke = append(rs.revoke, ref) | |
| 65 | + | } | |
| 66 | + | rs.c.mu.Lock() | |
| 67 | + | delete(rs.c.archiveTokens, jobRef(*j)) | |
| 68 | + | rs.c.mu.Unlock() | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 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, archive bool) string { | |||
| 40 | + | return rs.issueRepoTokenFor(j, rs.run.Repo, archive) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // issueRepoTokenFor is issueRepoToken for a token that reads repo. | |||
| 44 | + | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string { | |||
| 45 | + | c := rs.c | |||
| 46 | + | now := c.now() | |||
| 47 | + | at := *j | |||
| 48 | + | if at.AssignedAt.IsZero() { | |||
| 49 | + | at.AssignedAt = now | |||
| 50 | + | } | |||
| 51 | + | secret := newToken(domain.RepoTokenPrefix) | |||
| 52 | + | t := c.repoTokenExpiry(at) | |||
| 53 | + | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | |||
| 54 | + | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now | |||
| 55 | + | rs.tokens = append(rs.tokens, t) | |||
| 56 | + | return secret | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // revokeTokens deletes, with the next flush, every repository token of j, and | |||
| 60 | + | // forgets the archive tokens kept in memory. | |||
| 61 | + | func (rs *runState) revokeTokens(j *domain.CIJob) { | |||
| 62 | + | ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID} | |||
| 63 | + | if !slices.Contains(rs.revoke, ref) { | |||
| 64 | + | rs.revoke = append(rs.revoke, ref) | |||
| 65 | + | } | |||
| 66 | + | rs.c.mu.Lock() | |||
| 67 | + | delete(rs.c.archiveTokens, jobRef(*j)) | |||
| 68 | + | rs.c.mu.Unlock() | |||
| 69 | + | } | |||
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, false) | |
| 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 | |
| @@ -207,16 +207,16 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri | |||||
| 207 | if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID { | 207 | if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID { | ||
| 208 | return errNoJob | 208 | return errNoJob | ||
| 209 | } | 209 | } | ||
| 210 | - | out = c.runnerJob(rs, *j, rec.token) | 210 | + | out = c.runnerJob(rs, *j, rec.token, rec.ghToken) |
| 211 | return nil | 211 | return nil | ||
| 212 | }) | 212 | }) | ||
| 213 | return out, err == nil && out != nil | 213 | return out, err == nil && out != nil | ||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | // runnerJob is the job as the runner gets it. | 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 | return &ports.RunnerJob{ | 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 | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | 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 | return errNoJob | 241 | return errNoJob | ||
| 242 | } | 242 | } | ||
| 243 | token := newToken(domain.JobTokenPrefix) | 243 | token := newToken(domain.JobTokenPrefix) | ||
| 244 | + | ghToken := rs.issueRepoToken(j, false) | |||
| 244 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | 245 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | ||
| 245 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | 246 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | ||
| 246 | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | 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 | if err := rs.flush(ctx); err != nil { | 249 | if err := rs.flush(ctx); err != nil { | ||
| 249 | return err | 250 | return err | ||
| 250 | } | 251 | } | ||
| 251 | - | out = c.runnerJob(rs, *j, token) | 252 | + | out = c.runnerJob(rs, *j, token, ghToken) |
| 252 | if pollID != "" { | 253 | if pollID != "" { | ||
| 253 | c.mu.Lock() | 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 | c.mu.Unlock() | 256 | c.mu.Unlock() | ||
| 256 | } | 257 | } | ||
| 257 | return nil | 258 | return nil | ||
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 | |
| @@ -189,6 +189,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob | |||||
| 189 | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | 189 | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | ||
| 190 | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | 190 | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | ||
| 191 | j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{} | 191 | j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{} | ||
| 192 | + | rs.revokeTokens(j) // the next assignment gets new ones | |||
| 192 | c.forgetPoll(jobRef(*j)) | 193 | c.forgetPoll(jobRef(*j)) | ||
| 193 | if j.CancelReason != domain.CICancelNone { | 194 | if j.CancelReason != domain.CICancelNone { | ||
| 194 | // Cancelled (by the owner, fail-fast or the time budget) before the | 195 | // Cancelled (by the owner, fail-fast or the time budget) before the | ||
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 | + | } | |
| @@ -273,6 +273,9 @@ type Auth struct { | |||||
| 273 | root domain.TokenHash | 273 | root domain.TokenHash | ||
| 274 | tokens ports.TokenStore | 274 | tokens ports.TokenStore | ||
| 275 | clock ports.Clock | 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 | var ( | 281 | var ( | ||
| @@ -286,6 +289,17 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo | |||||
| 286 | return &Auth{root: rootHash, tokens: tokens, clock: clock} | 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 | // Revalidate checks that the holder of a principal obtained earlier is still | 303 | // Revalidate checks that the holder of a principal obtained earlier is still | ||
| 290 | // authorized: root always is; a stored token must still exist and not have | 304 | // authorized: root always is; a stored token must still exist and not have | ||
| 291 | // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the | 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 | if p.IsAnonymous() { | 312 | if p.IsAnonymous() { | ||
| 299 | return domain.ErrUnauthenticated | 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 | tok, err := a.tokens.Get(ctx, p.TokenID()) | 318 | tok, err := a.tokens.Get(ctx, p.TokenID()) | ||
| 302 | if errors.Is(err, domain.ErrNotFound) { | 319 | if errors.Is(err, domain.ErrNotFound) { | ||
| 303 | return domain.ErrUnauthenticated | 320 | return domain.ErrUnauthenticated | ||
| @@ -325,6 +342,9 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa | |||||
| 325 | if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 { | 342 | if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 { | ||
| 326 | return domain.Root(), nil | 343 | return domain.Root(), nil | ||
| 327 | } | 344 | } | ||
| 345 | + | if domain.IsRepoToken(secret) { | |||
| 346 | + | return a.authenticateRepoToken(ctx, h) | |||
| 347 | + | } | |||
| 328 | tok, err := a.tokens.FindByHash(ctx, h) | 348 | tok, err := a.tokens.FindByHash(ctx, h) | ||
| 329 | if errors.Is(err, domain.ErrNotFound) { | 349 | if errors.Is(err, domain.ErrNotFound) { | ||
| 330 | return domain.Principal{}, domain.ErrUnauthenticated | 350 | return domain.Principal{}, domain.ErrUnauthenticated | ||
| @@ -344,3 +364,41 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa | |||||
| 344 | } | 364 | } | ||
| 345 | return domain.TokenPrincipal(tok), nil | 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 | + | } | |
| @@ -668,3 +668,75 @@ func TestTokensCreateBranchPrefixes(t *testing.T) { | |||||
| 668 | assert.Equal(t, "mine", got[0].ID) | 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 +32 -0
| @@ -220,3 +220,35 @@ message CIRunner { | |||
| 220 | 220 | google.protobuf.Timestamp registered_at = 8; | |
| 221 | 221 | bytes token_hash = 9; | |
| 222 | 222 | } | |
| 223 | + | ||
| 224 | + | // CIRepoToken is a job repository token (the "github.token" of a job, or the | |
| 225 | + | // token of one action archive), stored under "ghtoken/<id>". The secret is | |
| 226 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | |
| 227 | + | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | |
| 228 | + | // the tokens of a job so that they are revoked together. | |
| 229 | + | message CIRepoToken { | |
| 230 | + | string id = 1; | |
| 231 | + | bytes hash = 2; | |
| 232 | + | // The one repository the token reads. | |
| 233 | + | string repo = 3; | |
| 234 | + | // The job the token belongs to: it is revoked when the job ends. | |
| 235 | + | int64 run_id = 4; | |
| 236 | + | int32 run_attempt = 5; | |
| 237 | + | string job_id = 6; | |
| 238 | + | // Set for the token of an action archive, unset for the job's own token. | |
| 239 | + | bool archive = 7; | |
| 240 | + | google.protobuf.Timestamp expires_at = 8; | |
| 241 | + | google.protobuf.Timestamp created_at = 9; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // CIActionResolution is the resolution of an action reference for a run | |
| 245 | + | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | |
| 246 | + | // so that every job of the attempt gets the same commit. | |
| 247 | + | message CIActionResolution { | |
| 248 | + | // "owner/repo@ref", lower-cased owner and repository. | |
| 249 | + | string key = 1; | |
| 250 | + | string name_with_owner = 2; | |
| 251 | + | string resolved_sha = 3; | |
| 252 | + | // 1 = github.com, 2 = this server (the numbers of ActionSource). | |
| 253 | + | int32 source = 4; | |
| 254 | + | } | |
| @@ -220,3 +220,35 @@ message CIRunner { | |||||
| 220 | google.protobuf.Timestamp registered_at = 8; | 220 | google.protobuf.Timestamp registered_at = 8; | ||
| 221 | bytes token_hash = 9; | 221 | bytes token_hash = 9; | ||
| 222 | } | 222 | } | ||
| 223 | + | ||||
| 224 | + | // CIRepoToken is a job repository token (the "github.token" of a job, or the | |||
| 225 | + | // token of one action archive), stored under "ghtoken/<id>". The secret is | |||
| 226 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | |||
| 227 | + | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | |||
| 228 | + | // the tokens of a job so that they are revoked together. | |||
| 229 | + | message CIRepoToken { | |||
| 230 | + | string id = 1; | |||
| 231 | + | bytes hash = 2; | |||
| 232 | + | // The one repository the token reads. | |||
| 233 | + | string repo = 3; | |||
| 234 | + | // The job the token belongs to: it is revoked when the job ends. | |||
| 235 | + | int64 run_id = 4; | |||
| 236 | + | int32 run_attempt = 5; | |||
| 237 | + | string job_id = 6; | |||
| 238 | + | // Set for the token of an action archive, unset for the job's own token. | |||
| 239 | + | bool archive = 7; | |||
| 240 | + | google.protobuf.Timestamp expires_at = 8; | |||
| 241 | + | google.protobuf.Timestamp created_at = 9; | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // CIActionResolution is the resolution of an action reference for a run | |||
| 245 | + | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | |||
| 246 | + | // so that every job of the attempt gets the same commit. | |||
| 247 | + | message CIActionResolution { | |||
| 248 | + | // "owner/repo@ref", lower-cased owner and repository. | |||
| 249 | + | string key = 1; | |||
| 250 | + | string name_with_owner = 2; | |||
| 251 | + | string resolved_sha = 3; | |||
| 252 | + | // 1 = github.com, 2 = this server (the numbers of ActionSource). | |||
| 253 | + | int32 source = 4; | |||
| 254 | + | } | |||