mirror of
https://gitea.com/gitea/act_runner
synced 2026-09-21 19:37:07 +02:00
fix: stop artifact uploads depending on the cache server reaching Gitea (#1216)
Cache v2 makes the cache server the `ACTIONS_RESULTS_URL` origin, so artifact calls arrived there and were proxied on to Gitea, failing whenever it could not reach the instance. - Artifact calls are answered with a redirect, so the cache server opens no connection to Gitea. A scheme change or an untrusted instance is still proxied, but there the cache server is the runner itself, which already reaches Gitea. - Failures answer in twirp, not an empty `502` that clients report as `Unexpected end of JSON input`. - `cache.v2: false` really points artifacts at Gitea now. - Cache reservations are bound to the job that made them, so two jobs saving one key cannot commit against each other's upload, and a retry after a lost answer no longer fails a saved entry. - The toolkit patch, which edits the GitHub-host check out of an action's bundle, was left in the shared checkout where a job running with `runner.patch_actions: false` could inherit it. It is put back after the job's copy. - `exec` names an origin for the cache v2 it advertises, and masks its runtime token. Behaviour changes: `no_proxy` no longer exempts `cache.external_server`, and `cache.enabled: false` also stops external registration. Fixes https://gitea.com/gitea/runner/issues/1208 Fixes https://gitea.com/gitea/runner/issues/1211 Assisted by Claude (Opus 5). Reviewed-on: https://gitea.com/gitea/runner/pulls/1216 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -291,7 +291,7 @@ The runner uses them for its own requests and gives them to every job, in lower
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- the built-in cache server, whose address is assigned at startup
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
@@ -325,7 +325,9 @@ cache:
|
||||
v2: false
|
||||
```
|
||||
|
||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle on its way into the job, undone whenever the action is downloaded again. A bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says: that setting only governs the API the runner advertises. Set `runner.patch_actions: false` to leave every bundle exactly as shipped, an escape hatch for an action the edit breaks. The artifact actions then refuse again and the cache client keeps to v1.
|
||||
Those actions refuse any host they do not take for GitHub, so the runner edits that check out of the bundle on its way into the job and puts the shared copy back afterwards. A bundle it does not recognise is left alone. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says. Set `runner.patch_actions: false` to leave bundles as shipped; the artifact actions then refuse and the cache client keeps to v1.
|
||||
|
||||
With v2 the job's artifact calls go via the cache server, so jobs need to reach it to upload artifacts, not just to cache. `v2: false` sends them to Gitea directly.
|
||||
|
||||
**Shared cache across multiple runners**
|
||||
|
||||
|
||||
@@ -603,12 +603,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||
return
|
||||
}
|
||||
cred, ok := h.lookupCredential(token)
|
||||
if !ok {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
||||
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||
@@ -617,6 +617,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, err)
|
||||
}
|
||||
|
||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||
@@ -720,6 +728,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
||||
h.responseJSON(w, r, http.StatusOK)
|
||||
}
|
||||
|
||||
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
|
||||
func hashedToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
@@ -805,8 +819,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
bolthold.Where("Key").Eq(key).Index("Key").
|
||||
And("Repo").Eq(repo).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(complete).
|
||||
SortBy(sortBy).Reverse())
|
||||
|
||||
@@ -39,8 +39,8 @@ const (
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
twirpUnauthenticated = "unauthenticated"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
@@ -56,7 +56,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
@@ -83,11 +83,30 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
||||
return
|
||||
}
|
||||
|
||||
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||
owner := hashedToken(bearerToken(r))
|
||||
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||
if pending.Owner != owner {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
|
||||
"ok": true,
|
||||
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Owner: owner,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
@@ -107,7 +126,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,6 +138,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||
cache = nil // not the reservation this job made, so not this job's to commit
|
||||
}
|
||||
if err == nil && cache == nil {
|
||||
// A retry whose first response was lost finds the entry already committed.
|
||||
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
||||
}
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
@@ -127,14 +153,16 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
if !cache.Complete {
|
||||
db.Close() // commitCache needs the store closed
|
||||
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
@@ -147,7 +175,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,8 +258,11 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
switch code {
|
||||
case twirpInternal:
|
||||
status = http.StatusInternalServerError
|
||||
case twirpUnauthenticated:
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
}
|
||||
list.WriteString(`</BlockList>`)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
|
||||
"a client that lost the first answer retries the list it already sent")
|
||||
|
||||
var reordered bytes.Buffer
|
||||
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||
for _, blockID := range []string{order[1], order[0], order[2]} {
|
||||
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
|
||||
}
|
||||
reordered.WriteString(`</BlockList>`)
|
||||
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
|
||||
"the blocks are already assembled, so a different order would silently disagree with them")
|
||||
|
||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||
handler := newTestHandler(t, Policy{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
|
||||
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
|
||||
require.NoError(t, err)
|
||||
if tt.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, tt.status, resp.StatusCode)
|
||||
got := map[string]string{}
|
||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||
assert.Equal(t, tt.code, got["code"])
|
||||
assert.NotEmpty(t, got["msg"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
|
||||
handler := newTestHandler(t, Policy{})
|
||||
content := []byte("the cached archive")
|
||||
|
||||
const otherJob = "another-jobs-token"
|
||||
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
|
||||
reserve := func(client *http.Client) map[string]any {
|
||||
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
|
||||
}
|
||||
first := reserve(testClient)
|
||||
require.Equal(t, true, first["ok"])
|
||||
retry := reserve(testClient)
|
||||
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
|
||||
entryOf := func(reserved map[string]any) string {
|
||||
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
|
||||
require.NoError(t, err)
|
||||
return parsed.Path
|
||||
}
|
||||
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
|
||||
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
|
||||
"another job's reservation would be finalized against the first upload")
|
||||
|
||||
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
|
||||
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
|
||||
require.Equal(t, true, reserved["ok"])
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
|
||||
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||
"another job's upload is not this job's to finalize")
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
|
||||
require.NoError(t, err)
|
||||
var madeByV1 struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
|
||||
resp.Body.Close()
|
||||
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||
resp, err = testClient.Do(upload)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||
"a reservation this job did not make through v2 is not its to commit")
|
||||
|
||||
finalized, _ := saveV2(t, handler, "deps", "v1", content)
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
|
||||
})
|
||||
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
|
||||
assert.Equal(t, finalized["entry_id"], again["entry_id"])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Cache struct {
|
||||
Key string `json:"key" boltholdIndex:"Key"`
|
||||
Version string `json:"version" boltholdIndex:"Version"`
|
||||
Size int64 `json:"cacheSize"`
|
||||
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
|
||||
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||
|
||||
@@ -5,6 +5,8 @@ package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -23,15 +25,33 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
|
||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||
// registered with, and the 404 the router would have written otherwise.
|
||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok {
|
||||
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
|
||||
return
|
||||
}
|
||||
if cred.Results == "" {
|
||||
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
|
||||
return
|
||||
}
|
||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
|
||||
err = errors.New("not an absolute http or https URL the job could use")
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||
return
|
||||
}
|
||||
if !h.mustProxy(r, cred, target) {
|
||||
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||
@@ -44,9 +64,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
// directly and never through here.
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||
},
|
||||
}
|
||||
if cred.InsecureTLS {
|
||||
@@ -55,5 +75,27 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
|
||||
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
|
||||
if cred.InsecureTLS && target.Scheme == "https" {
|
||||
return true
|
||||
}
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
|
||||
scheme = base.Scheme
|
||||
}
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme != target.Scheme
|
||||
}
|
||||
|
||||
func mergeQuery(target, request string) string {
|
||||
if target == "" || request == "" {
|
||||
return target + request
|
||||
}
|
||||
return target + "&" + request
|
||||
}
|
||||
|
||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json/v2"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,8 +16,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
||||
// still point at Gitea, and nothing else is proxied.
|
||||
func TestFrontResultsService(t *testing.T) {
|
||||
var gotHost, gotPath, gotProto string
|
||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer gitea.Close()
|
||||
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||
defer selfSigned.Close()
|
||||
|
||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
const token = "forward-token"
|
||||
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||
|
||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||
post := func(path string) int {
|
||||
noRedirect := func(token string) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &bearerTransport{token: token},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
}
|
||||
post := func(path string) (int, map[string]string) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
resp, err := noRedirect(token).Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
defer resp.Body.Close()
|
||||
body := map[string]string{}
|
||||
_ = json.UnmarshalRead(resp.Body, &body)
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||
"an unregistered token is forwarded nowhere")
|
||||
status, body := post(rpc)
|
||||
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
|
||||
assert.Equal(t, twirpUnauthenticated, body["code"])
|
||||
assert.Contains(t, body["msg"], "bearer")
|
||||
assert.NotEmpty(t, body["msg"])
|
||||
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
|
||||
|
||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
||||
status, _ = post(rpc)
|
||||
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
|
||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||
assert.Equal(t, rpc, gotPath)
|
||||
|
||||
gotPath = ""
|
||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
||||
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = post("/api/v1/repos/owner/repo")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cred JobCredential
|
||||
forwardedProto string
|
||||
reqQuery string
|
||||
want int
|
||||
location string
|
||||
wantMsg string
|
||||
}{
|
||||
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||
{
|
||||
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||
},
|
||||
{
|
||||
name: "a trusted https instance is reached by the job itself",
|
||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "the job does not share this server's disregard for the certificate",
|
||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
|
||||
},
|
||||
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||
{
|
||||
name: "a runner too old to send its public URL leaves the terminator to say so",
|
||||
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "a scheme the job could not use is refused before it is sent one",
|
||||
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||
},
|
||||
{
|
||||
name: "an address with no host is refused before it is sent one",
|
||||
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||
require.NoError(t, err)
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
resp, err := noRedirect(tt.name).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, tt.want, resp.StatusCode)
|
||||
switch tt.want {
|
||||
case http.StatusTemporaryRedirect:
|
||||
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||
case http.StatusInternalServerError:
|
||||
body := map[string]string{}
|
||||
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||
assert.Equal(t, twirpInternal, body["code"])
|
||||
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
|
||||
if recorded, err := os.ReadFile(list); err == nil {
|
||||
// A retry repeats the list it already sent; another one would reorder what is now staged.
|
||||
if !bytes.Equal(recorded, wanted) {
|
||||
return fmt.Errorf("cache %d was already assembled from a different block list", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, blockID := range blockIDs {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(list, wanted, 0o600)
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
|
||||
@@ -147,7 +147,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
|
||||
if !rc.Config.NoActionPatch {
|
||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
||||
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||
}
|
||||
|
||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||
|
||||
+24
-16
@@ -28,8 +28,8 @@ import (
|
||||
// of upload-artifact.
|
||||
//
|
||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
|
||||
// client reaches the cache server by its own address rather than through the results origin.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's
|
||||
// clone lock, which is what keeps another job's checkout from resetting them before the copy.
|
||||
func patchActions(ctx context.Context, scripts []string) {
|
||||
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
|
||||
func patchActions(ctx context.Context, scripts []string) func() {
|
||||
restore := map[string][]byte{}
|
||||
for _, script := range scripts {
|
||||
switch patched, err := patchBundle(script); {
|
||||
case err != nil:
|
||||
original, err := patchBundle(script)
|
||||
if original != nil {
|
||||
restore[script] = original // also when the write failed part way through
|
||||
}
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||
case patched:
|
||||
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
for script, original := range restore {
|
||||
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
|
||||
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func patchBundle(script string) (bool, error) {
|
||||
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||
func patchBundle(script string) ([]byte, error) {
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return false, nil
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return false, nil
|
||||
return nil, nil
|
||||
}
|
||||
// No atomic write needed: every prepare checks the action out and hard resets it.
|
||||
return true, os.WriteFile(script, patched, info.Mode().Perm())
|
||||
return data, os.WriteFile(script, patched, info.Mode().Perm())
|
||||
}
|
||||
|
||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||
|
||||
@@ -176,16 +176,16 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
||||
func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||
script := bundleFile(t, gateTSC)
|
||||
|
||||
done, err := patchBundle(script)
|
||||
original, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, done)
|
||||
require.NotNil(t, original)
|
||||
patched, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(patched)))
|
||||
|
||||
done, err = patchBundle(script)
|
||||
original, err = patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, done, "a patched bundle is not patched again")
|
||||
assert.Nil(t, original, "a patched bundle is not patched again")
|
||||
again, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(patched), string(again))
|
||||
@@ -194,9 +194,9 @@ func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||
script := bundleFile(t, `console.log("checkout")`)
|
||||
|
||||
done, err := patchBundle(script)
|
||||
original, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, done)
|
||||
assert.Nil(t, original)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||
@@ -251,6 +251,9 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
||||
source, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||
return copied
|
||||
}
|
||||
|
||||
|
||||
@@ -28,3 +28,24 @@ func testActionsCacheRoundTrip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testArtifactRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v2 := true
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
options runnerOptions
|
||||
}{
|
||||
{"through_the_cache_server", runnerOptions{cacheV2: &v2}},
|
||||
{"direct_with_no_reachable_cache", runnerOptions{cacheHost: "cache.invalid", cacheV2: new(bool)}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
api, repo, _ := startIsolatedScenario(t, "artifact.yml", "e2e-artifact", tc.options)
|
||||
|
||||
wfRun := waitForRun(t, api, repo)
|
||||
requireSuccess(t, api, repo, wfRun.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestCompatibility(t *testing.T) {
|
||||
|
||||
t.Run("payloads", testPayloads)
|
||||
t.Run("cache", testActionsCacheRoundTrip)
|
||||
t.Run("artifact", testArtifactRoundTrip)
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", testWorkflowDispatch)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
|
||||
@@ -27,6 +27,7 @@ type runnerOptions struct {
|
||||
capacity int
|
||||
ephemeral bool
|
||||
cacheV2 *bool
|
||||
cacheHost string
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||
@@ -49,6 +50,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
||||
cfg.Container.DockerHost = "unix:///var/run/docker.sock"
|
||||
}
|
||||
cfg.Cache.Dir = t.TempDir() + "/cache"
|
||||
cfg.Cache.Host = options.cacheHost
|
||||
cfg.Runner.Insecure = true
|
||||
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
|
||||
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
name: artifact
|
||||
on: push
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- run: |
|
||||
echo "results=$ACTIONS_RESULTS_URL cache=$ACTIONS_CACHE_URL v2=${ACTIONS_CACHE_SERVICE_V2:-unset}"
|
||||
if [ "$ACTIONS_CACHE_SERVICE_V2" = "true" ]; then
|
||||
test "$ACTIONS_RESULTS_URL/" = "$ACTIONS_CACHE_URL"
|
||||
else
|
||||
test "$ACTIONS_RESULTS_URL/" != "$ACTIONS_CACHE_URL"
|
||||
fi
|
||||
mkdir -p out
|
||||
echo "artifact-payload" > out/data.txt
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-artifact-${{ github.run_id }}
|
||||
path: out/data.txt
|
||||
download:
|
||||
needs: upload
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-artifact-${{ github.run_id }}
|
||||
path: got
|
||||
- run: grep -q artifact-payload got/data.txt
|
||||
@@ -5,6 +5,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -187,7 +188,10 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||
|
||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
||||
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||
if envs["ACTIONS_RESULTS_URL"] == "" {
|
||||
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
|
||||
}
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
|
||||
return envs
|
||||
@@ -540,6 +544,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
config.Env["ACT_EXEC"] = "true"
|
||||
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||
|
||||
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
|
||||
config.Token = t
|
||||
|
||||
@@ -12,9 +12,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -307,3 +310,19 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
require.NoError(t, r.Close())
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
|
||||
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
args := &executeArgs{cacheHandler: handler}
|
||||
|
||||
envs := args.LoadEnvs()
|
||||
assert.Equal(t, handler.ExternalURL(), envs["ACTIONS_RESULTS_URL"],
|
||||
"the v2 flag is advertised, so something has to serve that origin")
|
||||
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||
|
||||
args.envs = []string{"ACTIONS_RESULTS_URL=https://gitea.example"}
|
||||
assert.Equal(t, "https://gitea.example", args.LoadEnvs()["ACTIONS_RESULTS_URL"], "a supplied origin wins")
|
||||
}
|
||||
|
||||
+47
-11
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -89,7 +90,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||
maps.Copy(envs, cfg.Runner.Envs)
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
if cacheEnabled(cfg) {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
warnIgnoredCachePolicy(cfg)
|
||||
// The v1 client appends its path to this without a separator, so the slash is required.
|
||||
@@ -109,6 +110,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
} else {
|
||||
cacheHandler = handler
|
||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
||||
warnIfCacheUnreachable(cfg, handler.ExternalURL())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,7 +390,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
|
||||
// Added per task because this job's service containers must be reached directly, and
|
||||
// act reaches them by their workflow key.
|
||||
proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services)))
|
||||
proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services)))
|
||||
maps.Copy(envs, proxyEnv)
|
||||
|
||||
if r.capabilities != "" {
|
||||
@@ -454,14 +456,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
// is that server's responsibility to authenticate requests.
|
||||
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
|
||||
defer revokeCache()
|
||||
// A cache server that agreed to forward the artifact half is the whole results service, so the
|
||||
// job is pointed at it.
|
||||
if resultsURL != "" {
|
||||
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
||||
if r.cacheServiceV2() {
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
}
|
||||
}
|
||||
r.setResultsService(envs, resultsURL)
|
||||
|
||||
eventJSON, err := json.Marshal(preset.Event)
|
||||
if err != nil {
|
||||
@@ -584,12 +579,43 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return execErr
|
||||
}
|
||||
|
||||
func (r *Runner) builtInCacheURL() string {
|
||||
if r.cacheHandler == nil {
|
||||
return ""
|
||||
}
|
||||
return r.envs["ACTIONS_CACHE_URL"]
|
||||
}
|
||||
|
||||
func cacheEnabled(cfg *config.Config) bool {
|
||||
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
|
||||
}
|
||||
|
||||
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
|
||||
// turns off: the bundle edit that reaches it is what the artifact actions need too.
|
||||
func (r *Runner) cacheServiceV2() bool {
|
||||
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
|
||||
}
|
||||
|
||||
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
||||
v2 := resultsURL != "" && r.cacheServiceV2()
|
||||
if v2 {
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
} else {
|
||||
delete(envs, runner.CacheServiceV2Env)
|
||||
}
|
||||
// Cache v2 clients read no other variable for it, and some cannot use the instance address.
|
||||
if v2 || (resultsURL != "" && r.instanceOutOfReach(envs["ACTIONS_RESULTS_URL"])) {
|
||||
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
||||
}
|
||||
}
|
||||
|
||||
// These clients keep only the origin they are handed, and reach it on the job's own trust.
|
||||
func (r *Runner) instanceOutOfReach(instance string) bool {
|
||||
parsed, err := url.Parse(instance)
|
||||
return err != nil || strings.Trim(parsed.Path, "/") != "" ||
|
||||
(r.cfg.Runner.Insecure && parsed.Scheme == "https")
|
||||
}
|
||||
|
||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||
// with the given runtime token for the duration of this task. Returns a
|
||||
// function the caller must invoke (typically via defer) to revoke the
|
||||
@@ -616,7 +642,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
||||
if r.cacheHandler != nil {
|
||||
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
|
||||
}
|
||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
return r.registerExternalCacheJob(token, cred, reporter)
|
||||
}
|
||||
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
||||
@@ -799,3 +825,13 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
}
|
||||
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
||||
}
|
||||
|
||||
func warnIfCacheUnreachable(cfg *config.Config, cacheURL string) {
|
||||
if cfg.Cache.Host != "" || cfg.Container.Network != "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat("/.dockerenv"); err != nil {
|
||||
return
|
||||
}
|
||||
log.Warnf("jobs are given %s for the cache server; if they cannot reach it, set container.network to a network this runner is on, or cache.host", cacheURL)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,13 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be unknown to the remote server before registration")
|
||||
|
||||
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{
|
||||
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
|
||||
}}}
|
||||
disabled.registerCacheForTask(token, repo, nil)
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"a disabled cache registers nothing, whatever external server is left configured")
|
||||
|
||||
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
|
||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token must be accepted after registerCacheForTask")
|
||||
|
||||
@@ -143,6 +143,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
||||
|
||||
require.NotContains(t, r.envs, "http_proxy")
|
||||
require.NotContains(t, r.envs, "no_proxy")
|
||||
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours")
|
||||
}
|
||||
|
||||
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
||||
@@ -189,11 +190,38 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
|
||||
|
||||
envs := r.cloneEnvs()
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"])
|
||||
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||
|
||||
// Turning v2 off withdraws the advertisement and nothing else.
|
||||
assert.True(t, r.cacheServiceV2())
|
||||
cfg.Cache.V2 = new(bool)
|
||||
assert.False(t, r.cacheServiceV2())
|
||||
envs = r.cloneEnvs()
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, "https://gitea.example", envs["ACTIONS_RESULTS_URL"])
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env], "a runner.envs entry would promise v2 at an origin not serving it")
|
||||
|
||||
envs = r.cloneEnvs()
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
envs["ACTIONS_RESULTS_URL"] = "https://gitea.example/sub"
|
||||
r.setResultsService(envs, "")
|
||||
assert.Equal(t, "https://gitea.example/sub", envs["ACTIONS_RESULTS_URL"], "with no cache server there is nothing to front with")
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||
|
||||
for instance, insecure := range map[string]bool{
|
||||
"https://gitea.example/sub": false,
|
||||
"https://self-signed.example": true,
|
||||
} {
|
||||
cfg.Runner.Insecure = insecure
|
||||
envs = r.cloneEnvs()
|
||||
envs["ACTIONS_RESULTS_URL"] = instance
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||
}
|
||||
}
|
||||
|
||||
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
|
||||
|
||||
@@ -139,7 +139,8 @@ runner:
|
||||
# job_completed: ''
|
||||
|
||||
cache:
|
||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
|
||||
# started and no job is registered with an external_server either.
|
||||
#enabled: true
|
||||
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||
# Ignored when external_server is set.
|
||||
@@ -176,9 +177,9 @@ cache:
|
||||
# until its cache entry expires or is manually removed.
|
||||
#offline_mode: false
|
||||
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not
|
||||
# take for GitHub, so reaching it means editing that check out of their own bundle, undone
|
||||
# whenever it is downloaded again. That edit is made either way, this only governs the API
|
||||
# advertised. A bundle that does not match is left alone.
|
||||
# take for GitHub, so reaching it means editing that check out of their own bundle, put back
|
||||
# after the copy into the job. That edit is made either way, this only governs the API
|
||||
# advertised. A bundle that does not match is left alone. With v2, uploads need a reachable cache.
|
||||
#v2: true
|
||||
# How the cache server discards entries, ignored when external_server is set since that
|
||||
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
|
||||
|
||||
@@ -26,7 +26,7 @@ esac
|
||||
default_tests=false
|
||||
if [ $# -eq 0 ]; then
|
||||
default_tests=true
|
||||
set -- -count=1 -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
|
||||
set -- -count=1 -race -run '^TestDocker$' ./act/container/
|
||||
fi
|
||||
|
||||
port="${DIND_TEST_PORT:-32375}"
|
||||
|
||||
Reference in New Issue
Block a user