v0.6.0 aba298d

resolve.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"strings"
11	"time"
12	"unicode/utf8"
13
14	"connectrpc.com/connect"
15
16	ermite "bnema.dev/ermite-api/gen/ermite"
17
18	"bnema.dev/ermite-runner/internal/execstream/execpb"
19)
20
21// Limits of what the sandbox may ask the server through the host. The sandbox
22// runs untrusted code, so the host does not trust its requests.
23//
24// Resolutions are cached for the job, by "uses" value: an action that was
25// resolved once is never asked again (the same sha for the whole job is what the
26// server promises anyway), so a local composite action used 500 times costs one
27// resolution. The budget counts only what reaches the server:
28//
29//   - a request from the sandbox has at most maxRequestRefs references (a
30//     workflow has at most 1,000 steps), split into calls of maxResolveRefs, the
31//     limit of ResolveActionsRequest;
32//   - a job makes at most maxResolveRounds calls (up to 10 levels of repository
33//     composites, then one call per local composite that brings new actions) and
34//     asks for at most maxResolveRefsTotal distinct references.
35//
36// These are explicit limits of ermite, not of GitHub: a job that needs more
37// distinct actions than that fails "Set up job" with the message below.
38const (
39	maxRequestRefs      = 1000
40	maxResolveRefs      = 100  // per call to the server: ResolveActionsRequest.refs
41	maxResolveRounds    = 128  // calls per job
42	maxResolveRefsTotal = 2048 // distinct references per job
43	maxRefLen           = 1024 // the whole "uses" value
44	maxOwnerRepoLen     = 100  // owner and repository names
45	maxPathElemLen      = 255  // an element of the path of the action: a file name
46	maxRefNameLen       = 255
47)
48
49// checkUses validates a "uses" value of a request: owner/repo[/path]@ref. The
50// owner and the repository have the characters of repository names; the path of
51// the action is a path in the repository (any characters but control ones, with
52// no empty, "." or ".." element and no backslash, which the runner reads as a
53// separator, action.ParseUses); the ref has the characters git allows. The
54// server decides whether the action exists; this keeps garbage and oversized
55// strings from reaching it.
56func checkUses(u string) error {
57	bad := func() error { return fmt.Errorf("%q is not a valid action reference", cut(u)) }
58	if u == "" || len(u) > maxRefLen || !utf8.ValidString(u) {
59		return bad()
60	}
61	name, ref, ok := strings.Cut(u, "@")
62	if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) {
63		return bad()
64	}
65	segs := strings.Split(name, "/")
66	if len(segs) < 2 {
67		return bad()
68	}
69	for _, seg := range segs[:2] {
70		if seg == "" || seg == "." || seg == ".." || len(seg) > maxOwnerRepoLen || strings.ContainsFunc(seg, badSegmentRune) {
71			return bad()
72		}
73	}
74	for _, seg := range segs[2:] {
75		if seg == "" || seg == "." || seg == ".." || len(seg) > maxPathElemLen || strings.ContainsFunc(seg, badPathRune) {
76			return bad()
77		}
78	}
79	return nil
80}
81
82// badPathRune: control characters (NUL included) and the backslash. Invalid
83// UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a
84// valid character.
85func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' }
86
87func cut(s string) string {
88	if len(s) > 80 {
89		return s[:80] + "..."
90	}
91	return s
92}
93
94func badSegmentRune(r rune) bool {
95	return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.'
96}
97
98// badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces
99// or characters git refuses.
100func badRefRune(r rune) bool {
101	return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses)
102}
103
104// resolveActions answers a ResolveRequest of the executor with
105// RunnerService.ResolveActions. The job token stays here: the executor only
106// gets what the server returns (the per-archive tokens included, which it
107// masks). Requests come one at a time (see execstream.JobHandler.Resolve). What
108// was resolved before in the job comes from the cache; the rest goes to the
109// server in calls of at most maxResolveRefs, under the budget above. A call that
110// fails without a verdict is retried like the other job calls; a failure of the
111// call itself is reported to the executor, which fails "Set up job"
112// (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends with the
113// session.
114func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse {
115	start := time.Now()
116	defer func() {
117		j.mu.Lock()
118		defer j.mu.Unlock()
119		j.timeline.addMs("resolve_ms", time.Since(start))
120		j.timeline.add("resolve_calls", 1)
121	}()
122	fail := func(format string, a ...any) *execpb.ResolveResponse {
123		return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
124	}
125	if len(req.Uses) == 0 {
126		return fail("an empty request for action resolution")
127	}
128	if len(req.Uses) > maxRequestRefs {
129		return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxRequestRefs)
130	}
131	for _, u := range req.Uses {
132		if err := checkUses(u); err != nil {
133			return fail("%v", err)
134		}
135	}
136	// The job ending, or the session, stops the calls.
137	ctx, cancel := context.WithCancel(ctx)
138	defer cancel()
139	stop := context.AfterFunc(j.ctx, cancel)
140	defer stop()
141
142	got := map[string]*execpb.ResolvedAction{}
143	var pending []string
144	j.mu.Lock()
145	for _, u := range req.Uses {
146		if _, dup := got[u]; dup {
147			continue
148		}
149		if c, ok := j.resolved[u]; ok {
150			got[u] = c
151			continue
152		}
153		got[u] = nil
154		pending = append(pending, u)
155	}
156	j.mu.Unlock()
157	for len(pending) > 0 {
158		n := min(len(pending), maxResolveRefs)
159		chunk := pending[:n]
160		pending = pending[n:]
161		j.mu.Lock()
162		j.resolveRounds++
163		j.resolveRefs += len(chunk)
164		over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal
165		j.mu.Unlock()
166		if over {
167			return fail("this job asked to resolve too many actions (the limits are %d requests and %d distinct references)", maxResolveRounds, maxResolveRefsTotal)
168		}
169		res, errResp := j.callResolve(ctx, chunk)
170		if errResp != nil {
171			return errResp
172		}
173		j.mu.Lock()
174		for i, u := range chunk {
175			got[u] = res[i]
176			if res[i].Error == "" { // an error is not cached: it may be a passing one
177				if j.resolved == nil {
178					j.resolved = map[string]*execpb.ResolvedAction{}
179				}
180				j.resolved[u] = res[i]
181			}
182		}
183		j.mu.Unlock()
184	}
185	out := &execpb.ResolveResponse{}
186	for _, u := range req.Uses {
187		out.Actions = append(out.Actions, got[u])
188	}
189	return out
190}
191
192// callResolve makes one call to the server, with retries, and converts the
193// answer; the second result is the error to send to the executor.
194func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
195	fail := func(format string, a ...any) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
196		return nil, &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
197	}
198	refs := make([]*ermite.ActionRef, len(uses))
199	for i, u := range uses {
200		refs[i] = &ermite.ActionRef{Uses: u}
201	}
202	b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
203	for attempt := 1; ; attempt++ {
204		cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout)
205		resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
206		ccancel()
207		if err == nil {
208			conv := j.resolveResponse(resp.Msg, len(uses))
209			if conv.Error == "" {
210				j.cacheArchives(ctx, resp.Msg.Actions, conv.Actions)
211			}
212			if conv.Error != "" {
213				return fail("%s", conv.Error)
214			}
215			return conv.Actions, nil
216		}
217		if ctx.Err() != nil {
218			return fail("the job was cancelled while its actions were resolved")
219		}
220		switch classify(err) {
221		case kindEnded, kindAbandoned:
222			j.abandon(err)
223			return fail("the server ended the job")
224		case kindPermanent:
225			return fail("resolving actions failed: %s", serverReason(err))
226		}
227		j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
228		if attempt >= 3 || !sleep(ctx, b.next()) {
229			return fail("resolving actions failed: %s", serverReason(err))
230		}
231	}
232}
233
234// serverReason is the code of a failed call, followed by the message of the
235// server when it gave one, so the job log names what could not be resolved.
236// Only a message the server sent goes in the job log, which the readers of the
237// repository see: a failure of the runner's own connection (DNS, TLS, an
238// address) stays in the runner's log.
239func serverReason(err error) string {
240	var ce *connect.Error
241	if errors.As(err, &ce) && connect.IsWireError(ce) && ce.Message() != "" {
242		return fmt.Sprintf("%s: %s", ce.Code(), ce.Message())
243	}
244	return connect.CodeOf(err).String()
245}
246
247// resolveResponse converts the answer of the server. The token of an archive is
248// a secret that the sandbox sends in an Authorization header, so it only goes
249// with an archive of the ermite source whose tarball URL is on an origin this
250// host knows to be ermite: the public origin of the job (github.server_url) or
251// the origin of the runner's own URL. The origin the sandbox must send it to is
252// AuthOrigin; with guest forge access the tarball URL on the public origin is
253// rewritten to the runner's, like the URLs of the contexts. A token that does
254// not satisfy this is dropped (the download then fails like an unauthorised
255// one).
256func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
257	if len(m.Actions) != want {
258		return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
259	}
260	out := &execpb.ResolveResponse{}
261	for _, a := range m.Actions {
262		ra := &execpb.ResolvedAction{
263			Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
264			TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error,
265		}
266		if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE {
267			if origin := originOf(a.TarballUrl); origin != "" {
268				if j.guestForge != "" && origin == j.publicOrigin {
269					ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)])
270					origin = j.guestForge
271				}
272				if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) {
273					ra.Token, ra.AuthOrigin = a.Token, origin
274				}
275			}
276		}
277		out.Actions = append(out.Actions, ra)
278	}
279	return out
280}
281
282// setOrigins records the origins that are ermite for this job: the public one
283// (the origin of github.server_url as the server sent it), the runner's own
284// (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off.
285func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) {
286	j.serverOrigin = originOf(serverURL)
287	j.guestForge = guestForge
288	if guestForge != "" {
289		j.guestForge = originOf(guestForge)
290	}
291	if top, err := parseObject([]byte(contexts)); err == nil {
292		for _, m := range top {
293			if m.key != "github" {
294				continue
295			}
296			gh, err := parseObject(m.val)
297			if err != nil {
298				break
299			}
300			for _, g := range gh {
301				var s string
302				if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil {
303					j.publicOrigin = originOf(s)
304				}
305			}
306		}
307	}
308}
309
310// SetGitHubToken returns the contexts JSON of a job with github.token set to
311// token (the job repository token, Job.github_token). The key is added first,
312// like the runner does (ExecutionContext.cs:1023: token, then what the server
313// gave); an empty token leaves the contexts as they are.
314func SetGitHubToken(contexts, token string) (string, error) {
315	if token == "" {
316		return contexts, nil
317	}
318	top, err := parseObject([]byte(contexts))
319	if err != nil {
320		return "", fmt.Errorf("job contexts: %w", err)
321	}
322	gi := -1
323	for i, m := range top {
324		if m.key == "github" {
325			gi = i
326		}
327	}
328	var gh []jsonMember
329	if gi >= 0 {
330		if gh, err = parseObject(top[gi].val); err != nil {
331			return "", fmt.Errorf("job contexts: github: %w", err)
332		}
333	}
334	tv, err := json.Marshal(token)
335	if err != nil {
336		return "", err
337	}
338	set := false
339	for i, m := range gh {
340		if m.key == "token" {
341			gh[i].val, set = tv, true
342		}
343	}
344	if !set {
345		gh = append([]jsonMember{{"token", tv}}, gh...)
346	}
347	ghv, err := encodeObject(gh)
348	if err != nil {
349		return "", err
350	}
351	if gi >= 0 {
352		top[gi].val = ghv
353	} else {
354		top = append(top, jsonMember{"github", ghv})
355	}
356	out, err := encodeObject(top)
357	return string(out), err
358}
359
360// hostLen is the length of the authority of an absolute URL.
361func hostLen(u string) int {
362	rest := u[strings.Index(u, "://")+3:]
363	if i := strings.IndexAny(rest, "/?#"); i >= 0 {
364		return i
365	}
366	return len(rest)
367}

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 4

Tags 11

↑↓ moveenter openesc close