mirror of
https://gitea.com/gitea/act_runner
synced 2026-11-08 10:57:59 +01:00
The toolkit patch rewrote bundles in place in the shared action cache. Since https://gitea.com/gitea/runner/pulls/1316 trees are never refreshed, so a bundle left truncated failed every later job using it. Patched bundles now only overwrite the job container's copy, so the runner no longer writes to an extracted tree. Extraction records each file's size beside the tree, and a tree that no longer matches is fetched again, offline mode included, as a missing one always was. This also catches damage from outside the runner, such as storage losing a write, for a stat per file, a fraction of the copy each stage already makes. Trees from older runners have no record and download once more, except in offline mode, which keeps using them as before. Generated with Claude Code. Reviewed-on: https://gitea.com/gitea/runner/pulls/1352 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
341 lines
12 KiB
Go
341 lines
12 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/artifactcache"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const actionsCacheRef = "v6.1.0"
|
|
|
|
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()
|
|
require.NotEqual(t, http.StatusNotFound, resp.StatusCode, "%s is not in this release", url)
|
|
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
|
|
}
|
|
|
|
type jobEnv struct {
|
|
workspace, runnerTemp string
|
|
cacheURL, resultsURL string
|
|
token string
|
|
}
|
|
|
|
func newActionJobEnv(t *testing.T, token string, credential artifactcache.JobCredential) jobEnv {
|
|
t.Helper()
|
|
|
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = handler.Close() })
|
|
t.Cleanup(handler.RegisterJob(token, credential))
|
|
return jobEnv{
|
|
workspace: tempDirPath(t),
|
|
runnerTemp: tempDirPath(t),
|
|
cacheURL: handler.ExternalURL(),
|
|
resultsURL: handler.ExternalURL(),
|
|
token: token,
|
|
}
|
|
}
|
|
|
|
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
|
|
t.Helper()
|
|
state := filepath.Join(env.runnerTemp, "state")
|
|
output := filepath.Join(env.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 = env.workspace
|
|
cmd.Env = append(os.Environ(),
|
|
"ACTIONS_RUNTIME_TOKEN="+env.token,
|
|
"ACTIONS_CACHE_URL="+env.cacheURL+"/",
|
|
"ACTIONS_RESULTS_URL="+env.resultsURL,
|
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
|
"GITHUB_SERVER_URL=https://gitea.example.com",
|
|
"GITHUB_REPOSITORY=testuser/testrepo",
|
|
"GITHUB_RUN_ID=1",
|
|
"GITHUB_REF=refs/heads/main",
|
|
"GITHUB_EVENT_NAME=push",
|
|
"GITHUB_WORKSPACE="+env.workspace,
|
|
"RUNNER_TEMP="+env.runnerTemp,
|
|
"GITHUB_STATE="+state,
|
|
"GITHUB_OUTPUT="+output,
|
|
)
|
|
for name, value := range inputs {
|
|
cmd.Env = append(cmd.Env, "INPUT_"+strings.ToUpper(name)+"="+value)
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
t.Logf("%s:\n%s", filepath.Base(script), out)
|
|
require.NoError(t, err, "%s failed", script)
|
|
return string(out)
|
|
}
|
|
|
|
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
|
t.Helper()
|
|
|
|
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
|
require.NoError(t, err)
|
|
if patched, ok := patchedBundle(body); ok {
|
|
body = patched
|
|
}
|
|
script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint))
|
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
|
return script
|
|
}
|
|
|
|
func tempDirPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
|
require.NoError(t, err)
|
|
return dir
|
|
}
|
|
|
|
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|
requireHostTools(t, "node")
|
|
|
|
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
|
|
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
|
|
|
|
env := newActionJobEnv(t, "e2e-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
|
|
require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755))
|
|
content := []byte("cached through the patched gate")
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
|
|
|
|
const key = "patched-gate-key"
|
|
inputs := map[string]string{"path": "to-cache", "key": key}
|
|
|
|
missed := runActionEntrypoint(t, restore, env, inputs)
|
|
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 := runActionEntrypoint(t, save, env, inputs)
|
|
require.Contains(t, saved, "Cache saved with key: "+key)
|
|
|
|
env.workspace = tempDirPath(t)
|
|
hit := runActionEntrypoint(t, restore, env, inputs)
|
|
require.Contains(t, hit, "Cache restored from key: "+key)
|
|
|
|
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, got)
|
|
env.workspace = tempDirPath(t)
|
|
v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs)
|
|
require.Contains(t, v1, "Cache service version: v1")
|
|
require.Contains(t, v1, "Cache restored from key: "+key)
|
|
}
|
|
|
|
func TestPatchedBundleAcrossActions(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
repo, ref, path string
|
|
wantPatched bool
|
|
}{
|
|
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
|
{"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true},
|
|
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
|
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
|
{"actions/setup-java", "v6.1.0", "dist/setup/295.index.js", true},
|
|
{"ruby/setup-ruby", "v1.331.0", "dist/index.js", true},
|
|
{"pnpm/action-setup", "v6.1.0", "dist/index.js", true},
|
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
|
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
|
{"Swatinem/rust-cache", "v2.9.2", "dist/cache-CTfQoCTL.js", true},
|
|
{"docker/build-push-action", "v7.4.0", "dist/index.cjs", true},
|
|
{"gradle/actions", "v6.4.0", "dist/setup-gradle/main/index.js", true},
|
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
|
{"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true},
|
|
{"actions/download-artifact", "v8.0.2", "dist/index.js", true},
|
|
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
|
{"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false},
|
|
} {
|
|
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)
|
|
require.Equal(t, tc.wantPatched, patched)
|
|
if !tc.wantPatched {
|
|
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
|
return
|
|
}
|
|
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
|
|
|
if !strings.Contains(string(data), CacheServiceV2Env) {
|
|
return
|
|
}
|
|
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")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
|
requireHostTools(t, "node")
|
|
|
|
var called []string
|
|
var zipped []byte
|
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
method := path.Base(r.URL.Path)
|
|
called = append(called, method)
|
|
w.Header().Set("x-ms-request-id", "stub")
|
|
switch method {
|
|
case "CreateArtifact":
|
|
_, _ = io.WriteString(w, `{"ok":true,"signed_upload_url":"http://`+r.Host+
|
|
`/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"}`)
|
|
case "FinalizeArtifact":
|
|
_, _ = io.WriteString(w, `{"ok":true,"artifact_id":"1"}`)
|
|
case "ListArtifacts":
|
|
_, _ = io.WriteString(w, `{"artifacts":[{"workflow_run_backend_id":"11",`+
|
|
`"workflow_job_run_backend_id":"22","database_id":"1","name":"an-artifact","size":"`+
|
|
strconv.Itoa(len(zipped))+`"}]}`)
|
|
case "GetSignedArtifactURL":
|
|
_, _ = io.WriteString(w, `{"signed_url":"http://`+r.Host+`/download"}`)
|
|
case "download":
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
_, _ = w.Write(zipped)
|
|
default:
|
|
body, _ := io.ReadAll(r.Body)
|
|
switch r.URL.Query().Get("comp") {
|
|
case "block":
|
|
zipped = append(zipped, body...)
|
|
case "blocklist":
|
|
default:
|
|
zipped = body
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
}))
|
|
defer gitea.Close()
|
|
env := newActionJobEnv(t, "e30."+base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`))+".sig",
|
|
artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})
|
|
|
|
upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js")
|
|
|
|
uploaded := []byte("through the results service")
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600))
|
|
|
|
out := runActionEntrypoint(t, upload, env, map[string]string{
|
|
"name": "an-artifact", "path": "artifact.txt", "if-no-files-found": "error",
|
|
"retention-days": "0", "compression-level": "6", "overwrite": "false",
|
|
"include-hidden-files": "false", "archive": "true",
|
|
})
|
|
|
|
require.Contains(t, out, "has been successfully uploaded")
|
|
download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js")
|
|
env.workspace = tempDirPath(t)
|
|
out = runActionEntrypoint(t, download, env, map[string]string{
|
|
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
|
"skip-decompress": "false", "include-hidden-files": "false", "github-token": "",
|
|
})
|
|
|
|
require.Contains(t, out, "Artifact download completed")
|
|
assert.Subset(t, called,
|
|
[]string{"CreateArtifact", "UploadArtifact", "FinalizeArtifact", "ListArtifacts", "GetSignedArtifactURL"},
|
|
"the artifact service was not reached through the cache server")
|
|
got, err := os.ReadFile(filepath.Join(env.workspace, "downloaded", "artifact.txt"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, uploaded, got)
|
|
}
|
|
|
|
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
|
requireHostTools(t, "node", "npm")
|
|
|
|
setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js")
|
|
|
|
env := newActionJobEnv(t, "setup-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"),
|
|
[]byte(`{"lockfileVersion":3}`), 0o600))
|
|
|
|
out := runActionEntrypoint(t, setup, env, map[string]string{"cache": "npm"})
|
|
|
|
require.Contains(t, out, "Cache service version: v2")
|
|
require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server")
|
|
}
|
|
|
|
func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
|
|
requireHostTools(t, "node", "rustc")
|
|
|
|
dir := tempDirPath(t)
|
|
for _, name := range []string{
|
|
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
|
|
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
|
|
} {
|
|
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
|
|
require.NoError(t, err)
|
|
action, err := model.ReadAction(strings.NewReader(string(body)))
|
|
require.NoError(t, err)
|
|
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
|
|
for name, input := range action.Inputs {
|
|
if _, ok := inputs[name]; !ok {
|
|
inputs[name] = input.Default
|
|
}
|
|
}
|
|
t.Setenv("CARGO_HOME", tempDirPath(t))
|
|
|
|
for _, entry := range patchedScripts(t.Context(), dir, actionScriptPaths(dir, dir, action)) {
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, entry.Name), []byte(entry.Body), 0o600))
|
|
}
|
|
out := runActionEntrypoint(t, filepath.Join(dir, action.Runs.Main),
|
|
newActionJobEnv(t, "split-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}), inputs)
|
|
|
|
require.Contains(t, out, "Cache service version: v2")
|
|
require.Contains(t, out, "No cache found.", "the lookup did not reach the cache server")
|
|
}
|