mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-02 21:03:09 +00:00
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>
197 lines
8.1 KiB
Go
197 lines
8.1 KiB
Go
// 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")
|
|
})
|
|
}
|
|
}
|