v0.3.0 8f98fd3

actions_download.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"encoding/base64"
8	"errors"
9	"fmt"
10	"io"
11	"math/rand/v2"
12	"net/http"
13	"os"
14	"path/filepath"
15	"slices"
16	"time"
17
18	"github.com/google/uuid"
19
20	"bnema.dev/ermite-runner/internal/urlorigin"
21)
22
23// Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
24const (
25	downloadAttempts = 3
26	downloadTimeout  = 20 * time.Minute
27	// maxDownloadBytes bounds an archive (the runner has no bound).
28	maxDownloadBytes = 1 << 30
29)
30
31// noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739).
32const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"
33
34// errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28).
35type errActionNotFound struct{ url string }
36
37func (e errActionNotFound) Error() string {
38	return fmt.Sprintf("An action could not be found at the URI '%s'", e.url)
39}
40
41// errAccessDenied is AccessDeniedException; the request id is empty (ermite
42// has none) and the runner still prints its parentheses.
43type errAccessDenied struct{ url string }
44
45func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
46
47// originOf is the canonical origin of a URL (urlorigin.Canonical): the one the
48// host and the executor both compare, scheme, host and port with the default
49// port filled in, "" for anything that is not an absolute http(s) URL.
50func originOf(raw string) string { return urlorigin.Canonical(raw) }
51
52// tokenAllowed reports whether the token of ra may be sent to its tarball URL:
53// the URL is on exactly the origin the host named, compared in canonical form
54// (default ports filled in, IPv6 hosts in brackets).
55func tokenAllowed(ra ResolvedAction) bool {
56	o := originOf(ra.AuthOrigin)
57	return ra.Token != "" && o != "" && o == originOf(ra.TarballURL)
58}
59
60// archiveClient returns the client that downloads the archives. It follows
61// redirects (GitHub's codeload does redirect) but never carries credentials
62// across an origin: the Authorization header is dropped as soon as the scheme,
63// the host or the port changes, and a redirect from https to http is refused.
64// (net/http alone would keep the header for subdomains and ignore the scheme and
65// the port.) Tests may supply their own transport through Actions.HTTPClient;
66// its redirect policy is replaced.
67func (j *job) archiveClient() *http.Client {
68	c := &http.Client{}
69	if base := j.cfg.Actions.HTTPClient; base != nil {
70		cp := *base
71		c = &cp
72	}
73	c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
74		if len(via) >= 10 {
75			return errors.New("stopped after 10 redirects")
76		}
77		// Go builds every redirected request from the headers of the first one
78		// (copying Authorization when the host matches), so the header must be
79		// removed again on every hop that is not on the origin the token was meant
80		// for: the first request's. Once the chain left that origin, it never gets
81		// the header back, even if a later hop returns to it (A -> B -> A would
82		// otherwise be re-authorised by the copy).
83		origin := originOf(via[0].URL.String())
84		chain := slices.Concat(via[1:], []*http.Request{req}) // not append: via belongs to the client
85		for i, r := range chain {
86			prev := via[i]
87			if prev.URL.Scheme == "https" && r.URL.Scheme != "https" {
88				return fmt.Errorf("refusing to follow a redirect from https to %s", r.URL.Scheme)
89			}
90		}
91		for _, r := range chain {
92			if originOf(r.URL.String()) != origin {
93				req.Header.Del("Authorization")
94				break
95			}
96		}
97		return nil
98	}
99	return c
100}
101
102// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
103// GetRandomBackoff), or what the test hook says.
104func (j *job) sleepBackoff(ctx context.Context) time.Duration {
105	if d := j.cfg.Actions.Backoff; d != nil {
106		return d()
107	}
108	return time.Duration(10+rand.Int64N(21)) * time.Second
109}
110
111// downloadArchive saves the archive of an action in file, retrying like the
112// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
113// The final error is the one of the last attempt, which is what the runner
114// shows (PrepareActionsAsync prints the inner exception of
115// FailedToDownloadActionException).
116func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
117	url := ra.TarballURL
118	var last error
119	for attempt := 1; attempt <= downloadAttempts; attempt++ {
120		err := j.fetch(ctx, ra, file)
121		if err == nil {
122			return nil
123		}
124		last = err
125		var nf errActionNotFound
126		var ad errAccessDenied
127		if ctx.Err() != nil || errors.As(err, &ad) {
128			return err
129		}
130		if errors.As(err, &nf) {
131			return err
132		}
133		if attempt == downloadAttempts {
134			break
135		}
136		j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
137		if os.Getenv(noBackoffEnv) == "" {
138			d := j.sleepBackoff(ctx)
139			j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
140			select {
141			case <-time.After(d):
142			case <-ctx.Done():
143				return ctx.Err()
144			}
145		}
146	}
147	return last
148}
149
150func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
151	url, token := ra.TarballURL, ra.Token
152	// The token goes only to the origin the host named, never by the URL alone.
153	if !tokenAllowed(ra) {
154		token = ""
155	}
156	ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
157	defer cancel()
158	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
159	if err != nil {
160		return err
161	}
162	if token != "" {
163		req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
164	}
165	req.Header.Set("User-Agent", "ermite-runner")
166	resp, err := j.archiveClient().Do(req)
167	if err != nil {
168		return err
169	}
170	defer func() { _ = resp.Body.Close() }()
171	switch {
172	case resp.StatusCode == http.StatusNotFound:
173		return errActionNotFound{url}
174	case resp.StatusCode == http.StatusForbidden:
175		return errAccessDenied{url}
176	case resp.StatusCode < 200 || resp.StatusCode > 299:
177		// HttpResponseMessage.EnsureSuccessStatusCode.
178		return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
179	}
180	f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
181	if err != nil {
182		return err
183	}
184	n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
185	if cerr := f.Close(); err == nil {
186		err = cerr
187	}
188	if err == nil && n > maxDownloadBytes {
189		err = errors.New("the archive is larger than 1 GiB")
190	}
191	return err
192}
193
194// watermark is the file that says an action was downloaded
195// (ActionManager.GetWatermarkFilePath).
196func watermark(dir string) string { return dir + ".completed" }
197
198// downloadAction downloads and unpacks one action into dir
199// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
200// archive's single top directory stripped, renamed to dir on success, and
201// marked with the watermark.
202func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
203	if _, err := os.Stat(watermark(dir)); err == nil {
204		j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
205		return nil
206	}
207	j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
208
209	actions := j.state.Layout().Actions
210	tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
211	if err := os.MkdirAll(tmp, 0o755); err != nil {
212		return err
213	}
214	defer func() { _ = os.RemoveAll(tmp) }()
215	archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
216	if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
217		return err
218	}
219	staging := filepath.Join(tmp, "_staging")
220	if err := os.Mkdir(staging, 0o755); err != nil {
221		return err
222	}
223	af, err := os.Open(archive)
224	if err != nil {
225		return err
226	}
227	var owner *[2]int
228	if c := j.cfg.Credential; c != nil {
229		owner = &[2]int{int(c.Uid), int(c.Gid)}
230	}
231	// The top directory is stripped while extracting, so that links are checked
232	// against the tree they will live in (see extractTarball).
233	err = extractTarball(ctx, af, staging, owner)
234	_ = af.Close()
235	if err != nil {
236		return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
237	}
238	_ = os.RemoveAll(dir)
239	if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
240		return err
241	}
242	if err := os.Rename(staging, dir); err != nil {
243		return err
244	}
245	j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
246	if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
247		return err
248	}
249	j.own(dir)
250	_ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
251		if err == nil {
252			j.own(p)
253		}
254		return nil
255	})
256	return nil
257}

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