feat!: serve the actions cache from the runner (#1229)

Make cache requests go through the runner and remove outbound traffic from the cache server. The runner forwards them to `cache.external_server` when one is set, and attaches itself to each job network so jobs can reach it.

Benefits:

1. Jobs need no http proxy rules for the cache server, which `@actions/http-client` mishandles for CIDR ranges in `no_proxy` (https://github.com/actions/toolkit/pull/2495).
2. Only runners reach a shared cache server, which needs no route to Gitea.
3. A runner container sharing the host Docker socket now caches, where jobs previously could not reach it.

Breaks:

1. Jobs that reach `external_server` but not their runner, such as a remote `DOCKER_HOST`. The built-in cache has always needed that reachability, so both modes now agree. Set `cache.host` and `cache.port` to an address those jobs reach.
2. Jobs on `container.network: bridge`, where Docker rejects the alias, so the runner cannot attach and their cache fails as it does today. Leave `container.network` empty, or name a user-defined network.

*Written by Claude.*

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1229
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwindandbircni authored and bircni committed 2026-09-24 18:56:07 +00:00
1 parent 95daa50d9b
commit ace8653105
17 files changed
+428 -252

No files matched your search

+71 -79
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"maps"
"net"
"net/http"
"net/netip"
"net/url"
@@ -71,7 +72,7 @@ type Runner struct {
cacheHandler *artifactcache.Handler
capabilities string
isolatedCacheNetwork func() string
isolatedCacheContainer func() string
runningTasks sync.Map
runningCount atomic.Int64
@@ -99,24 +100,22 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
if cacheEnabled(cfg) {
if cfg.Cache.ExternalServer != "" {
warnIgnoredCachePolicy(cfg)
// The v1 client appends its path to this without a separator, so the slash is required.
envs["ACTIONS_CACHE_URL"] = strings.TrimRight(cfg.Cache.ExternalServer, "/") + "/"
}
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler(artifactcache.Options{
Dir: cfg.Cache.Dir,
OutboundIP: cfg.Cache.Host,
Port: cfg.Cache.Port,
Upstream: cfg.Cache.ExternalServer,
Policy: CachePolicy(cfg),
Logger: log.StandardLogger().WithField("module", "cache_request"),
})
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} else {
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler(artifactcache.Options{
Dir: cfg.Cache.Dir,
OutboundIP: cfg.Cache.Host,
Port: cfg.Cache.Port,
Policy: CachePolicy(cfg),
Logger: log.StandardLogger().WithField("module", "cache_request"),
})
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} else {
cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
}
cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
}
}
@@ -140,7 +139,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
now: time.Now,
runHealthCheck: executeHealthCheck,
}
runner.isolatedCacheNetwork = sync.OnceValue(runner.detectIsolatedCacheNetwork)
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
return runner
}
@@ -183,7 +182,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
return
}
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
if err := removeOrphanNetworks(ctx, r.uuid, cutoff); err != nil {
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(), cutoff); err != nil {
log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
}
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
@@ -418,11 +417,24 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
taskContext := task.Context.Fields
envs := r.cloneEnvs()
// act asks for the platform once per step, so resolve the fallback at most once per task.
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
platformPicker := func(runsOn []string) string {
if platform := r.labels.PickPlatform(runsOn); platform != "" {
return platform
}
return fallbackPlatform()
}
cacheURL, cacheContainer := r.cacheForJob(job, platformPicker)
if cacheURL != "" {
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
}
// Added per task because this job's service containers must be reached directly, and
// act reaches them by their workflow key.
var services map[string]yaml.Node
_ = job.RawServices.Decode(&services) // a whole-value expression names no service before evaluation
proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(services)))
proxyEnv := JobProxyEnv(envs, cacheURL, slices.Sorted(maps.Keys(services)))
maps.Copy(envs, proxyEnv)
if r.capabilities != "" {
@@ -467,10 +479,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// 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.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
// lifetime.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, cacheURL, reporter)
defer revokeCache()
eventJSON, err := json.Marshal(preset.Event)
@@ -503,20 +513,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs
// for the same repository would share this directory and can race with per-job cleanup.
// act asks for the platform once per step, so resolve the fallback at most once per task.
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
platformPicker := func(runsOn []string) string {
if platform := r.labels.PickPlatform(runsOn); platform != "" {
return platform
}
return fallbackPlatform()
}
if resultsURL != "" && r.cacheIsolatedFrom(job, platformPicker) {
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf("jobs cannot reach the cache server at %s on docker network %q, so caching fails and artifacts go to Gitea directly, set cache.host and cache.port to an address jobs reach, or container.network to %[2]q",
r.cacheHandler.ExternalURL(), r.isolatedCacheNetwork())))
resultsURL = ""
}
r.setResultsService(envs, resultsURL)
runnerConfig := &runner.Config{
@@ -546,6 +542,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
ContainerMaxLifetime: maxLifetime,
CleanWorkdir: true,
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
CacheContainer: cacheContainer,
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
@@ -643,32 +640,24 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
// function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes.
//
// Two modes:
// - Embedded handler: register in-process via RegisterJob.
// - external_server: 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.
//
// Safe with an empty token (older Gitea did not issue one).
// It also returns the URL to advertise as ACTIONS_RESULTS_URL, which is the cache server itself
// when it agreed to forward this instance's artifact service, and "" when it did not.
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) (func(), string) {
if token == "" {
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
func (r *Runner) registerCacheForTask(token, repo, publicURL string, reporter *report.Reporter) (func(), string) {
if token == "" || r.cacheHandler == nil {
return func() {}, ""
}
cred := artifactcache.JobCredential{
Repo: repo,
PublicURL: publicURL,
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
InsecureTLS: r.cfg.Runner.Insecure,
}
if r.cacheHandler != nil {
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
revoke := r.cacheHandler.RegisterJob(token, cred)
if r.cfg.Cache.ExternalServer != "" {
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, repo, publicURL, reporter)
revoke = func() { revokeLocal(); revokeExternal() }
}
if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, cred, reporter)
}
// No cache server to register against: caching is disabled, or the built-in server failed to start.
return func() {}, ""
return revoke, r.cacheHandler.ResultsURL(cred)
}
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
@@ -676,23 +665,19 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
// 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 string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporter *report.Reporter) func() {
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
resultsURL := ""
if body, err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
"token": token, "repo": cred.Repo, "results": cred.Results, "insecure_tls": cred.InsecureTLS,
"public_url": base,
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
"token": token, "repo": repo, "public_url": publicURL,
}); err != nil {
log.Warnf("cache external_server register failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
"cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)))
}
} else if forwarded, _ := body["results_url"].(string); forwarded != "" {
resultsURL = base // the answer only says it forwards, its own address need not be the job's
}
return func() {
if _, err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
map[string]any{"token": token}); err != nil {
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
if reporter != nil {
@@ -700,33 +685,30 @@ func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCr
"cache external_server revoke failed (%s): %v", base, err)))
}
}
}, resultsURL
}
}
func postInternalCache(url, secret string, body map[string]any) (map[string]any, error) {
func postInternalCache(url, secret string, body map[string]any) error {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
return err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, err
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 nil, err
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
return fmt.Errorf("status %d", resp.StatusCode)
}
answer := map[string]any{}
// A server too old to answer with a body is not an error, it simply tells us nothing.
_ = json.UnmarshalRead(resp.Body, &answer)
return answer, nil
return nil
}
func (r *Runner) RunningCount() int64 {
@@ -848,12 +830,22 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
}
func (r *Runner) cacheIsolatedFrom(job *model.Job, pickPlatform func([]string) string) bool {
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
func (r *Runner) cacheForJob(job *model.Job, pickPlatform func([]string) string) (string, string) {
jobContainer := job.Container()
return (jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform) && r.isolatedCacheNetwork() != ""
cacheContainer := ""
if jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform {
cacheContainer = r.isolatedCacheContainer()
}
if cacheContainer == "" {
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
}
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
return cacheURL.String(), cacheContainer
}
func (r *Runner) detectIsolatedCacheNetwork() string {
func (r *Runner) detectIsolatedCacheContainer() string {
if r.cacheHandler == nil {
return ""
}
@@ -863,9 +855,9 @@ func (r *Runner) detectIsolatedCacheNetwork() string {
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
network, err := container.IsolatedNetwork(ctx, addr, r.cfg.Container.Network)
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
if err != nil {
log.Warnf("cannot check whether jobs reach the cache server: %v", err)
}
return network
return cacheContainer
}
+53 -80
View File
@@ -15,6 +15,7 @@ import (
"testing"
"gitea.com/gitea/runner/act/artifactcache"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
@@ -23,54 +24,13 @@ import (
func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
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(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
unregister, _ := r.registerCacheForTask("", "owner/repo", nil)
require.NotNil(t, unregister)
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
require.NotNil(t, unregister, token)
unregister()
})
}
}
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
@@ -81,10 +41,11 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "full-flow-token"
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
defer unregister()
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
assert.Equal(t, publicURL, resultsURL)
base := handler.ExternalURL() + "/_apis/artifactcache"
do := func(method, url, contentType, contentRange, body string) *http.Response {
@@ -137,17 +98,22 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
}
require.NoError(t, decodeJSON(resp, &hit))
require.Equal(t, key, hit.CacheKey)
require.NotEmpty(t, hit.ArchiveLocation)
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough.
dl, err := http.Get(hit.ArchiveLocation)
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1))
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]))
unregister()
resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func decodeJSON(resp *http.Response, v any) error {
@@ -155,33 +121,38 @@ func decodeJSON(resp *http.Response, v any) error {
return json.UnmarshalRead(resp.Body, v)
}
// End-to-end against a remote cache-server: token unknown → 401, register →
// reserve/upload/commit/find/download all OK, revoke → 401 again. Registering also names the
// instance and the address its jobs reach the server at, which makes a shared cache server the
// whole results service, as the built-in one is.
// End-to-end through a runner to 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(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.2", InternalSecret: secret}) // advertised, never dialled
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
require.NoError(t, err)
defer remote.Close()
external := strings.Replace(remote.ExternalURL(), "127.0.0.2", "127.0.0.1", 1)
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
gitea := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
assert.Equal(t, "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", req.URL.Path)
assert.Equal(t, "Bearer external-task-token", req.Header.Get("Authorization"))
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer gitea.Close()
r := &Runner{
cfg: &config.Config{Cache: config.Cache{
ExternalServer: external,
cli := clientmocks.NewClient(t)
cli.AddressValue = gitea.URL
r := NewRunner(&config.Config{
Runner: config.Runner{Insecure: true},
Cache: config.Cache{
Host: "127.0.0.1",
ExternalServer: remote.ExternalURL() + "//",
ExternalSecret: secret,
}},
envs: map[string]string{"ACTIONS_RESULTS_URL": gitea.URL},
}
},
}, &config.Registration{}, cli)
t.Cleanup(func() { _ = r.Close() })
require.NotNil(t, r.cacheHandler)
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "external-task-token"
repo := "owner/repoX"
base := external + "/_apis/artifactcache"
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
probe := func() int {
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -194,30 +165,24 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration")
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
}}}
disabled.registerCacheForTask(token, repo, nil)
require.Equal(t, http.StatusUnauthorized, probe(),
"a disabled cache registers nothing, whatever external server is left configured")
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
unregister, resultsURL := r.registerCacheForTask(token, repo, publicURL, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask")
// The server took the results service over, so the artifact half reaches Gitea through it.
require.Equal(t, external, resultsURL)
require.Equal(t, publicURL, resultsURL)
artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
resultsURL+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
r.cacheHandler.ExternalURL()+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
require.NoError(t, err)
artifact.Header.Set("Authorization", "Bearer "+token)
forwarded, err := http.DefaultClient.Do(artifact)
require.NoError(t, err)
forwarded.Body.Close()
require.Equal(t, http.StatusOK, forwarded.StatusCode)
var artifactResult map[string]bool
require.NoError(t, decodeJSON(forwarded, &artifactResult))
assert.True(t, artifactResult["ok"])
// Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, against the remote (external) server.
// @actions/cache does, through the runner to the remote 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))
@@ -256,14 +221,22 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, decodeJSON(resp, &hit))
require.True(t, strings.HasPrefix(hit.ArchiveLocation, external), hit.ArchiveLocation)
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
dl, err := http.Get(hit.ArchiveLocation)
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
require.NoError(t, err)
defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode)
downloaded, err := io.ReadAll(dl.Body)
require.NoError(t, err)
assert.Equal(t, body, downloaded)
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
forwarded, err = http.DefaultClient.Do(artifact)
require.NoError(t, err)
forwarded.Body.Close()
assert.Equal(t, http.StatusUnauthorized, forwarded.StatusCode)
}
+6 -1
View File
@@ -311,11 +311,13 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
}
var swept []string
var detached []string
var sweptVolumes []string
var sweptCutoff time.Time
origRemoveOrphanNetworks := removeOrphanNetworks
removeOrphanNetworks = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
removeOrphanNetworks = func(_ context.Context, runnerUUID, detach string, createdBefore time.Time) error {
swept = append(swept, runnerUUID)
detached = append(detached, detach)
sweptCutoff = createdBefore
return nil
}
@@ -329,6 +331,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
r.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes)
@@ -340,6 +343,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
dockerReachable = func(context.Context) bool { return false }
t.Cleanup(func() { dockerReachable = origDockerReachable })
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
hostOnly.isolatedCacheContainer = func() string { return "" }
hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes)
@@ -348,4 +352,5 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
now = now.Add(time.Minute)
hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
assert.Equal(t, []string{"a1b2c3d4e5f6", ""}, detached)
}
+15 -21
View File
@@ -251,15 +251,19 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
cfg := &config.Config{}
cfg.Cache.ExternalServer = "http://cache.local:8088/"
cfg.Cache.Host = "runner.local"
reg := &config.Registration{Name: "runner"}
cli := clientmocks.NewClient(t)
cli.AddressValue = "https://gitea.example/"
r := NewRunner(cfg, reg, cli)
t.Cleanup(func() { _ = r.Close() })
require.NotContains(t, r.envs, "http_proxy")
require.NotContains(t, r.envs, "no_proxy")
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours")
proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil)
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
}
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
@@ -289,7 +293,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
// The registration is what makes it true: the cache server takes the results service over,
// having been told which instance to forward the artifact half to.
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", nil)
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", r.cacheHandler.ExternalURL(), nil)
defer revoke()
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
@@ -341,26 +345,16 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
require.NoError(t, err)
r.isolatedCacheNetwork = func() string { return "compose" }
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
assert.Equal(t, isolated, r.cacheIsolatedFrom(workflow.GetJob(job), pickPlatform), job)
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform)
if isolated {
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
} else {
assert.Empty(t, cacheContainer, job)
assert.Equal(t, r.cacheHandler.ExternalURL(), cacheURL, job)
}
}
}
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
// server that is missing the slash would send it to a URL whose port swallows the path.
func TestNewRunnerNormalizesTheExternalCacheServer(t *testing.T) {
cfg := &config.Config{}
cfg.Cache.ExternalServer = "http://cache.local:8088//"
cli := clientmocks.NewClient(t)
cli.AddressValue = "https://gitea.example/"
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli)
assert.Equal(t, "http://cache.local:8088/", r.envs["ACTIONS_CACHE_URL"])
// Nothing to front the results service with, so the variable stays unset and the client keeps
// to v1, which reads the cache URL first.
assert.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
assert.Empty(t, r.envs[runner.CacheServiceV2Env])
}
+4 -12
View File
@@ -148,19 +148,12 @@ cache:
#dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
# A runner container on the jobs' Docker daemon attaches to each job network automatically.
#host: ""
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
#port: 0
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# URL of a shared `gitea-runner cache-server` to forward cache calls to.
# Set on every runner that should share a cache pool. A trailing slash is optional.
# Jobs reach the server at this URL too, so set it to the reverse proxy when one fronts the server.
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
#external_server: ""
@@ -181,7 +174,7 @@ cache:
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
# put back after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
# cache server unless container jobs cannot reach its Docker network.
# cache server.
#v2: true
# How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
@@ -201,9 +194,8 @@ cache:
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# A runner container outside bridge cannot join it, so jobs on bridge cannot reach its cache.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
#network: ""
# network_create_options only apply when `network` is left empty and the runner