v0.9.0 dd8597a

ci_actions.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
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.
33func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
34	out, err := c.resolveActions(ctx, secret, jobID, uses)
35	if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) {
36		// Our own deadline, not the caller's: github.com (or the disk) was too
37		// slow. The runner may retry, or fail the job.
38		return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout)
39	}
40	return out, err
41}
42
43func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
44	ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout)
45	defer cancel()
46	j, err := c.jobFor(ctx, secret, jobID)
47	if err != nil {
48		return nil, err
49	}
50	if j.Ended() {
51		return nil, endedError(j)
52	}
53	if len(uses) > maxActionRefs {
54		return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs)
55	}
56	parsed := make([]actionRef, len(uses))
57	for i, u := range uses {
58		if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") {
59			return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u)
60		}
61		if strings.HasPrefix(u, "docker://") {
62			return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u)
63		}
64		parsed[i] = parseActionRef(u)
65	}
66
67	src, err := c.cfg.Store.GetRunSource(ctx, j.RunID)
68	if err != nil {
69		return nil, err
70	}
71	by := c.onBehalfOf(src)
72
73	// Resolve what the attempt has not resolved yet, outside the lock of the
74	// run: a ls-remote takes seconds and must not hold up the job's calls.
75	found := map[string]domain.CIActionResolution{}
76	fails := map[string]string{}
77	remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)
78	for _, p := range parsed {
79		if p.err != "" {
80			continue
81		}
82		if _, done := found[p.key()]; done {
83			continue
84		}
85		if _, bad := fails[p.key()]; bad {
86			continue
87		}
88		if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil {
89			found[p.key()] = res
90			continue
91		} else if !errors.Is(err, domain.ErrNotFound) {
92			return nil, err
93		}
94		res, msg, err := c.resolveAction(ctx, j, p, remote, by)
95		if err != nil {
96			return nil, err
97		}
98		if msg != "" {
99			fails[p.key()] = msg
100			continue
101		}
102		found[p.key()] = res
103	}
104
105	out := make([]ports.ResolvedAction, len(parsed))
106	err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
107		if cur.Ended() {
108			return endedError(*cur)
109		}
110		rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt)
111		if err != nil {
112			return err
113		}
114		own := rs.find(cur.ID)
115		if own == nil {
116			return domain.ErrNotFound
117		}
118		for i, p := range parsed {
119			out[i] = ports.ResolvedAction{Ref: p.uses}
120			if p.err != "" {
121				out[i].Error = p.err
122				continue
123			}
124			if msg, bad := fails[p.key()]; bad {
125				out[i].Error = msg
126				continue
127			}
128			// The first resolution stored wins, ours or a concurrent job's.
129			res, ok := found[p.key()]
130			if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil {
131				res, ok = cached, true
132			} else if !errors.Is(err, domain.ErrNotFound) {
133				return err
134			} else if ok {
135				rs.resolutions = append(rs.resolutions, res)
136				found[p.key()] = res // two refs of the call with the same key
137			}
138			if !ok {
139				continue
140			}
141			ra, err := c.answer(ctx, rs, own, p, res, by)
142			if err != nil {
143				return err
144			}
145			out[i] = ra
146		}
147		if err := rs.flush(ctx); err != nil {
148			// The tokens were not stored: forget their secrets.
149			c.mu.Lock()
150			delete(c.archiveTokens, jobRef(*cur))
151			c.mu.Unlock()
152			return err
153		}
154		return nil
155	})
156	if err != nil {
157		return nil, err
158	}
159	return out, nil
160}
161
162// maxActionRefs is the most references of one ResolveActions call.
163const maxActionRefs = 100
164
165// answer builds the answer for a resolved reference. The archive token of an
166// ermite action is bound to the commit of the answer: the one the job already
167// holds for it, else a new one, written with the next flush. Whether the pusher
168// may read the repository is asked again at each answer, not only when the
169// commit was resolved: a token revoked since, or a repository made private,
170// stops the answers.
171func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) {
172	ra := ports.ResolvedAction{
173		Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
174	}
175	notFound := ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}
176
177	// A repository of this server that the pusher cannot read is never served
178	// from anywhere else: not from github.com under the same name, whatever
179	// source an earlier resolution of the attempt stored. It answers as a
180	// repository that does not exist. (When it does not exist on this server, a
181	// stored ermite resolution has nothing to serve either.)
182	repo, onErmite, err := c.ermiteRepoOf(ctx, p)
183	if err != nil {
184		return ports.ResolvedAction{}, err
185	}
186	if onErmite {
187		if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
188			return ports.ResolvedAction{}, err
189		} else if !ok {
190			return notFound, nil
191		}
192	}
193	switch res.Source {
194	case domain.CIActionGitHub:
195		// The case the workflow wrote: the stored resolution is shared by
196		// spellings that differ only by case, and github.com ignores it.
197		ra.NameWithOwner = p.owner + "/" + p.repo
198		ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
199	case domain.CIActionErmite:
200		if !onErmite {
201			return notFound, nil
202		}
203		ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
204		ra.Token = c.archiveToken(rs, j, repo.Name, res.ResolvedSHA)
205	}
206	return ra, nil
207}
208
209// archiveToken returns the secret of the token of the job for the archive of
210// the commit sha of repo, issuing it when the job has none. The token downloads
211// that archive and nothing else (domain.RepoTokenPrincipal).
212func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string {
213	ref, key := jobRef(*j), repo.String()+"@"+sha
214	c.mu.Lock()
215	tok, ok := c.archiveTokens[ref][key]
216	c.mu.Unlock()
217	if ok {
218		return tok
219	}
220	tok = rs.issueRepoTokenFor(j, repo, sha)
221	c.mu.Lock()
222	if c.archiveTokens[ref] == nil {
223		c.archiveTokens[ref] = map[string]string{}
224	}
225	c.archiveTokens[ref][key] = tok
226	c.mu.Unlock()
227	return tok
228}
229
230// ermiteRepoOf finds the repository of this server that a reference names: the
231// owner is ERMITE_OWNER (without regard to case) and the repository exists. ok
232// is false for any other reference, which github.com serves. It is the one
233// place that decides the source of an action, for a first resolution and for the
234// answer from a stored one.
235func (c *CI) ermiteRepoOf(ctx context.Context, a actionRef) (repo domain.Repo, ok bool, err error) {
236	if !domain.OwnerMatches(c.cfg.Owner, a.owner) {
237		return domain.Repo{}, false, nil
238	}
239	name, perr := domain.ParseRepoName(strings.ToLower(a.repo))
240	if perr != nil {
241		return domain.Repo{}, false, nil // not a name ermite can hold
242	}
243	repo, err = c.cfg.Meta.Get(ctx, name)
244	switch {
245	case errors.Is(err, domain.ErrNotFound):
246		return domain.Repo{}, false, nil
247	case err != nil:
248		return domain.Repo{}, false, err
249	}
250	return repo, true, nil
251}
252
253// pusherAccess is who the actions of a run are resolved on behalf of: the
254// pusher recorded with the run.
255type pusherAccess struct{ pusher domain.CIPusher }
256
257func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} }
258
259// pusherCanRead tells whether the pusher of a run may read repo, with the rule
260// of the forge (canRead) applied to the principal the pusher is now: the root
261// token and the owner's session read everything; a stored token is looked up
262// again, so a token revoked or expired since reads nothing, and its scopes and
263// repository list apply; a run that records no pusher reads what an anonymous
264// caller reads. The repository of the run itself needs no check: the job reads
265// it with its own token.
266func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) {
267	if repo.Name == runRepo {
268		return true, nil
269	}
270	var p domain.Principal
271	switch by.pusher.Kind {
272	case domain.CIPusherRoot:
273		p = domain.Root()
274	case domain.CIPusherOwner:
275		p = domain.Owner()
276	case domain.CIPusherToken:
277		if c.cfg.Tokens == nil {
278			return false, nil
279		}
280		tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
281		if errors.Is(err, domain.ErrNotFound) {
282			return false, nil
283		}
284		if err != nil {
285			return false, err
286		}
287		if tok.Expired(c.now()) {
288			return false, nil
289		}
290		p = domain.TokenPrincipal(tok)
291	default:
292		p = domain.Anonymous()
293	}
294	return canRead(p, repo), nil
295}
296
297// actionRef is a parsed "uses": owner/repo[/path]@ref.
298type actionRef struct {
299	uses, owner, repo, path, ref string
300	// err is GitHub's message for a reference of the wrong shape.
301	err string
302}
303
304// key identifies the resolution: GitHub names are case-insensitive, refs are
305// not.
306func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref }
307
308// parseActionRef splits a "uses" of a repository action. Only the shape
309// owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and
310// ignores empty segments (WorkflowTemplateConverter.cs), so do we.
311func parseActionRef(uses string) actionRef {
312	a := actionRef{uses: uses}
313	left, ref, ok := strings.Cut(uses, "@")
314	segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' })
315	if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" {
316		a.err = errActionFormat(uses)
317		return a
318	}
319	for _, s := range segs[2:] {
320		if s == "." || s == ".." {
321			a.err = errActionFormat(uses)
322			return a
323		}
324	}
325	if !validActionName(segs[0]) || !validActionName(segs[1]) {
326		a.err = errActionFormat(uses)
327		return a
328	}
329	a.owner, a.repo, a.ref = segs[0], segs[1], ref
330	a.path = strings.Join(segs[2:], "/")
331	return a
332}
333
334// validActionName reports whether s can be an owner or a repository name on
335// GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of
336// dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a
337// URL, a path or a command.
338func validActionName(s string) bool {
339	if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" {
340		return false
341	}
342	for _, r := range s {
343		switch {
344		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
345		default:
346			return false
347		}
348	}
349	return true
350}
351
352// --- GitHub's texts ---
353//
354// All the messages a reference can fail with are here. The first is the
355// runner's own parser text; the two others are the ones GitHub gives for an
356// unknown version and an unknown repository, to be confirmed by recording
357// against github.com (ci2-execution plan, phase A).
358
359func errActionFormat(uses string) string {
360	return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'"
361}
362
363func errUnresolvedRef(a actionRef) string {
364	return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`"
365}
366
367func errUnresolvedRepo(a actionRef) string {
368	return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found"
369}
370
371// resolveAction resolves one reference. msg is the message of a reference that
372// does not resolve; err is a failure of the call.
373func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
374	res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
375	repo, onErmite, err := c.ermiteRepoOf(ctx, a)
376	if err != nil {
377		return res, "", err
378	}
379	if onErmite {
380		// The repository exists here, so github.com never answers for it. A
381		// repository the pusher may not read answers as one that does not exist,
382		// before any ref is looked at, so that the texts leak nothing about it.
383		if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {
384			return res, "", err
385		} else if !ok {
386			return res, errUnresolvedRepo(a), nil
387		}
388		sha, found, err := c.resolveOnErmite(ctx, repo.Name, a.ref)
389		if err != nil {
390			return res, "", err
391		}
392		if !found {
393			return res, errUnresolvedRef(a), nil
394		}
395		res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+repo.Name.String(), sha, domain.CIActionErmite
396		return res, "", nil
397	}
398	res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
399	if low := strings.ToLower(a.ref); isHex40(low) {
400		res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download
401		return res, "", nil
402	}
403	refs, err := remote.list(ctx, a.owner, a.repo)
404	switch {
405	case errors.Is(err, domain.ErrNotFound):
406		return res, errUnresolvedRepo(a), nil
407	case err != nil:
408		return res, "", err
409	}
410	sha := pickRemoteRef(refs, a.ref)
411	if sha == "" {
412		return res, errUnresolvedRef(a), nil
413	}
414	res.ResolvedSHA = sha
415	return res, "", nil
416}
417
418// pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a
419// remote. An annotated tag gives its peeled commit.
420func pickRemoteRef(refs []ports.RemoteRef, name string) string {
421	for _, prefix := range []string{"refs/tags/", "refs/heads/"} {
422		for _, r := range refs {
423			if r.Name != prefix+name {
424				continue
425			}
426			if r.Peeled != "" {
427				return r.Peeled
428			}
429			return r.ID
430		}
431	}
432	return ""
433}
434
435// resolveOnErmite resolves a ref on a repository of this server: a tag, else
436// a branch, else a full commit id that exists.
437func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) {
438	refs, err := c.cfg.Reader.ListRefs(ctx, repo)
439	if err != nil && !errors.Is(err, domain.ErrNotFound) {
440		return "", false, err
441	}
442	for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} {
443		i := slices.IndexFunc(refs, func(r domain.Ref) bool {
444			return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero()
445		})
446		if i >= 0 {
447			return refs[i].CommitID.String(), true, nil
448		}
449	}
450	if id, perr := domain.ParseObjectID(ref); perr == nil {
451		ok, err := c.cfg.Git.HasCommit(ctx, repo, id)
452		if err != nil && !errors.Is(err, domain.ErrNotFound) {
453			return "", false, err
454		}
455		if ok {
456			return id.String(), true, nil
457		}
458	}
459	return "", false, nil
460}
461
462// remoteRefs lists the refs of a remote once per repository within a call.
463type remoteRefs struct {
464	remote ports.GitRemote
465	slots  chan struct{}
466	seen   map[string]remoteList
467}
468
469type remoteList struct {
470	refs []ports.RemoteRef
471	err  error
472}
473
474func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {
475	return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}
476}
477
478func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
479	if r.remote == nil {
480		return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
481	}
482	key := strings.ToLower(owner + "/" + repo)
483	if l, ok := r.seen[key]; ok {
484		return l.refs, l.err
485	}
486	// At most a few ls-remote at once on the whole server, whatever the number
487	// of jobs resolving: a process each, and github.com is slow to answer.
488	select {
489	case r.slots <- struct{}{}:
490		defer func() { <-r.slots }()
491	case <-ctx.Done():
492		return nil, ctx.Err()
493	}
494	refs, err := r.remote.ListRefs(ctx, owner, repo)
495	if ctx.Err() != nil {
496		return nil, ctx.Err()
497	}
498	r.seen[key] = remoteList{refs, err}
499	return refs, err
500}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close