fix: serve the whole results service from the cache server (#1141)

`ACTIONS_RESULTS_URL` names one origin serving every `github.actions.results.api.v1` service. Gitea serves the artifact half and this runner the cache half, so announcing `ACTIONS_CACHE_SERVICE_V2` while that URL pointed at Gitea was a promise the environment could not keep, and `docker buildx` posted its cache calls at Gitea and got a 404.

The cache server now forwards the artifact half to the instance each job registers with, so it is the whole results service and jobs are pointed at it. The announcement follows, and the bundle patch follows the cache URL instead.

Also fixes three things no JavaScript client reached: camelCase in the v2 responses where the Go clients read proto names, the missing `x-ms-request-id` on blob uploads that panics buildkit, and `cache.external_server` passed through without the trailing slash the v1 client concatenates onto.

Tests run the real actions against the services they look for: `actions/cache` over both API versions, the artifact actions up and back down through the forwarding, and `setup-node`. The regression itself is covered by asserting that whatever a job is handed as `ACTIONS_RESULTS_URL` answers a cache service call.

Fixes https://gitea.com/gitea/runner/issues/1139

Reviewed-on: https://gitea.com/gitea/runner/pulls/1141
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
silverwind
2026-08-03 16:41:53 +00:00
committed by bircni
parent 55a625f733
commit 3618385b28
15 changed files with 539 additions and 141 deletions

View File

@@ -74,6 +74,7 @@ type Config struct {
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
ActionCache ActionCache // Use a custom ActionCache Implementation
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
PatchToolkit bool // edit the @actions toolkit bundled into an action so it works against Gitea, see toolkit_patch.go
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath

View File

@@ -236,10 +236,10 @@ func (sar *stepActionRemote) toolkitBundles() (string, []string) {
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.
// patchActionToolkit edits the bundled toolkit so it works against Gitea: the artifact actions
// stop refusing, and the cache client keeps to the cache server whichever API version it picks.
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
if sar.RunContext.Config.PatchToolkit {
dir, scripts := sar.toolkitBundles()
patchToolkit(ctx, dir, scripts)
}

View File

@@ -5,11 +5,15 @@ package runner
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -59,41 +63,65 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
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 {
// jobEnv is the environment a job gets from this runner, which is what decides where an action's
// toolkit looks for the cache and artifact services.
type jobEnv struct {
workspace, runnerTemp string
cacheURL, resultsURL string
token string
}
// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests
// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose
// names are the ones the action's own action.yml uses.
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
t.Helper()
state := filepath.Join(runnerTemp, "state")
output := filepath.Join(runnerTemp, "output")
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 = workspace
cmd.Dir = env.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_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="+workspace,
"RUNNER_TEMP="+runnerTemp,
"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(filepath.Dir(script)), out)
t.Logf("%s:\n%s", filepath.Base(script), out)
require.NoError(t, err, "%s failed", script)
return string(out)
}
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be,
// keeping the untouched original in the sidecar beside it.
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)
dir := tempDirPath(t)
script := filepath.Join(dir, filepath.Base(entrypoint))
require.NoError(t, os.WriteFile(script, body, 0o600))
patchToolkit(t.Context(), dir, []string{script})
return script
}
// 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 {
@@ -113,43 +141,52 @@ func tempDirPath(t *testing.T) string {
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"]})
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
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)
handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo})
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
// The results service is the cache server's too, which is what the runner advertises.
resultsURL: handler.ExternalURL(),
token: token,
}
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(workspace, "to-cache", "data.txt"), content, 0o600))
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
const key = "patched-gate-key"
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, 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 := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
saved := runActionEntrypoint(t, save, env, inputs)
require.Contains(t, saved, "Cache saved with key: "+key)
restored := tempDirPath(t)
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, 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(restored, "to-cache", "data.txt"))
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
require.NoError(t, err)
assert.Equal(t, content, got)
// Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the
// cache server on its own address. That is what a runner without a results service of its own
// leaves its jobs with, so it has to round trip too.
env.workspace = tempDirPath(t)
v1 := runActionEntrypoint(t, filepath.Join(sidecarDir(filepath.Dir(restore)), "index.js"), env, inputs)
require.Contains(t, v1, "Cache service version: v1")
require.Contains(t, v1, "Cache restored from key: "+key)
}
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
@@ -158,19 +195,32 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
// 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"},
for _, tc := range []struct {
repo, ref, path string
wantPatched bool
}{
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
// actions that drive a Go or a Rust cache client of their own.
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
{"actions/setup-node", "v7.0.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},
{"ruby/setup-ruby", "v1.321.0", "dist/index.js", true},
{"pnpm/action-setup", "v6.0.9", "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},
{"docker/build-push-action", "v7.3.0", "dist/index.cjs", true},
// 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"},
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js", true},
{"actions/download-artifact", "v8.0.1", "dist/index.js", true},
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
{"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()
@@ -179,7 +229,11 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
require.NoError(t, err)
out, patched := patchedBundle(data)
assert.True(t, patched, "the version gate was not patched")
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) {
@@ -194,3 +248,121 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
})
}
}
// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk
// to the results service, which is this runner's cache server forwarding the artifact half on to
// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once:
// the patch, and the forwarding the job's registration set up.
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: // the zip on its way up, in the blocks the Azure protocol puts it in
body, _ := io.ReadAll(r.Body)
switch r.URL.Query().Get("comp") {
case "block":
zipped = append(zipped, body...)
case "blocklist": // the ordering document, not content
default:
zipped = body
}
w.WriteHeader(http.StatusCreated)
}
}))
defer gitea.Close()
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
// The artifact client decodes the runtime token for the run ids it puts in its requests, where
// the cache client only presents it, so this one has to be shaped like Gitea's.
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
upload := patchedAction(t, "actions/upload-artifact", "v7.0.1", "dist/upload/index.js")
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
resultsURL: handler.ExternalURL(),
token: token,
}
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")
// And back down again: listing and downloading go the same way, and the signed URL the
// artifact service hands out is fetched straight from it.
download := patchedAction(t, "actions/download-artifact", "v8.0.1", "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)
}
// The setup actions carry the same toolkit and reach the same service, from a key of their own
// making. setup-node is the cheapest of them to run: given a lockfile and no version to install,
// it does the cache lookup and nothing else.
func TestSetupActionFindsTheCacheService(t *testing.T) {
requireHostTools(t, "node", "npm")
setup := patchedAction(t, "actions/setup-node", "v7.0.0", "dist/setup/index.js")
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 = "setup-runtime-token"
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})()
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
resultsURL: handler.ExternalURL(),
token: token,
}
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")
}

View File

@@ -283,7 +283,7 @@ func TestPatchBundleAfterTheActionMoved(t *testing.T) {
// 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) {
newStep := func(t *testing.T, patch bool) (*stepActionRemote, string) {
t.Helper()
sar := &stepActionRemote{
@@ -291,8 +291,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
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()},
Config: &Config{ActionCacheDir: t.TempDir(), PatchToolkit: patch},
},
}
script := filepath.Join(sar.actionDir(), "sub", "index.js")
@@ -301,8 +300,8 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
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{})
t.Run("left alone when the runner does not patch", func(t *testing.T) {
sar, script := newStep(t, false)
require.NoError(t, sar.patchActionToolkit(t.Context()))
body, err := os.ReadFile(script)
@@ -311,7 +310,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
})
t.Run("patched, and put back when the step fails", func(t *testing.T) {
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
sar, script := newStep(t, true)
require.NoError(t, sar.patchActionToolkit(t.Context()))
body, err := os.ReadFile(script)