mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
feat!: add cache service v2, add toolkit patches (#1110)
Serves `github.actions.results.api.v1.CacheService` next to the v1 cache API, sharing its store, plus the subset of the Azure blob protocol the toolkit uploads with. On by default via `cache.v2`, and works with `external_server`. Clients reach it through two edits in the action's own bundle: the GHES check is opened, and the cache service URL is taken from `ACTIONS_CACHE_URL`. The same GHES check is what makes the stock `actions/upload-artifact` and `download-artifact` abort on Gitea. Opening it makes them work without the `gitea-upload-artifact` fork, from `upload-artifact@v4.4.0` on. Verified against 118 real bundles, every major version of 16 actions: 92 patched, the rest deliberately left alone, and every patched bundle checked with `node --check`. Also end to end against pinned `actions/cache@v6.1.0` with an unreachable results URL, so only the patch can make the cache work. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1110 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
@@ -158,12 +158,13 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||
// does not attach Authorization when downloading archiveLocation.
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||
// cache API can authenticate them. Always wired so the routes exist; the
|
||||
// handlers themselves 401 when internalSecret is unset.
|
||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||
h.registerV2Routes(router)
|
||||
|
||||
h.router = router
|
||||
|
||||
@@ -339,7 +340,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findCache(db, cred.Repo, keys, version)
|
||||
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
@@ -348,15 +349,6 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.storage.Exist(cache.ID); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
@@ -364,6 +356,25 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
})
|
||||
}
|
||||
|
||||
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
||||
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
||||
// entry is dropped on the way out.
|
||||
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||
cache, err := findCache(db, repo, keys, version)
|
||||
if err != nil || cache == nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := h.storage.Exist(cache.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
return nil, nil //nolint:nilnil // absence is not an error here
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
@@ -438,7 +449,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
@@ -479,23 +490,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
|
||||
db.Close()
|
||||
|
||||
size, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = size
|
||||
|
||||
db, err = h.openDB()
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache.Complete = true
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
@@ -503,8 +498,28 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
|
||||
// have closed its store first: Commit concatenates the whole archive and would otherwise
|
||||
// hold bolt's exclusive file lock for the duration.
|
||||
func (h *Handler) commitCache(cache *Cache) error {
|
||||
written, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = written
|
||||
cache.Complete = true
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
||||
// Authenticated via signed URL (see signedAuth), not bearer, because the
|
||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||
// Repository scoping is already enforced at find() time; the signature binds
|
||||
// the URL to the specific cache ID and an expiry.
|
||||
@@ -514,7 +529,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.storage.Serve(w, r, uint64(id))
|
||||
}
|
||||
|
||||
@@ -548,7 +563,9 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
// 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 {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
@@ -571,7 +588,7 @@ func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||
return
|
||||
}
|
||||
expected := h.computeSignature(id, exp)
|
||||
expected := h.computeSignature(purpose, id, exp)
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||
return
|
||||
@@ -655,19 +672,26 @@ func credFromContext(ctx context.Context) JobCredential {
|
||||
return JobCredential{}
|
||||
}
|
||||
|
||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
||||
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
|
||||
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
|
||||
// other way round. Downloads use the empty purpose, the message v1 has always signed.
|
||||
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
||||
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
||||
expUnix := exp.Unix()
|
||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
||||
q := url.Values{}
|
||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||
q.Set("sig", sig)
|
||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
||||
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
@@ -675,16 +699,12 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
cache := &Cache{}
|
||||
for _, prefix := range keys {
|
||||
// if a key in the list matches exactly, don't return partial matches
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(prefix).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
exact, err := findExactCache(db, repo, prefix, version, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exact != nil {
|
||||
return exact, nil
|
||||
}
|
||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||
re, err := regexp.Compile(prefixPattern)
|
||||
@@ -707,6 +727,34 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
|
||||
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
|
||||
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
|
||||
//
|
||||
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
||||
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||
// touches UsedAt on every part.
|
||||
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
|
||||
sortBy := "UsedAt"
|
||||
if complete {
|
||||
sortBy = "CreatedAt"
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(complete).
|
||||
SortBy(sortBy).Reverse())
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
return nil, nil //nolint:nilnil // absence is not an error here
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||
return fmt.Errorf("insert cache: %w", err)
|
||||
@@ -718,18 +766,30 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) useCache(id int64) {
|
||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
|
||||
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
|
||||
// other jobs restore no longer matching its recorded size. An entry missing from the store is
|
||||
// accepted, since the signature proves the id was handed out.
|
||||
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache := &Cache{}
|
||||
if err := db.Get(id, cache); err != nil {
|
||||
return
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if requireIncomplete && cache.Complete {
|
||||
return fmt.Errorf("cache %d: already complete", id)
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
_ = db.Update(cache.ID, cache)
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1041,14 +1041,14 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||
sig := first.computeSignature(42, exp)
|
||||
sig := first.computeSignature("", 42, exp)
|
||||
require.NoError(t, first.Close())
|
||||
|
||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer second.Close()
|
||||
|
||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
||||
assert.Equal(t, sig, second.computeSignature("", 42, exp))
|
||||
}
|
||||
|
||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
||||
|
||||
268
act/artifactcache/handler_v2.go
Normal file
268
act/artifactcache/handler_v2.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
// The cache service v2 API. A client on this version talks twirp to
|
||||
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||
// Both API versions are served from the same store, so a repository keeps its cache
|
||||
// when a workflow moves between action versions.
|
||||
const (
|
||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||
|
||||
// blobPath authenticates by signature, because the client uploads without an
|
||||
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||
blobPath = apiPath + "/blobs"
|
||||
|
||||
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||
blobUploadPurpose = "upload:"
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||
}
|
||||
|
||||
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||
// the upload.
|
||||
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||
// existing and silently never saved.
|
||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
} else if existing != nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
}
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||
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,
|
||||
// int64 fields travel as strings in the proto JSON mapping.
|
||||
"entryId": strconv.FormatUint(cache.ID, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
"matchedKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||
// in order.
|
||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.touchCache(id, true); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
switch strings.ToLower(query.Get("comp")) {
|
||||
case "block":
|
||||
blockID := query.Get("blockid")
|
||||
if blockID == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||
return
|
||||
}
|
||||
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||
case "blocklist":
|
||||
var list struct{ Latest []string }
|
||||
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||
return
|
||||
}
|
||||
err = h.storage.OrderBlocks(id, list.Latest)
|
||||
default:
|
||||
err = h.storage.Write(id, 0, r.Body)
|
||||
}
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||
}
|
||||
|
||||
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||
// instead of a parse error.
|
||||
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 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||
type (
|
||||
v2CreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
v2FinalizeRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes json.Number `json:"size_bytes"`
|
||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
v2DownloadRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
RestoreKeys []string `json:"restore_keys"`
|
||||
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||
}
|
||||
)
|
||||
|
||||
func (d v2DownloadRequest) keys() []string {
|
||||
restoreKeys := d.RestoreKeys
|
||||
if len(restoreKeys) == 0 {
|
||||
restoreKeys = d.RestoreKeysCamel
|
||||
}
|
||||
return append([]string{d.Key}, restoreKeys...)
|
||||
}
|
||||
|
||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||
var req T
|
||||
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||
return req, err
|
||||
}
|
||||
236
act/artifactcache/handler_v2_test.go
Normal file
236
act/artifactcache/handler_v2_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// v2Call posts a twirp request to the cache service and returns the decoded response.
|
||||
// Field names are the proto ones, which is what the toolkit's client sends.
|
||||
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
return got
|
||||
}
|
||||
|
||||
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
|
||||
func putBlob(t *testing.T, url string, content []byte) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getURL(t *testing.T, url string) []byte {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func startTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
|
||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
return handler
|
||||
}
|
||||
|
||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||
// with the upload URL it used.
|
||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||
t.Helper()
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||
require.Equal(t, true, created["ok"])
|
||||
uploadURL, _ = created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||
|
||||
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": key, "version": version,
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
}), uploadURL
|
||||
}
|
||||
|
||||
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
|
||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||
// or to replace a finalized entry.
|
||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
content := []byte("the cached archive")
|
||||
|
||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||
|
||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
assert.NotEmpty(t, finalized["entryId"])
|
||||
|
||||
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||
// and it is an upload URL only: nothing reads a blob back through it.
|
||||
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
|
||||
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-v1", got["matchedKey"])
|
||||
downloadURL, _ := got["signedDownloadUrl"].(string)
|
||||
require.NotEmpty(t, downloadURL)
|
||||
assert.Equal(t, content, getURL(t, downloadURL))
|
||||
}
|
||||
|
||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||
uploadURL, _ := created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
|
||||
blocks := map[string][]byte{}
|
||||
var order []string
|
||||
for i, part := range []string{"hello ", "world", "!"} {
|
||||
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||
blocks[blockID] = []byte(part)
|
||||
order = append(order, blockID)
|
||||
}
|
||||
// Upload in an order that is not the block list order.
|
||||
for _, blockID := range []string{order[2], order[0], order[1]} {
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||
}
|
||||
|
||||
var list bytes.Buffer
|
||||
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||
for _, blockID := range order {
|
||||
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||
}
|
||||
list.WriteString(`</BlockList>`)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||
|
||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
})
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
||||
}
|
||||
|
||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||
require.Equal(t, true, saved["ok"])
|
||||
|
||||
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The toolkit serialises with the proto field names; the camelCase spellings of the same
|
||||
// proto JSON mapping are accepted alongside them.
|
||||
for _, field := range []string{"restore_keys", "restoreKeys"} {
|
||||
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||
})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-abc", got["matchedKey"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, again["ok"])
|
||||
})
|
||||
|
||||
// A key that is only a prefix of an existing one is a different entry, so the
|
||||
// reservation check must be exact and not a restore-key prefix match, or the shorter
|
||||
// key would be reported as existing and silently never saved.
|
||||
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||
require.Equal(t, true, reserved["ok"])
|
||||
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
||||
})
|
||||
|
||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The size the client declares is what Commit validates the assembled archive against.
|
||||
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
||||
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// Both API versions are served from one store, so an entry written through v2 is a hit for
|
||||
// a v1 client asking for the same key and version.
|
||||
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||
assert.NotEmpty(t, got["archiveLocation"])
|
||||
})
|
||||
|
||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||
handler.RegisterJob("other-runtime-token", "other/repo")
|
||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||
|
||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
}
|
||||
@@ -5,12 +5,15 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
return s.writeFile(s.tempName(id, offset), reader)
|
||||
}
|
||||
|
||||
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||
return s.writeFile(s.blockName(id, blockID), reader)
|
||||
}
|
||||
|
||||
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||
// 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 {
|
||||
for i, blockID := range blockIDs {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := assemble(name, tempNames)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||
func assemble(name string, tempNames []string) (int64, error) {
|
||||
if len(tempNames) == 1 {
|
||||
info, err := os.Stat(tempNames[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), os.Rename(tempNames[0], name)
|
||||
}
|
||||
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -119,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||
// Commit's name-ordered concatenation.
|
||||
const blockFilePrefix = "block-"
|
||||
|
||||
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||
// path element.
|
||||
sum := sha256.Sum256([]byte(blockID))
|
||||
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
@@ -127,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,8 +595,8 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
if sar, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = sar.actionDir()
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
|
||||
@@ -69,6 +69,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
||||
}
|
||||
}
|
||||
// Actions served from the action cache are read out of a git object store rather than a
|
||||
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
||||
if sar.RunContext.Config.ActionCache != nil {
|
||||
cache := sar.RunContext.Config.ActionCache
|
||||
|
||||
@@ -112,7 +114,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
@@ -171,6 +173,9 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
// A stage of its own: it takes the same clone lock, and it has to land before
|
||||
// runAction copies the action into the job container.
|
||||
sar.patchActionToolkit,
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +194,7 @@ func (sar *stepActionRemote) pre() common.Executor {
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) main() common.Executor {
|
||||
@@ -211,15 +216,51 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
|
||||
return sar.runAction(sar, actionDir, sar.remoteAction)(ctx)
|
||||
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) post() common.Executor {
|
||||
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
}
|
||||
|
||||
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
||||
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
||||
if sar.remoteAction == nil {
|
||||
return "", nil
|
||||
}
|
||||
dir := sar.actionDir()
|
||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||
}
|
||||
|
||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea, which lets the cache
|
||||
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
patchToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
||||
// runs it as shipped rather than repeating a failure the patch may have caused.
|
||||
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := exec(ctx)
|
||||
if err != nil {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
revertToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) actionDir() string {
|
||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||
@@ -270,7 +311,7 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
|
||||
|
||||
262
act/runner/toolkit_patch.go
Normal file
262
act/runner/toolkit_patch.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||
// from working against Gitea. Both are edited out of the bundle the runner downloaded.
|
||||
//
|
||||
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
|
||||
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
|
||||
// which is why the stock upload-artifact aborts here. The edit empties the last of the three
|
||||
// hostname tests, so `endsWith('.LOCALHOST')` becomes `endsWith(”)`, which every hostname
|
||||
// satisfies: one string literal, no call sites to resolve, and the same answer the toolkit's own
|
||||
// proposed ACTIONS_VENDOR switch would give. Gitea already makes this edit by hand in its fork
|
||||
// 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.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
// https://github.com/actions/toolkit/pull/2123 — an ACTIONS_VENDOR switch, naming Gitea
|
||||
// https://github.com/actions/toolkit/issues/2439 — treat ACTIONS_RESULTS_URL as the signal
|
||||
const (
|
||||
CacheServiceV2Env = "ACTIONS_CACHE_SERVICE_V2"
|
||||
cacheURLEnv = "ACTIONS_CACHE_URL"
|
||||
resultsURLEnv = "ACTIONS_RESULTS_URL"
|
||||
|
||||
// localhostHost is the suffix isGhes accepts; emptying the test is what opens the gate,
|
||||
// because every hostname ends with the empty string.
|
||||
localhostHost = ".LOCALHOST"
|
||||
|
||||
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
|
||||
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
|
||||
// runner has not looked at, and is left alone.
|
||||
artifactRefusal = "GHESNotSupportedError"
|
||||
|
||||
// sidecarSuffix names the directory of untouched copies, a sibling of the action directory
|
||||
// because that directory is copied wholesale into job containers.
|
||||
sidecarSuffix = ".toolkit-patch"
|
||||
|
||||
// skipMarker in the sidecar means a patched bundle already failed once here.
|
||||
skipMarker = "skip"
|
||||
|
||||
maxBundleSize = 64 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
// localhostTest matches the third hostname test of isGhes, in any quoting. The match is case
|
||||
// sensitive on purpose, and that is load-bearing: isGhes uppercases the hostname before
|
||||
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
|
||||
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
|
||||
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
|
||||
// covering every major version of sixteen actions.
|
||||
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
|
||||
|
||||
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
|
||||
// the cache URL and falls back to the results URL, the v2 branch just below reads the results
|
||||
// URL alone. That `||` pairing is the only place the two variables are read together, so
|
||||
// matching them as one expression is what keeps the edit inside this function rather than
|
||||
// anywhere they happen to sit near each other. The branches are 21 bytes apart minified and
|
||||
// 63 not, across every bundle measured.
|
||||
serviceURLBranches = regexp.MustCompile(`(` + envRead(cacheURLEnv) + `\s*\|\|\s*)(` +
|
||||
envRead(resultsURLEnv) + `)((?s).{0,256}?)(` + envRead(resultsURLEnv) + `)`)
|
||||
|
||||
// cacheURLFirst gives both reads the preference the v1 branch already had.
|
||||
cacheURLFirst = []byte(`${1}(process.env.` + cacheURLEnv + `||${2})${3}(process.env.` + cacheURLEnv + `||${4})`)
|
||||
)
|
||||
|
||||
func envRead(name string) string {
|
||||
return `process\s*\.\s*env\s*(?:\.\s*` + name + `\b|\[\s*` + quoted(name) + `\s*\])`
|
||||
}
|
||||
|
||||
// quoted matches a string literal in any of the three quote characters. RE2 has no
|
||||
// backreferences, so the pairs are spelled out.
|
||||
func quoted(pattern string) string {
|
||||
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
|
||||
}
|
||||
|
||||
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only
|
||||
// remote actions get here: a local one lives in the user's checkout, which the runner does not
|
||||
// rewrite.
|
||||
func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
if action == nil || !action.Runs.Using.IsNode() {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
|
||||
if script != "" {
|
||||
paths = append(paths, filepath.Join(dir, script))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// patchToolkit edits the toolkit in an action's bundles, keeping each original beside them. Every
|
||||
// failure is silent and leaves the bundle as it was, which costs the cache client the v2 API and
|
||||
// an artifact action nothing at all.
|
||||
func patchToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sidecarDir(actionDir), skipMarker)); err == nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
for _, script := range scripts {
|
||||
if err := patchBundle(script, originalFor(actionDir, script)); err != nil {
|
||||
common.Logger(ctx).Debugf("actions toolkit: %s left unpatched: %v", filepath.Base(script), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revertToolkit puts the originals back and stops this action being patched again, so the next job
|
||||
// runs it exactly as shipped. Called when a step failed with a patched bundle; it does not re-run
|
||||
// the step, because a step's outputs and env-file writes are already recorded by then.
|
||||
func revertToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(sidecarDir(actionDir)); err != nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
reverted := false
|
||||
for _, script := range scripts {
|
||||
original := originalFor(actionDir, script)
|
||||
if !isPatchOf(original, script) {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(original, script); err == nil {
|
||||
reverted = true
|
||||
}
|
||||
}
|
||||
if reverted {
|
||||
_ = os.WriteFile(filepath.Join(sidecarDir(actionDir), skipMarker), nil, 0o600)
|
||||
common.Logger(ctx).Warnf("actions toolkit: restored the original %s, it will not be patched again", filepath.Base(actionDir))
|
||||
}
|
||||
}
|
||||
|
||||
// sidecarDir holds an action's untouched bundles, and the marker that stops it being patched.
|
||||
func sidecarDir(actionDir string) string {
|
||||
return actionDir + sidecarSuffix
|
||||
}
|
||||
|
||||
// originalFor is where a script's untouched copy lives, or "" for a script the action's own
|
||||
// `runs` keys placed outside its directory, which is not this runner's to rewrite.
|
||||
func originalFor(actionDir, script string) string {
|
||||
rel, err := filepath.Rel(actionDir, script)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(sidecarDir(actionDir), rel)
|
||||
}
|
||||
|
||||
// patchBundle rewrites one entrypoint in place. The untouched copy kept beside it is what marks
|
||||
// the bundle as already patched.
|
||||
func patchBundle(script, original string) error {
|
||||
if original == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(original); err == nil {
|
||||
if isPatchOf(original, script) {
|
||||
return nil
|
||||
}
|
||||
// The action's ref moved and git checked the new bundle out over the patched one, so
|
||||
// the pair no longer belongs together. Patch afresh rather than keep an original that
|
||||
// would restore an older version of the action.
|
||||
if err := os.Remove(original); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(original), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// The copy is taken before the bundle is replaced, so a write that fails part way can put the
|
||||
// action back as it was. A crash needs no handling: the clone executor checks the action out
|
||||
// and hard resets it on every prepare, so a half-written bundle never outlives the job.
|
||||
if err := os.WriteFile(original, data, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(script, patched, info.Mode().Perm()); err != nil {
|
||||
_ = os.Rename(original, script)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPatchOf reports whether script is exactly what patching original produced. It is what proves
|
||||
// the two still belong together: an action whose ref moved is checked out over the patched bundle,
|
||||
// leaving an original that would restore the version before the move.
|
||||
func isPatchOf(original, script string) bool {
|
||||
data, err := os.ReadFile(original)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
return ok && bytes.Equal(patched, current)
|
||||
}
|
||||
|
||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||
// service at the cache server. A bundle this runner cannot account for comes back untouched.
|
||||
func patchedBundle(data []byte) ([]byte, bool) {
|
||||
if !localhostTest.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
switch {
|
||||
case bytes.Contains(data, []byte(CacheServiceV2Env)):
|
||||
// The cache toolkit: both edits or neither, because choosing v2 without redirecting the
|
||||
// URL would send the client to a results URL that serves no cache service.
|
||||
if !serviceURLBranches.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
case bytes.Contains(data, []byte(artifactRefusal)):
|
||||
// The artifact toolkit, where the gate is a plain refusal and there is no URL to move:
|
||||
// artifacts already go to Gitea, which implements that service.
|
||||
default:
|
||||
return data, false
|
||||
}
|
||||
|
||||
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
|
||||
// Drop the hostname from the test rather than rewriting the call, so the bundle's own
|
||||
// quoting survives and the result stays valid even inside a string literal.
|
||||
return bytes.Replace(test, []byte(localhostHost), nil, 1)
|
||||
})
|
||||
return serviceURLBranches.ReplaceAll(opened, cacheURLFirst), true
|
||||
}
|
||||
196
act/runner/toolkit_patch_e2e_test.go
Normal file
196
act/runner/toolkit_patch_e2e_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// actionsCacheRef pins the actions/cache release this is verified against. Bump it
|
||||
// deliberately: a new release is exactly what can stop the patch matching.
|
||||
const actionsCacheRef = "v6.1.0"
|
||||
|
||||
// bundleFromGitHub downloads one entrypoint, keeping it in the user cache dir so repeated runs
|
||||
// cost nothing. The bundles are megabytes, too large to vendor.
|
||||
func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
||||
t.Helper()
|
||||
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
require.NoError(t, err)
|
||||
dir := filepath.Join(cacheDir, "gitea-runner-test", strings.ReplaceAll(repo, "/", "-")+"-"+ref)
|
||||
bundle := filepath.Join(dir, strings.ReplaceAll(path, "/", "-"))
|
||||
if _, err := os.Stat(bundle); err == nil {
|
||||
return bundle
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
|
||||
url := "https://raw.githubusercontent.com/" + repo + "/" + ref + "/" + path
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Skipf("cannot reach %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
file, err := os.Create(bundle)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
require.NoError(t, file.Close())
|
||||
require.NoError(t, err)
|
||||
return bundle
|
||||
}
|
||||
|
||||
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
||||
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
||||
// so only the patch can make the client choose v2 and find the cache server.
|
||||
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
||||
t.Helper()
|
||||
state := filepath.Join(runnerTemp, "state")
|
||||
output := filepath.Join(runnerTemp, "output")
|
||||
for _, name := range []string{state, output} {
|
||||
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(t.Context(), "node", script)
|
||||
cmd.Dir = workspace
|
||||
cmd.Env = append(os.Environ(),
|
||||
"INPUT_PATH=to-cache",
|
||||
"INPUT_KEY="+key,
|
||||
"ACTIONS_RUNTIME_TOKEN="+token,
|
||||
"ACTIONS_CACHE_URL="+cacheURL+"/",
|
||||
// Unreachable on purpose: the artifact service lives here, the cache service must not.
|
||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||
"GITHUB_REF=refs/heads/main",
|
||||
"GITHUB_EVENT_NAME=push",
|
||||
"GITHUB_WORKSPACE="+workspace,
|
||||
"RUNNER_TEMP="+runnerTemp,
|
||||
"GITHUB_STATE="+state,
|
||||
"GITHUB_OUTPUT="+output,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
||||
require.NoError(t, err, "%s failed", script)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
||||
// to /private/var and the client derives archive paths relative to the workspace.
|
||||
func tempDirPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
return dir
|
||||
}
|
||||
|
||||
// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the
|
||||
// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it
|
||||
// save and restore through this runner's cache server. The unreachable results URL is the point,
|
||||
// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release
|
||||
// stops matching the patch the client falls back to v1 and this fails on the version line, which
|
||||
// is the signal to look at the new bundle.
|
||||
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
// A stand-in action directory, patched exactly as a downloaded one would be.
|
||||
actionDir := tempDirPath(t)
|
||||
scripts := map[string]string{}
|
||||
for _, stage := range []string{"restore", "save"} {
|
||||
body, err := os.ReadFile(bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/"+stage+"/index.js"))
|
||||
require.NoError(t, err)
|
||||
scripts[stage] = filepath.Join(actionDir, stage+".js")
|
||||
require.NoError(t, os.WriteFile(scripts[stage], body, 0o600))
|
||||
}
|
||||
patchToolkit(t.Context(), actionDir, []string{scripts["restore"], scripts["save"]})
|
||||
|
||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||
handler.RegisterJob(token, repo)
|
||||
|
||||
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
||||
content := []byte("cached through the patched gate")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
||||
|
||||
const key = "patched-gate-key"
|
||||
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
||||
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
||||
|
||||
saved := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||
|
||||
restored := tempDirPath(t)
|
||||
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, got)
|
||||
}
|
||||
|
||||
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||
// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is
|
||||
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||
// the two shapes and leaving every cache on v1.
|
||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||
for _, tc := range []struct{ repo, ref, path string }{
|
||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
||||
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
||||
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
||||
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||
// releases before it refuse in a shape the runner leaves alone.
|
||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js"},
|
||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
||||
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
||||
} {
|
||||
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := os.ReadFile(bundleFromGitHub(t, tc.repo, tc.ref, tc.path))
|
||||
require.NoError(t, err)
|
||||
|
||||
out, patched := patchedBundle(data)
|
||||
assert.True(t, patched, "the version gate was not patched")
|
||||
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
||||
|
||||
if !strings.Contains(string(data), CacheServiceV2Env) {
|
||||
return // the artifact toolkit: a refusal to open, and no URL to move
|
||||
}
|
||||
// Only the reads inside getCacheServiceURL are rewritten. The others, such as the
|
||||
// feature-availability check, must be left as they are.
|
||||
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
|
||||
"the cache service URL was not redirected")
|
||||
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
|
||||
"a read of the results URL was lost, it must stay as the fallback")
|
||||
})
|
||||
}
|
||||
}
|
||||
328
act/runner/toolkit_patch_test.go
Normal file
328
act/runner/toolkit_patch_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The three shapes real bundlers emit, reduced to the bytes that matter: the version gate, and
|
||||
// the URL getter that follows it. tsc keeps the names, webpack prefixes them, esbuild mangles
|
||||
// them, writes ternaries in place of the switch, and records the real name in the export
|
||||
// assignment. Each carries both reads of the results URL, as the real getter does.
|
||||
const (
|
||||
urlTSC = `function getCacheServiceURL() {` + "\n" + ` switch (getCacheServiceVersion()) {` + "\n" + ` case 'v1':` + "\n" + ` return (process.env['ACTIONS_CACHE_URL'] || process.env['ACTIONS_RESULTS_URL'] || '');` + "\n" + ` case 'v2':` + "\n" + ` return process.env['ACTIONS_RESULTS_URL'] || '';` + "\n" + ` }` + "\n" + `}`
|
||||
urlEsbuild = `function YK(){let e=XK();return e==="v1"?process.env.ACTIONS_CACHE_URL||process.env.ACTIONS_RESULTS_URL||"":e==="v2"?process.env.ACTIONS_RESULTS_URL||"":""}`
|
||||
|
||||
isGhesTSC = `function isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}`
|
||||
gateTSC = isGhesTSC + "\n" + `function getCacheServiceVersion() {` + "\n" + ` if (isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||
gateWebpack = `function config_isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}` + "\n" + `function config_getCacheServiceVersion() {` + "\n" + ` if (config_isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||
gateEsbuild = `vu.isGhes=$K;vu.getCacheServiceVersion=XK;function $K(){let e=new URL(process.env.GITHUB_SERVER_URL||"https://github.com").hostname.toUpperCase(),r=e==="GITHUB.COM",n=e.endsWith(".GHE.COM"),i=e.endsWith(".LOCALHOST");return!r&&!n&&!i}function XK(){return $K()?"v1":process.env.ACTIONS_CACHE_SERVICE_V2?"v2":"v1"}` + urlEsbuild
|
||||
)
|
||||
|
||||
func TestPatchedBundle(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
wantPatched bool
|
||||
}{
|
||||
{"tsc keeps the names", gateTSC, true},
|
||||
{"webpack prefixes them", gateWebpack, true},
|
||||
{"esbuild mangles and minifies them", gateEsbuild, true},
|
||||
// A bundler picks its own quoting; gateTSC is single-quoted already.
|
||||
{"double-quoted", requoted(`"`), true},
|
||||
{"backtick-quoted", requoted("`"), true},
|
||||
// sccache-action sets the variable itself; there is no gate to open.
|
||||
{"mentions the variable without the gate", `core.exportVariable("ACTIONS_CACHE_SERVICE_V2","on")`, false},
|
||||
// Both edits or neither: a gate patched without the URL would send the client to a
|
||||
// results URL that serves no cache service.
|
||||
{"gate without a recognisable url getter", strings.TrimSuffix(gateTSC, "\n"+urlTSC), false},
|
||||
// And the other way round: an action that reads both variables but has no gate to open.
|
||||
{"url getter without a gate", urlTSC, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, patched := patchedBundle([]byte(tc.body))
|
||||
assert.Equal(t, tc.wantPatched, patched)
|
||||
if !tc.wantPatched {
|
||||
assert.Equal(t, tc.body, string(out), "an unpatched bundle must come back byte for byte")
|
||||
return
|
||||
}
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
// The other two hostname tests are left alone, so a host that really is GitHub or
|
||||
// GHES is still recognised as such.
|
||||
assert.NotContains(t, string(out), ".LOCALHOST", "the localhost test is the one that opens")
|
||||
assert.Contains(t, string(out), ".GHE.COM")
|
||||
|
||||
// Every read of the results URL now prefers the cache URL, and none was lost: the
|
||||
// results URL stays the fallback, so a runner not serving the cache still works.
|
||||
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"), strings.Count(string(out), "ACTIONS_RESULTS_URL"))
|
||||
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"),
|
||||
strings.Count(string(out), "(process.env.ACTIONS_CACHE_URL||process.env"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// undici, bundled into every one of these actions, decides whether to trust a URL with a
|
||||
// lowercase test that reads almost the same. Opening it would tell the HTTP client that every URL
|
||||
// is trustworthy, so the uppercase the toolkit produces is what separates them.
|
||||
func TestPatchedBundleLeavesTrustworthyURLCheckAlone(t *testing.T) {
|
||||
const undici = `if(n.hostname==="localhost"||n.hostname.includes("localhost.")||n.hostname.endsWith(".localhost")){return true}`
|
||||
|
||||
out, patched := patchedBundle([]byte(undici + gateTSC))
|
||||
require.True(t, patched)
|
||||
assert.Contains(t, string(out), undici, "the trustworthy-URL check must survive byte for byte")
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
}
|
||||
|
||||
// The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so
|
||||
// opening it is what lets the stock upload-artifact work against Gitea instead of aborting.
|
||||
func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) {
|
||||
const artifact = isGhesTSC + "\n" + `uploadArtifact(){if(isGhes()){throw new GHESNotSupportedError()}}`
|
||||
|
||||
out, patched := patchedBundle([]byte(artifact))
|
||||
assert.True(t, patched)
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
assert.Contains(t, string(out), "GHESNotSupportedError", "the refusal itself is left in place, it just stops firing")
|
||||
|
||||
// A bundle using the gate for something this runner has not accounted for is not touched.
|
||||
unknown := strings.Replace(artifact, "GHESNotSupportedError", "SomeOtherError", 1)
|
||||
out, patched = patchedBundle([]byte(unknown))
|
||||
assert.False(t, patched)
|
||||
assert.Equal(t, unknown, string(out))
|
||||
}
|
||||
|
||||
// requoted respells gateTSC's string literals with another quote character.
|
||||
func requoted(quote string) string {
|
||||
gate := strings.ReplaceAll(gateTSC, `'.LOCALHOST'`, quote+".LOCALHOST"+quote)
|
||||
gate = strings.ReplaceAll(gate, `['ACTIONS_RESULTS_URL']`, "["+quote+"ACTIONS_RESULTS_URL"+quote+"]")
|
||||
return strings.ReplaceAll(gate, `['ACTIONS_CACHE_URL']`, "["+quote+"ACTIONS_CACHE_URL"+quote+"]")
|
||||
}
|
||||
|
||||
// gateOpened reports whether the hostname test was emptied, in whatever quoting the bundle used.
|
||||
func gateOpened(body string) bool {
|
||||
return strings.Contains(body, "endsWith(") && !strings.Contains(body, ".LOCALHOST")
|
||||
}
|
||||
|
||||
// The patched bundle must still be JavaScript, and must resolve the way the runner needs: v2 for
|
||||
// an ordinary Gitea host, the cache server for the service URL, and the results URL when there is
|
||||
// no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing.
|
||||
func TestPatchedBundleBehavesInNode(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
eval := func(t *testing.T, bundle, prelude, cacheURL string) string {
|
||||
t.Helper()
|
||||
|
||||
script := prelude + bundle + "\nprocess.stdout.write(getCacheServiceVersion()+' '+getCacheServiceURL())"
|
||||
cmd := exec.CommandContext(t.Context(), "node", "-e", script)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||
"ACTIONS_CACHE_URL="+cacheURL,
|
||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||
"GITHUB_SERVER_URL=https://gitea.example",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "%s", out)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, bundle, prelude string }{
|
||||
{"tsc", gateTSC, ""},
|
||||
{"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"},
|
||||
{"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Unpatched, a Gitea host is taken for GHES: v1, whose branch already reads the
|
||||
// cache URL. The patch has to move the version without moving that.
|
||||
assert.Equal(t, "v1 http://cache:8088/", eval(t, tc.bundle, tc.prelude, "http://cache:8088/"))
|
||||
|
||||
patched, ok := patchedBundle([]byte(tc.bundle))
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "v2 http://cache:8088/", eval(t, string(patched), tc.prelude, "http://cache:8088/"))
|
||||
assert.Equal(t, "v2 https://gitea.example", eval(t, string(patched), tc.prelude, ""),
|
||||
"with no cache server the results URL is still the fallback")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A bundler that embeds module sources as strings, such as webpack with devtool: eval, carries
|
||||
// the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument
|
||||
// would end that string early and leave the bundle unparseable.
|
||||
func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
escaped := strings.ReplaceAll(gateTSC, `"`, `\"`)
|
||||
embedded := `eval("` + strings.ReplaceAll(escaped, "\n", `\n`) + `");`
|
||||
out, patched := patchedBundle([]byte(embedded))
|
||||
require.True(t, patched)
|
||||
|
||||
file := filepath.Join(t.TempDir(), "bundle.js")
|
||||
require.NoError(t, os.WriteFile(file, out, 0o600))
|
||||
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
|
||||
require.NoError(t, err, "%s", checked)
|
||||
}
|
||||
|
||||
func TestPatchBundleKeepsTheOriginal(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
original := originalFor(dir, script)
|
||||
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
patched, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gateOpened(string(patched)))
|
||||
|
||||
kept, err := os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(kept), "the untouched bundle is kept outside the action tree")
|
||||
assert.NotContains(t, original, dir+string(filepath.Separator), "originals must not ship into job containers")
|
||||
|
||||
// Patching again must not stack, and must not overwrite the kept original.
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
again, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(patched), string(again))
|
||||
kept, err = os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(kept))
|
||||
}
|
||||
|
||||
// A bundle with nothing to patch is left exactly as it was, with no original kept beside it.
|
||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||
dir, script := bundleFile(t, `console.log("checkout")`)
|
||||
original := originalFor(dir, script)
|
||||
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||
_, err = os.Stat(original)
|
||||
assert.True(t, os.IsNotExist(err), "no original is kept for a bundle that was not patched")
|
||||
}
|
||||
|
||||
// bundleFile writes one entrypoint into a fresh action directory.
|
||||
func bundleFile(t *testing.T, body string) (dir, script string) {
|
||||
t.Helper()
|
||||
|
||||
dir = t.TempDir()
|
||||
script = filepath.Join(dir, "index.js")
|
||||
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
||||
return dir, script
|
||||
}
|
||||
|
||||
func TestActionScriptPaths(t *testing.T) {
|
||||
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "dist/restore/index.js", Post: "dist/save/index.js"}}
|
||||
assert.Equal(t, []string{"/a/dist/restore/index.js", "/a/dist/save/index.js"}, actionScriptPaths("/a", node))
|
||||
|
||||
// Only a node action has a bundle to patch.
|
||||
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
||||
assert.Nil(t, actionScriptPaths("/a", nil))
|
||||
}
|
||||
|
||||
// A step that fails with a patched bundle gets the untouched bundle back, and the action is not
|
||||
// patched again, so later jobs run it exactly as its author shipped it.
|
||||
func TestRevertToolkit(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
scripts := []string{script}
|
||||
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(body)), "precondition: the bundle is patched")
|
||||
|
||||
revertToolkit(t.Context(), dir, scripts)
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body), "the original bundle is back")
|
||||
|
||||
// The skip marker survives, so the action stays unpatched from now on.
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body), "a reverted action stays unpatched")
|
||||
}
|
||||
|
||||
// An action whose ref moves is checked out over the patched bundle. The kept original then
|
||||
// belongs to the version before the move, and must not be restored over the new one.
|
||||
func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
original := originalFor(dir, script)
|
||||
scripts := []string{script}
|
||||
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateWebpack), 0o600)) // the new version lands
|
||||
|
||||
// Reverting must not roll the action back to the version the original came from.
|
||||
revertToolkit(t.Context(), dir, scripts)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateWebpack, string(body))
|
||||
|
||||
// Nothing was reverted, so the action is not marked off either: the new version is patched
|
||||
// in its own right, and keeps its own original.
|
||||
require.NoFileExists(t, filepath.Join(sidecarDir(dir), skipMarker))
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gateOpened(string(body)))
|
||||
kept, err := os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateWebpack, string(kept))
|
||||
}
|
||||
|
||||
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
||||
// that fails gets them back. The action's path inside its repository is part of where they live.
|
||||
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
||||
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
||||
t.Helper()
|
||||
|
||||
sar := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||
RunContext: &RunContext{
|
||||
Env: env,
|
||||
Config: &Config{ActionCacheDir: t.TempDir()},
|
||||
},
|
||||
}
|
||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||
return sar, script
|
||||
}
|
||||
|
||||
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
||||
sar, script := newStep(t, map[string]string{})
|
||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body))
|
||||
})
|
||||
|
||||
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
||||
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(body)))
|
||||
|
||||
failed := errors.New("the step failed")
|
||||
require.ErrorIs(t, sar.revertToolkitOnFailure(func(context.Context) error { return failed })(t.Context()), failed)
|
||||
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user