v0.5.1 25d29f5

resolve_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"net/http"
11	"net/http/httptest"
12	"strings"
13	"testing"
14	"time"
15
16	"connectrpc.com/connect"
17	"github.com/bnema/zerowrap"
18
19	ermite "bnema.dev/ermite-api/gen/ermite"
20	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21
22	"bnema.dev/ermite-runner/internal/execstream/execpb"
23)
24
25type resolveStub struct {
26	stubClient
27	got    *ermite.ResolveActionsRequest
28	resp   *ermite.ResolveActionsResponse
29	err    error
30	fn     func(*ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse
31	onCall func(*ermite.ResolveActionsRequest)
32	calls  int
33}
34
35func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
36	c.got = r.Msg
37	c.calls++
38	if c.onCall != nil {
39		c.onCall(r.Msg)
40	}
41	if c.err != nil {
42		return nil, c.err
43	}
44	if c.fn != nil {
45		return connect.NewResponse(c.fn(r.Msg)), nil
46	}
47	return connect.NewResponse(c.resp), nil
48}
49
50func resolveJob(c jobClient) *jobRun {
51	j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
52		t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
53	j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "")
54	return j
55}
56
57func TestResolveActions(t *testing.T) {
58	c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
59		{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
60		{Ref: "a/c@v1", Error: "boom"},
61	}}}
62	resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
63	if resp.Error != "" || len(resp.Actions) != 2 {
64		t.Fatalf("%+v", resp)
65	}
66	if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
67		t.Errorf("request %+v", c.got)
68	}
69	if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" {
70		t.Errorf("%+v", a)
71	}
72	if resp.Actions[1].Error != "boom" {
73		t.Errorf("%+v", resp.Actions[1])
74	}
75}
76
77func TestResolveActionsFailures(t *testing.T) {
78	c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
79	if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
80		t.Errorf("%+v", r)
81	}
82	c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
83	if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
84		t.Errorf("a short answer must fail: %+v", r)
85	}
86	many := make([]string, maxResolveRefs+1)
87	if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" {
88		t.Error("more than 100 refs must fail")
89	}
90}
91
92func TestSetGitHubToken(t *testing.T) {
93	cases := map[string]string{
94		`{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`,
95		`{"github":{"job":"j"}}`:                       `{"github":{"token":"T","job":"j"}}`,
96		`{"needs":{}}`:                                 `{"needs":{},"github":{"token":"T"}}`,
97	}
98	for in, want := range cases {
99		got, err := SetGitHubToken(in, "T")
100		if err != nil || got != want {
101			t.Errorf("%s: %s %v, want %s", in, got, err, want)
102		}
103	}
104	if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` {
105		t.Errorf("empty token changed the contexts: %s", got)
106	}
107	if _, err := SetGitHubToken(`[]`, "T"); err == nil {
108		t.Error("not an object")
109	}
110	var m map[string]any
111	if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil {
112		t.Errorf("invalid JSON: %s", got)
113	}
114}
115
116func TestCheckUses(t *testing.T) {
117	long := func(n int) string { return strings.Repeat("a", n) }
118	for u, ok := range map[string]bool{
119		"actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true,
120		// GO-011: the path of the action is a path, not a repository name.
121		"o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,
122		"o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character
123		"o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,
124		"": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false,
125		"o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,
126		"o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false,
127		"o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,
128		"o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,
129		"o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false,
130		"o/r\x00@v1": false, "ö/r@v1": false, "o/r+x@v1": false, "o/r:x@v1": false,
131	} {
132		if err := checkUses(u); (err == nil) != ok {
133			t.Errorf("%q: %v, want ok=%v", cut(u), err, ok)
134		}
135	}
136}
137
138// GO-003: whatever the sandbox sends, the server is called with valid
139// references only, and with a bounded number of calls and references.
140func TestResolveActionsRejectsAndBudgets(t *testing.T) {
141	c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}}
142	j := resolveJob(c)
143	for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} {
144		c.got = nil
145		if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil {
146			t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil)
147		}
148	}
149	// Distinct references, one per request: the rounds are the limit.
150	c.resp = nil
151	j = resolveJob(c)
152	c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { return echo(r) }
153	var calls int
154	for i := 0; i < maxResolveRounds+20; i++ {
155		c.got = nil
156		r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{fmt.Sprintf("a/b%d@v1", i)}})
157		if c.got != nil {
158			calls++
159		}
160		if i >= maxResolveRounds && r.Error == "" {
161			t.Fatalf("round %d is over the budget and was served", i)
162		}
163	}
164	if calls != maxResolveRounds {
165		t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds)
166	}
167	// Distinct references in big requests: the references are the limit.
168	j = resolveJob(c)
169	served := 0
170	for i := 0; i < 40; i++ {
171		big := make([]string, maxResolveRefs)
172		for k := range big {
173			big[k] = fmt.Sprintf("a/b%d_%d@v1", i, k)
174		}
175		if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" {
176			served++
177		}
178	}
179	if served != maxResolveRefsTotal/maxResolveRefs {
180		t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal)
181	}
182}
183
184func echo(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
185	out := &ermite.ResolveActionsResponse{}
186	for _, ref := range r.Refs {
187		out.Actions = append(out.Actions, &ermite.ResolvedAction{Ref: ref.Uses, NameWithOwner: "a/b"})
188	}
189	return out
190}
191
192// GO-010: a resolution is cached for the job, so repeated invocations of a local
193// composite action cost nothing, and a big request is split into calls.
194func TestResolveActionsCacheAndBatches(t *testing.T) {
195	c := &resolveStub{}
196	c.fn = echo
197	j := resolveJob(c)
198	for i := 0; i < 500; i++ {
199		r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/c/sub@v2"}})
200		if r.Error != "" || len(r.Actions) != 2 || r.Actions[1].Ref != "a/c/sub@v2" {
201			t.Fatalf("invocation %d: %+v", i, r)
202		}
203	}
204	if c.calls != 1 {
205		t.Errorf("%d calls for 500 invocations of the same actions", c.calls)
206	}
207	// A new action goes through; only it is asked.
208	c.got = nil
209	if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/new@v1"}}); r.Error != "" || len(c.got.Refs) != 1 || c.got.Refs[0].Uses != "a/new@v1" {
210		t.Errorf("%+v %v", r, c.got)
211	}
212	// Duplicates in one request are asked once, answered in order.
213	j = resolveJob(c)
214	c.calls = 0
215	r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/x@v1", "a/y@v1", "a/x@v1"}})
216	if r.Error != "" || len(r.Actions) != 3 || r.Actions[2].Ref != "a/x@v1" || r.Actions[1].Ref != "a/y@v1" || c.calls != 1 || len(c.got.Refs) != 2 {
217		t.Errorf("%+v calls %d", r, c.calls)
218	}
219	// 250 distinct references: three calls of at most 100, in order.
220	j = resolveJob(c)
221	c.calls = 0
222	var sizes []int
223	c.onCall = func(r *ermite.ResolveActionsRequest) { sizes = append(sizes, len(r.Refs)) }
224	many := make([]string, 250)
225	for i := range many {
226		many[i] = fmt.Sprintf("a/b%d@v1", i)
227	}
228	r = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many})
229	if r.Error != "" || len(r.Actions) != 250 || r.Actions[249].Ref != many[249] || fmt.Sprint(sizes) != "[100 100 50]" {
230		t.Errorf("%q calls %v", r.Error, sizes)
231	}
232	// An error of one action is not cached.
233	c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
234		return &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: r.Refs[0].Uses, Error: "not found"}}}
235	}
236	j = resolveJob(c)
237	c.calls = 0
238	_ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
239	_ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
240	if c.calls != 2 {
241		t.Errorf("an error was cached: %d calls", c.calls)
242	}
243	// More than the request limit.
244	if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: make([]string, maxRequestRefs+1)}); r.Error == "" {
245		t.Error("a request over the limit was served")
246	}
247}
248
249// GO-002 on the host side: the token only goes with an ermite archive whose URL
250// is on an origin of ermite; with guest forge access the URL is rewritten.
251func TestResolveResponseTokenOrigin(t *testing.T) {
252	j := resolveJob(&resolveStub{})
253	ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB
254	conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction {
255		r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1)
256		return r.Actions[0]
257	}
258	if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" {
259		t.Errorf("%+v", a)
260	}
261	for name, a := range map[string]*execpb.ResolvedAction{
262		"github source":     conv(j, ghSrc, "https://ermite.example/x", "t"),
263		"foreign origin":    conv(j, ermiteSrc, "https://evil.example/x", "t"),
264		"subdomain":         conv(j, ermiteSrc, "https://x.ermite.example/x", "t"),
265		"other port":        conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"),
266		"other scheme":      conv(j, ermiteSrc, "http://ermite.example/x", "t"),
267		"not a url":         conv(j, ermiteSrc, "ermite.example/x", "t"),
268		"credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"),
269	} {
270		if a.Token != "" || a.AuthOrigin != "" {
271			t.Errorf("%s: token kept: %+v", name, a)
272		}
273	}
274	// No token, no origin: github.com archives are anonymous.
275	if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" {
276		t.Errorf("%+v", a)
277	}
278	// Guest forge access: the sandbox reaches ermite at the runner's origin.
279	j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088")
280	a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t")
281	if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" {
282		t.Errorf("%+v", a)
283	}
284}
285
286// GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is
287// exact, and a look-alike is not accepted.
288func TestResolveResponseTokenOriginIPv6(t *testing.T) {
289	j := resolveJob(&resolveStub{})
290	j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088")
291	ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE
292	conv := func(url string) *execpb.ResolvedAction {
293		return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0]
294	}
295	// The public origin is rewritten to the guest one.
296	if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" {
297		t.Errorf("%+v", a)
298	}
299	// The runner's own origin, default port.
300	if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" {
301		t.Errorf("%+v", a)
302	}
303	// The look-alike host "::1:8443" and other ports are not ermite.
304	for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} {
305		if a := conv(u); a.Token != "" || a.AuthOrigin != "" {
306			t.Errorf("%s: token kept: %+v", u, a)
307		}
308	}
309}
310
311type unavailableServer struct {
312	ermiteconnect.UnimplementedRunnerServiceHandler
313}
314
315func (unavailableServer) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
316	return nil, connect.NewError(connect.CodeUnavailable, errors.New("resolving `actions/checkout@v4`: github.com did not answer"))
317}
318
319// The job log gives the reason the server sent, not the runner's own
320// connection failures.
321func TestResolveActionsServerReason(t *testing.T) {
322	path, h := ermiteconnect.NewRunnerServiceHandler(unavailableServer{})
323	mux := http.NewServeMux()
324	mux.Handle(path, h)
325	srv := httptest.NewServer(mux)
326	t.Cleanup(srv.Close)
327	uses := &execpb.ResolveRequest{Uses: []string{"actions/checkout@v4"}}
328
329	r := resolveJob(ermiteconnect.NewRunnerServiceClient(srv.Client(), srv.URL)).resolveActions(context.Background(), uses)
330	if want := "resolving actions failed: unavailable: resolving `actions/checkout@v4`: github.com did not answer"; r.Error != want {
331		t.Errorf("the job log must name the action: %q", r.Error)
332	}
333
334	closed := httptest.NewServer(mux)
335	url := closed.URL
336	closed.Close()
337	r = resolveJob(ermiteconnect.NewRunnerServiceClient(http.DefaultClient, url)).resolveActions(context.Background(), uses)
338	if r.Error != "resolving actions failed: unavailable" {
339		t.Errorf("a connection failure of the runner stays out of the job log: %q", r.Error)
340	}
341}

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