1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "time"
13
14 ermite "bnema.dev/ermite-api/gen/ermite"
15
16 "bnema.dev/ermite-runner/internal/actionscache"
17 "bnema.dev/ermite-runner/internal/execstream/execpb"
18)
19
20const (
21 // archiveFetchTimeout and maxArchiveBytes are the executor's limits for an
22 // archive (actions_download.go).
23 archiveFetchTimeout = 20 * time.Minute
24 maxArchiveBytes = 1 << 30
25)
26
27// cacheArchives points the actions of a resolution at the host's archive cache.
28// The host downloads each archive once per (forge, repository, resolved sha) and
29// the sandbox reads it from the cache channel instead of the forge, so that a
30// busy runner does not trip the forge's rate limit (codeload 429).
31//
32// It only ever improves a resolution: an action that cannot be cached (no cache,
33// no channel for this job, a ref that did not resolve to a sha, a download that
34// failed, a full disk) keeps its original URL and token, and the sandbox
35// downloads it as before, with its retries and its messages. The sandbox prints
36// "Download action repository" in either case, so a hit reads like a miss in the
37// job's log; the hit or miss is logged here.
38func (j *jobRun) cacheArchives(ctx context.Context, orig []*ermite.ResolvedAction, conv []*execpb.ResolvedAction) {
39 rt := j.r
40 if rt == nil || rt.Archives == nil || rt.Cache == nil || len(orig) != len(conv) {
41 return
42 }
43 if _, ok, err := cacheScope(j.job, j.contexts); err != nil || !ok {
44 return // no channel for this job: the guest has no relay either
45 }
46 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
47 for i, a := range orig {
48 c := conv[i]
49 if c.Error != "" || a.Error != "" || a.TarballUrl == "" {
50 continue
51 }
52 origin := originOf(a.TarballUrl)
53 key, ok := actionscache.Key(origin, a.NameWithOwner, a.ResolvedSha)
54 if !ok {
55 continue
56 }
57 hit, err := rt.Archives.Ensure(ctx, key, func(ctx context.Context, w io.Writer) error {
58 return j.fetchArchive(ctx, a, origin, w)
59 })
60 log := j.log.With().Str("action", a.NameWithOwner).Str("sha", a.ResolvedSha).Logger()
61 if err != nil {
62 log.Warn().Err(err).Msg("action archive not cached; the sandbox downloads it")
63 continue
64 }
65 u := rt.Cache.ArchiveURL(j.job.JobId, key, ttl)
66 if u == "" {
67 continue
68 }
69 log.Info().Bool("hit", hit).Msg("action archive cache")
70 c.TarballUrl, c.Token, c.AuthOrigin = u, "", ""
71 }
72}
73
74// fetchArchive downloads the archive of a from the forge, with the token only
75// when the server gave one for an origin that is ermite (like resolveResponse).
76func (j *jobRun) fetchArchive(ctx context.Context, a *ermite.ResolvedAction, origin string, w io.Writer) error {
77 token := ""
78 if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE && a.Token != "" && (origin == j.publicOrigin || origin == j.serverOrigin) {
79 token = a.Token
80 }
81 ctx, cancel := context.WithTimeout(ctx, archiveFetchTimeout)
82 defer cancel()
83 req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.TarballUrl, nil)
84 if err != nil {
85 return err
86 }
87 if token != "" {
88 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
89 }
90 req.Header.Set("User-Agent", "ermite-runner")
91 resp, err := actionscache.NewClient(j.r.ArchiveHTTP).Do(req)
92 if err != nil {
93 return err
94 }
95 defer func() { _ = resp.Body.Close() }()
96 if resp.StatusCode < 200 || resp.StatusCode > 299 {
97 return fmt.Errorf("status %d", resp.StatusCode)
98 }
99 n, err := io.Copy(w, io.LimitReader(resp.Body, maxArchiveBytes+1))
100 if err == nil && n > maxArchiveBytes {
101 err = errors.New("the archive is larger than 1 GiB")
102 }
103 return err
104}