detached 33a243c

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	"time"
16
17	"github.com/google/uuid"
18
19	"bnema.dev/ermite-runner/internal/actionscache"
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
61// (actionscache.NewClient): it follows redirects but never carries credentials
62// across an origin. Tests may supply their own transport through
63// Actions.HTTPClient; its redirect policy is replaced.
64func (j *job) archiveClient() *http.Client {
65	return actionscache.NewClient(j.cfg.Actions.HTTPClient)
66}
67
68// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
69// GetRandomBackoff), or what the test hook says.
70func (j *job) sleepBackoff(ctx context.Context) time.Duration {
71	if d := j.cfg.Actions.Backoff; d != nil {
72		return d()
73	}
74	return time.Duration(10+rand.Int64N(21)) * time.Second
75}
76
77// downloadArchive saves the archive of an action in file, retrying like the
78// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
79// The final error is the one of the last attempt, which is what the runner
80// shows (PrepareActionsAsync prints the inner exception of
81// FailedToDownloadActionException).
82func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
83	url := ra.TarballURL
84	var last error
85	for attempt := 1; attempt <= downloadAttempts; attempt++ {
86		err := j.fetch(ctx, ra, file)
87		if err == nil {
88			return nil
89		}
90		last = err
91		var nf errActionNotFound
92		var ad errAccessDenied
93		if ctx.Err() != nil || errors.As(err, &ad) {
94			return err
95		}
96		if errors.As(err, &nf) {
97			return err
98		}
99		if attempt == downloadAttempts {
100			break
101		}
102		j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
103		if os.Getenv(noBackoffEnv) == "" {
104			d := j.sleepBackoff(ctx)
105			j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
106			select {
107			case <-time.After(d):
108			case <-ctx.Done():
109				return ctx.Err()
110			}
111		}
112	}
113	return last
114}
115
116func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
117	url, token := ra.TarballURL, ra.Token
118	// The token goes only to the origin the host named, never by the URL alone.
119	if !tokenAllowed(ra) {
120		token = ""
121	}
122	ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
123	defer cancel()
124	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
125	if err != nil {
126		return err
127	}
128	if token != "" {
129		req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
130	}
131	req.Header.Set("User-Agent", "ermite-runner")
132	resp, err := j.archiveClient().Do(req)
133	if err != nil {
134		return err
135	}
136	defer func() { _ = resp.Body.Close() }()
137	switch {
138	case resp.StatusCode == http.StatusNotFound:
139		return errActionNotFound{url}
140	case resp.StatusCode == http.StatusForbidden:
141		return errAccessDenied{url}
142	case resp.StatusCode < 200 || resp.StatusCode > 299:
143		// HttpResponseMessage.EnsureSuccessStatusCode.
144		return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
145	}
146	f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
147	if err != nil {
148		return err
149	}
150	n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
151	if cerr := f.Close(); err == nil {
152		err = cerr
153	}
154	if err == nil && n > maxDownloadBytes {
155		err = errors.New("the archive is larger than 1 GiB")
156	}
157	return err
158}
159
160// watermark is the file that says an action was downloaded
161// (ActionManager.GetWatermarkFilePath).
162func watermark(dir string) string { return dir + ".completed" }
163
164// downloadAction downloads and unpacks one action into dir
165// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
166// archive's single top directory stripped, renamed to dir on success, and
167// marked with the watermark.
168func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
169	if _, err := os.Stat(watermark(dir)); err == nil {
170		j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
171		return nil
172	}
173	j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
174
175	actions := j.state.Layout().Actions
176	tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
177	if err := os.MkdirAll(tmp, 0o755); err != nil {
178		return err
179	}
180	defer func() { _ = os.RemoveAll(tmp) }()
181	archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
182	if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
183		return err
184	}
185	staging := filepath.Join(tmp, "_staging")
186	if err := os.Mkdir(staging, 0o755); err != nil {
187		return err
188	}
189	af, err := os.Open(archive)
190	if err != nil {
191		return err
192	}
193	var owner *[2]int
194	if c := j.cfg.Credential; c != nil {
195		owner = &[2]int{int(c.Uid), int(c.Gid)}
196	}
197	// The top directory is stripped while extracting, so that links are checked
198	// against the tree they will live in (see extractTarball).
199	err = extractTarball(ctx, af, staging, owner)
200	_ = af.Close()
201	if err != nil {
202		return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
203	}
204	_ = os.RemoveAll(dir)
205	if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
206		return err
207	}
208	if err := os.Rename(staging, dir); err != nil {
209		return err
210	}
211	j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
212	if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
213		return err
214	}
215	j.own(dir)
216	_ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
217		if err == nil {
218			j.own(p)
219		}
220		return nil
221	})
222	return nil
223}

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