mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-05-01 00:10:31 +08:00
Authenticate cache requests via ACTIONS_RUNTIME_TOKEN and scope by repo (#849)
Closes #848. Addresses [GHSA-82g9-637c-2fx2](https://github.com/go-gitea/gitea/security/advisories/GHSA-82g9-637c-2fx2) and the follow-up points raised by @ChristopherHX and @haroutp in that thread. The change is breaking only for `cache.external_server` which uses auth via a pre-shared secret. ## How auth works now 1. **Runner starts** → opens the embedded cache server on `:port`. Loads / creates a 32-byte HMAC signing key in `<cache-dir>/.secret`. 2. **Runner receives a task** → calls `handler.RegisterJob(ACTIONS_RUNTIME_TOKEN, repository)` before the job runs, defers a revoker that removes the credential on completion. Registrations are reference-counted so a stray re-register cannot revoke a live job. 3. **Job container runs `actions/cache`** → the toolkit sends `Authorization: Bearer $ACTIONS_RUNTIME_TOKEN` on every management call (`reserve`, `upload`, `commit`, `find`, `clean`). The cache server's middleware looks the token up in the registered-jobs map: miss → 401; hit → the job's repository is injected into the request context. 4. **Repository scoping** — every cache entry is stamped with `Repo` on reserve; `find`, `upload`, `commit` all verify the caller's repo matches. A job in repo A cannot see or poison a cache entry owned by repo B, even when both reach the server over the same docker bridge. GC dedup also groups by `(Repo, Key, Version)` so one repo can't age out another. 5. **Archive downloads** — `@actions/cache` does not attach Authorization when downloading `archiveLocation`, so the `find` response is a short-lived HMAC-signed URL: `…/artifacts/:id?exp=<unix>&sig=<hmac>`, 10-minute TTL, signature binds `cacheID:exp`. Tampered, expired, or foreign-secret URLs get 401. 6. **Defence-in-depth** — `ACTIONS_RUNTIME_TOKEN` is added to `task.Secrets` so the runner's log masker scrubs it from step output. ## `cache.external_server` (standalone `act_runner cache-server`) Operators set `cache.external_secret` to the same value on the runner config and the `act_runner cache-server` config. The `cache-server` then runs with bearer auth on the cache API and exposes a control-plane at `POST /_internal/{register,revoke}` (gated by the shared secret). The runner pre-registers each task's `ACTIONS_RUNTIME_TOKEN` with the remote server before the job runs and revokes it on completion. Same per-job auth + repo scoping as the embedded handler, just over the network. `cache-server` refuses to start without `cache.external_secret`; runner config load also fails when `cache.external_server` is set without `cache.external_secret`. ## User-facing changes - **One-time cache miss after upgrade.** Pre-existing entries in `bolt.db` have no `Repo` stamp and won't match any job — they'll be evicted by the normal GC. First job per cache key rebuilds its cache. - **`cache.external_server` deployments must add `cache.external_secret`.** Breaking change for anyone running a standalone `act_runner cache-server`: set the same `cache.external_secret` in both the runner config and the cache-server config. Without it neither side starts. - **No config changes required for the default setup.** Runners using the embedded cache server (the common case) keep working without any yaml edits; the auth mechanism is invisible to workflows. --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Nicolas <bircni@icloud.com> Co-authored-by: Christopher Homberger <christopher.homberger@web.de> Reviewed-on: https://gitea.com/gitea/act_runner/pulls/849 Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -47,10 +48,15 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
port = cacheArgs.Port
|
||||
}
|
||||
|
||||
secret := cfg.Cache.ExternalSecret
|
||||
if secret == "" {
|
||||
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
}
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
host,
|
||||
port,
|
||||
secret,
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -368,7 +370,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
// init a cache server
|
||||
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
|
||||
handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -393,6 +395,25 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
execArgs.artifactServerPath = tempDir
|
||||
}
|
||||
|
||||
// Register ACTIONS_RUNTIME_TOKEN against local cache server
|
||||
env := execArgs.LoadEnvs()
|
||||
const actionsRuntimeTokenEnvName = "ACTIONS_RUNTIME_TOKEN"
|
||||
actionsRuntimeToken := env[actionsRuntimeTokenEnvName]
|
||||
if actionsRuntimeToken == "" {
|
||||
actionsRuntimeToken = os.Getenv(actionsRuntimeTokenEnvName)
|
||||
}
|
||||
if actionsRuntimeToken == "" {
|
||||
tmpBranch := make([]byte, 12)
|
||||
if _, err := rand.Read(tmpBranch); err != nil {
|
||||
actionsRuntimeToken = "token"
|
||||
} else {
|
||||
actionsRuntimeToken = hex.EncodeToString(tmpBranch)
|
||||
}
|
||||
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
|
||||
}
|
||||
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
|
||||
|
||||
// run the plan
|
||||
config := &runner.Config{
|
||||
Workdir: execArgs.Workdir(),
|
||||
@@ -402,7 +423,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
ForceRebuild: execArgs.forceRebuild,
|
||||
LogOutput: true,
|
||||
JSONLogger: execArgs.jsonLogger,
|
||||
Env: execArgs.LoadEnvs(),
|
||||
Env: env,
|
||||
Vars: execArgs.LoadVars(),
|
||||
Secrets: execArgs.LoadSecrets(),
|
||||
InsecureSecrets: execArgs.insecureSecrets,
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -38,9 +40,10 @@ type Runner struct {
|
||||
|
||||
cfg *config.Config
|
||||
|
||||
client client.Client
|
||||
labels labels.Labels
|
||||
envs map[string]string
|
||||
client client.Client
|
||||
labels labels.Labels
|
||||
envs map[string]string
|
||||
cacheHandler *artifactcache.Handler
|
||||
|
||||
runningTasks sync.Map
|
||||
runningCount atomic.Int64
|
||||
@@ -55,21 +58,24 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
}
|
||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||
maps.Copy(envs, cfg.Runner.Envs)
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
||||
} else {
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
handler, err := artifactcache.StartHandler(
|
||||
cfg.Cache.Dir,
|
||||
cfg.Cache.Host,
|
||||
cfg.Cache.Port,
|
||||
"",
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
// go on
|
||||
} else {
|
||||
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/"
|
||||
cacheHandler = handler
|
||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,11 +90,12 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version()
|
||||
|
||||
return &Runner{
|
||||
name: reg.Name,
|
||||
cfg: cfg,
|
||||
client: cli,
|
||||
labels: ls,
|
||||
envs: envs,
|
||||
name: reg.Name,
|
||||
cfg: cfg,
|
||||
client: cli,
|
||||
labels: ls,
|
||||
envs: envs,
|
||||
cacheHandler: cacheHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +206,21 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
giteaRuntimeToken = preset.Token
|
||||
}
|
||||
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
|
||||
// Mask the runtime token so it cannot be echoed in user step output; it is
|
||||
// now also the cache server's bearer credential and leaking it would let
|
||||
// any reader of the log impersonate this job against the cache.
|
||||
if giteaRuntimeToken != "" {
|
||||
task.Secrets["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
|
||||
}
|
||||
|
||||
// Register this job's runtime token with the local cache server so that
|
||||
// cache requests from the job container can authenticate. The credential
|
||||
// is removed when the task finishes, so a leaked token stops working as
|
||||
// soon as the job ends rather than remaining valid for the runner's
|
||||
// lifetime. Only applies to the embedded cache server; when the operator
|
||||
// points the runner at an external cache via cfg.Cache.ExternalServer, it
|
||||
// is that server's responsibility to authenticate requests.
|
||||
defer r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)()
|
||||
|
||||
eventJSON, err := json.Marshal(preset.Event)
|
||||
if err != nil {
|
||||
@@ -278,6 +300,82 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return execErr
|
||||
}
|
||||
|
||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||
// with the given runtime token for the duration of this task. Returns a
|
||||
// function the caller must invoke (typically via defer) to revoke the
|
||||
// credential when the task finishes.
|
||||
//
|
||||
// Three modes:
|
||||
// - Embedded handler: register in-process via RegisterJob.
|
||||
// - external_server + external_secret: POST to the remote server's
|
||||
// /_internal/register, defer a POST to /_internal/revoke. This is what
|
||||
// enables full per-job auth and repo scoping over the network.
|
||||
// - external_server alone (no secret): no-op revoker. The remote server is
|
||||
// in legacy openMode and ignores the runtime token; trust is at the
|
||||
// network layer.
|
||||
//
|
||||
// Safe with an empty token (older Gitea did not issue one).
|
||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
||||
if token == "" {
|
||||
return func() {}
|
||||
}
|
||||
if r.cacheHandler != nil {
|
||||
return r.cacheHandler.RegisterJob(token, repo)
|
||||
}
|
||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
return r.registerExternalCacheJob(token, repo, reporter)
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
||||
// Failures are logged but not fatal: if registration fails, the cache will
|
||||
// 401 the job's requests — better than failing the whole task for a cache
|
||||
// outage. The warning is mirrored to the job log so users can see why their
|
||||
// cache calls 401, instead of having to read the runner daemon's stderr.
|
||||
func (r *Runner) registerExternalCacheJob(token, repo string, reporter *report.Reporter) func() {
|
||||
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
||||
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret,
|
||||
map[string]string{"token": token, "repo": repo}); err != nil {
|
||||
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||
if reporter != nil {
|
||||
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
||||
map[string]string{"token": token}); err != nil {
|
||||
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
||||
if reporter != nil {
|
||||
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postInternalCache(url, secret string, body map[string]string) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) RunningCount() int64 {
|
||||
return r.runningCount.Load()
|
||||
}
|
||||
|
||||
239
internal/app/run/runner_cache_test.go
Normal file
239
internal/app/run/runner_cache_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/artifactcache"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func emptyCfg() *config.Config { return &config.Config{} }
|
||||
|
||||
func TestRunner_registerCacheForTask(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
token := "run-token-123"
|
||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
||||
|
||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||
probe := func() int {
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
assert.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token should be accepted while task is registered")
|
||||
|
||||
unregister()
|
||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be rejected after the revoker runs")
|
||||
}
|
||||
|
||||
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||
t.Run("nil cacheHandler", func(t *testing.T) {
|
||||
r := &Runner{cfg: emptyCfg()}
|
||||
unregister := r.registerCacheForTask("tok", "owner/repo", nil)
|
||||
require.NotNil(t, unregister)
|
||||
unregister()
|
||||
})
|
||||
|
||||
t.Run("empty token", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
unregister := r.registerCacheForTask("", "owner/repo", nil)
|
||||
require.NotNil(t, unregister)
|
||||
unregister()
|
||||
})
|
||||
}
|
||||
|
||||
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
|
||||
// /find, no auth on the signed archiveLocation download.
|
||||
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
token := "full-flow-token"
|
||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
||||
defer unregister()
|
||||
|
||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||
do := func(method, url, contentType, contentRange, body string) *http.Response {
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if contentRange != "" {
|
||||
req.Header.Set("Content-Range", contentRange)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
key := "toolkit-flow"
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
body := `hello-cache-body`
|
||||
|
||||
// reserve
|
||||
resp := do(http.MethodPost, base+"/caches", "application/json", "",
|
||||
fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body)))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var reserved struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &reserved))
|
||||
require.NotZero(t, reserved.CacheID)
|
||||
|
||||
// upload
|
||||
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
|
||||
"application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
|
||||
// commit
|
||||
resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
|
||||
// find — @actions/cache always sends comma-separated keys here
|
||||
resp = do(http.MethodGet,
|
||||
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var hit struct {
|
||||
ArchiveLocation string `json:"archiveLocation"`
|
||||
CacheKey string `json:"cacheKey"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &hit))
|
||||
require.Equal(t, key, hit.CacheKey)
|
||||
require.NotEmpty(t, hit.ArchiveLocation)
|
||||
|
||||
// download — toolkit does NOT attach Authorization here; the signature
|
||||
// in the URL must be enough.
|
||||
dl, err := http.Get(hit.ArchiveLocation)
|
||||
require.NoError(t, err)
|
||||
defer dl.Body.Close()
|
||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||
got := make([]byte, 64)
|
||||
n, _ := dl.Body.Read(got)
|
||||
assert.Equal(t, body, string(got[:n]))
|
||||
}
|
||||
|
||||
func decodeJSON(resp *http.Response, v any) error {
|
||||
defer resp.Body.Close()
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// End-to-end against a remote cache-server: token unknown → 401, register →
|
||||
// reserve/upload/commit/find/download all OK, revoke → 401 again.
|
||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "remote-cache")
|
||||
const secret = "shared-secret-for-tests"
|
||||
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
|
||||
require.NoError(t, err)
|
||||
defer remote.Close()
|
||||
|
||||
r := &Runner{cfg: &config.Config{Cache: config.Cache{
|
||||
ExternalServer: remote.ExternalURL(),
|
||||
ExternalSecret: secret,
|
||||
}}}
|
||||
|
||||
token := "external-task-token"
|
||||
repo := "owner/repoX"
|
||||
base := remote.ExternalURL() + "/_apis/artifactcache"
|
||||
probe := func() int {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be unknown to the remote server before registration")
|
||||
|
||||
unregister := r.registerCacheForTask(token, repo, nil)
|
||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token must be accepted after registerCacheForTask")
|
||||
|
||||
// Full reserve→upload→commit→find→download cycle, identical to what
|
||||
// @actions/cache does, against the remote (external) server.
|
||||
body := []byte("payload-from-task")
|
||||
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var reserved struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &reserved))
|
||||
require.NotZero(t, reserved.CacheID)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var hit struct {
|
||||
ArchiveLocation string `json:"archiveLocation"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &hit))
|
||||
require.NotEmpty(t, hit.ArchiveLocation)
|
||||
|
||||
dl, err := http.Get(hit.ArchiveLocation)
|
||||
require.NoError(t, err)
|
||||
defer dl.Body.Close()
|
||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||
|
||||
unregister()
|
||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be rejected after the revoker runs")
|
||||
}
|
||||
@@ -81,7 +81,12 @@ cache:
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
|
||||
external_server: ""
|
||||
# Shared secret between this runner and the external `act_runner cache-server`. Required when external_server
|
||||
# (or `act_runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
|
||||
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
|
||||
external_secret: ""
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
@@ -47,6 +48,7 @@ type Cache struct {
|
||||
Host string `yaml:"host"` // Host specifies the caching host.
|
||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external act_runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
|
||||
}
|
||||
|
||||
// Container represents the configuration for the container.
|
||||
@@ -135,6 +137,9 @@ func LoadDefault(file string) (*Config, error) {
|
||||
home, _ := os.UserHomeDir()
|
||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the act_runner cache-server")
|
||||
}
|
||||
}
|
||||
if cfg.Container.WorkdirParent == "" {
|
||||
cfg.Container.WorkdirParent = "workspace"
|
||||
|
||||
41
internal/pkg/config/config_test.go
Normal file
41
internal/pkg/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDefault_RejectsExternalServerWithoutSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "external_secret")
|
||||
}
|
||||
|
||||
func TestLoadDefault_AcceptsExternalServerWithSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret: "shh"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user