mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 23:05:45 +00: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 <[email protected]> Reviewed-by: bircni <[email protected]> Co-authored-by: Zettat123 <[email protected]>
406 lines
14 KiB
Go
406 lines
14 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package git
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cgi"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestFetchAction(t *testing.T) {
|
|
original := archiveRetryDelay
|
|
archiveRetryDelay = func(_retryAfter string) time.Duration { return time.Nanosecond }
|
|
t.Cleanup(func() { archiveRetryDelay = original })
|
|
|
|
root := t.TempDir()
|
|
remote := filepath.Join(root, "org", "action")
|
|
require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
|
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
|
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
|
require.NoError(t, gitCmd("-C", remote, "add", "."))
|
|
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
|
|
require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
|
|
|
|
gitPath, err := exec.LookPath("git")
|
|
require.NoError(t, err)
|
|
backend := &cgi.Handler{Path: gitPath, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
|
var mu sync.Mutex
|
|
var archiveRequests []http.Header
|
|
var mirror *httptest.Server
|
|
requests, archives, failures := 0, "", 0
|
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
requests++
|
|
ref, ok := strings.CutPrefix(request.URL.Path, "/org/action/archive/")
|
|
if !ok {
|
|
backend.ServeHTTP(writer, request)
|
|
return
|
|
}
|
|
archiveRequests = append(archiveRequests, request.Header.Clone())
|
|
switch {
|
|
case failures > 0:
|
|
failures--
|
|
writer.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
case archives == "none":
|
|
http.NotFound(writer, request)
|
|
return
|
|
case archives == "unreachable":
|
|
http.Redirect(writer, request, "http://127.0.0.1:1"+request.URL.Path, http.StatusFound)
|
|
return
|
|
case archives == "redirect" && request.Host != mirror.Listener.Addr().String():
|
|
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
|
return
|
|
}
|
|
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
|
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
|
if request.Header.Get("If-None-Match") == etag {
|
|
writer.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
args := []string{"archive", "--format=tar.gz", sha}
|
|
if archives != "unprefixed" {
|
|
args = append(args, "--prefix=action/")
|
|
}
|
|
archive, _ := runGit(request.Context(), remote, nil, args...)
|
|
writer.Header().Set("ETag", etag)
|
|
_, _ = writer.Write(archive)
|
|
})
|
|
server := httptest.NewServer(handler)
|
|
mirror = httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
t.Cleanup(mirror.Close)
|
|
serve := func(mode string, failing int) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
requests, archives, failures, archiveRequests = 0, mode, failing, nil
|
|
}
|
|
served := func() (int, []http.Header) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return requests, append([]http.Header(nil), archiveRequests...)
|
|
}
|
|
newInput := func(ref string) NewGitCloneExecutorInput {
|
|
return NewGitCloneExecutorInput{URL: server.URL + "/org/action", Ref: ref, Dir: t.TempDir(), Token: "secret"}
|
|
}
|
|
|
|
t.Run("shares identical fetches and isolates cancellation, panics and differing inputs", func(t *testing.T) {
|
|
expectedSha := gitRevParse(t, remote, "main")
|
|
for _, scenario := range []string{"shared", "leader cancelled", "leader panics", "different token"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
serve("prefixed", 0)
|
|
input := newInput("main")
|
|
started, release := make(chan struct{}), make(chan struct{})
|
|
releaseFetches := sync.OnceFunc(func() { close(release) })
|
|
t.Cleanup(releaseFetches)
|
|
var once sync.Once
|
|
var panicked atomic.Bool
|
|
transport := actionFetchTransport(func(request *http.Request) (*http.Response, error) {
|
|
once.Do(func() { close(started) })
|
|
select {
|
|
case <-request.Context().Done():
|
|
return nil, request.Context().Err()
|
|
case <-release:
|
|
}
|
|
if scenario == "leader panics" && panicked.CompareAndSwap(false, true) {
|
|
panic("broken archive")
|
|
}
|
|
return http.DefaultTransport.RoundTrip(request)
|
|
})
|
|
input.Transport = &transport
|
|
leaderCtx, cancelLeader := context.WithCancel(t.Context())
|
|
defer cancelLeader()
|
|
waiting := &actionFetchContext{Context: t.Context(), waiting: make(chan struct{})}
|
|
followerInput := input
|
|
expectedRequests := 1
|
|
if scenario == "different token" {
|
|
followerInput.Token, expectedRequests = "other", 2
|
|
}
|
|
leaderDone, followerDone := make(chan error, 1), make(chan error, 1)
|
|
fetch := func(ctx context.Context, input NewGitCloneExecutorInput, done chan<- error) {
|
|
var err error
|
|
defer func() { done <- err }()
|
|
defer common.RecoverPanic(&err)
|
|
var tree, sha string
|
|
if tree, sha, err = FetchAction(ctx, input); err == nil {
|
|
assert.Equal(t, expectedSha, sha)
|
|
assert.Equal(t, filepath.Join(input.Dir, sha), tree)
|
|
}
|
|
}
|
|
go fetch(leaderCtx, input, leaderDone)
|
|
<-started
|
|
go fetch(waiting, followerInput, followerDone)
|
|
<-waiting.waiting
|
|
if scenario == "leader cancelled" {
|
|
cancelLeader()
|
|
require.ErrorIs(t, <-leaderDone, context.Canceled)
|
|
}
|
|
releaseFetches()
|
|
switch scenario {
|
|
case "leader cancelled":
|
|
case "leader panics":
|
|
require.ErrorContains(t, <-leaderDone, "panic: broken archive")
|
|
default:
|
|
require.NoError(t, <-leaderDone)
|
|
}
|
|
require.NoError(t, <-followerDone)
|
|
_, headers := served()
|
|
assert.Len(t, headers, expectedRequests)
|
|
if scenario == "different token" {
|
|
assert.NotEqual(t, headers[0].Get("Authorization"), headers[1].Get("Authorization"))
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("downloads, revalidates and follows a moved tag", func(t *testing.T) {
|
|
serve("prefixed", 0)
|
|
input := newInput("v1")
|
|
tree, sha, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gitRevParse(t, remote, "v1"), sha)
|
|
assert.Equal(t, filepath.Join(input.Dir, sha), tree)
|
|
assert.NoDirExists(t, filepath.Join(tree, ".git"))
|
|
info, err := os.Stat(filepath.Join(tree, "run.sh"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
link, err := os.Readlink(filepath.Join(tree, "link"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "action.yml", link)
|
|
|
|
again, _, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tree, again)
|
|
|
|
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
|
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
|
moved, movedSha, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gitRevParse(t, remote, "v1"), movedSha)
|
|
assert.NotEqual(t, tree, moved)
|
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
|
|
|
input.OfflineMode = true
|
|
_, offlineSha, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, movedSha, offlineSha)
|
|
require.NoError(t, os.Truncate(filepath.Join(moved, "action.yml"), 4)) // fetched again, offline or not
|
|
_, _, err = FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.Remove(moved+treeSizes)) // from an older runner, used as it is offline
|
|
reused, _, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, moved, reused)
|
|
|
|
requests, headers := served()
|
|
assert.Equal(t, 4, requests)
|
|
require.Len(t, headers, 4)
|
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), headers[0].Get("Authorization"))
|
|
assert.Empty(t, headers[0].Get("If-None-Match"))
|
|
assert.Equal(t, fmt.Sprintf(`W/"%s-tar.gz-true"`, sha), headers[1].Get("If-None-Match"))
|
|
assert.Equal(t, headers[1].Get("If-None-Match"), headers[2].Get("If-None-Match"))
|
|
})
|
|
|
|
t.Run("downloads a pinned commit once and rejects a short one", func(t *testing.T) {
|
|
serve("prefixed", 0)
|
|
sha := gitRevParse(t, remote, "main")
|
|
input := newInput(sha)
|
|
for range 2 {
|
|
tree, got, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, sha, got)
|
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
|
}
|
|
_, _, err := FetchAction(t.Context(), newInput(sha[:7]))
|
|
require.ErrorIs(t, err, ErrShortRef)
|
|
_, headers := served()
|
|
assert.Len(t, headers, 1)
|
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
|
})
|
|
|
|
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
|
|
sha := gitRevParse(t, remote, "main")
|
|
for _, mode := range []string{"prefixed", "none"} {
|
|
serve(mode, 0)
|
|
input := newInput(sha)
|
|
tree, _, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err, mode)
|
|
yml := filepath.Join(tree, "action.yml")
|
|
require.NoError(t, os.WriteFile(filepath.Join(tree, "trampoline.js"), nil, 0o644)) // the runner's own, no damage
|
|
_, _, err = FetchAction(t.Context(), input)
|
|
require.NoError(t, err, mode)
|
|
for _, damage := range []func() error{
|
|
func() error { return os.Truncate(yml, 4) },
|
|
func() error { return os.Remove(tree + treeSizes) },
|
|
} {
|
|
require.NoError(t, damage(), mode)
|
|
_, _, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err, mode)
|
|
body, err := os.ReadFile(yml)
|
|
require.NoError(t, err, mode)
|
|
assert.Equal(t, "name: action\n", string(body), mode)
|
|
}
|
|
_, headers := served()
|
|
assert.Len(t, headers, 3, mode)
|
|
}
|
|
|
|
serve("prefixed", 0)
|
|
input := newInput(sha)
|
|
tree, _, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
yml := filepath.Join(tree, "action.yml")
|
|
require.NoError(t, os.Truncate(yml, 4))
|
|
unlock, err := AcquireCloneLock(t.Context(), tree)
|
|
require.NoError(t, err)
|
|
release := sync.OnceFunc(unlock)
|
|
defer release()
|
|
waiting := make(chan struct{})
|
|
lockTree = func(ctx context.Context, dir string) (func(), error) {
|
|
close(waiting)
|
|
return AcquireCloneLock(ctx, dir)
|
|
}
|
|
t.Cleanup(func() { lockTree = AcquireCloneLock })
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := FetchAction(t.Context(), input)
|
|
done <- err
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("FetchAction replaced the tree without waiting for the job holding it: %v", err)
|
|
case <-waiting:
|
|
}
|
|
info, err := os.Stat(yml)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(4), info.Size(), "the tree a job holds is left alone")
|
|
release()
|
|
require.NoError(t, <-done)
|
|
body, err := os.ReadFile(yml)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "name: action\n", string(body))
|
|
})
|
|
|
|
t.Run("retries a failing download and drops the token on a redirect to another origin", func(t *testing.T) {
|
|
serve("redirect", 2)
|
|
input := newInput("main")
|
|
_, sha, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
|
_, headers := served()
|
|
require.Len(t, headers, 4)
|
|
assert.NotEmpty(t, headers[2].Get("Authorization"))
|
|
assert.Empty(t, headers[3].Get("Authorization"))
|
|
})
|
|
|
|
t.Run("keeps an unprefixed archive whole, downloads where insteadOf points and exports a clone without an archive or from an unreachable host", func(t *testing.T) {
|
|
for _, mode := range []string{"unprefixed", "none", "unreachable"} {
|
|
serve(mode, 0)
|
|
tree, sha, err := FetchAction(t.Context(), newInput("main"))
|
|
require.NoError(t, err, mode)
|
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha, mode)
|
|
assert.FileExists(t, filepath.Join(tree, "action.yml"), mode)
|
|
assert.NoDirExists(t, filepath.Join(tree, ".git"), mode)
|
|
_, headers := served()
|
|
assert.Len(t, headers, 1, mode)
|
|
}
|
|
|
|
serve("prefixed", 0)
|
|
config := filepath.Join(t.TempDir(), "gitconfig")
|
|
require.NoError(t, os.WriteFile(config, []byte("[url \""+server.URL+"/org/\"]\n\tinsteadOf = https://example.invalid/org/\n"), 0o644))
|
|
t.Setenv("GIT_CONFIG_GLOBAL", config)
|
|
input := newInput("main")
|
|
input.URL = "https://example.invalid/org/action"
|
|
_, sha, err := FetchAction(t.Context(), input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
|
_, headers := served()
|
|
assert.Len(t, headers, 1)
|
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
|
})
|
|
}
|
|
|
|
type actionFetchTransport func(*http.Request) (*http.Response, error)
|
|
|
|
func (transport actionFetchTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return transport(request)
|
|
}
|
|
|
|
type actionFetchContext struct {
|
|
context.Context
|
|
waiting chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (ctx *actionFetchContext) Done() <-chan struct{} {
|
|
ctx.once.Do(func() { close(ctx.waiting) })
|
|
return ctx.Context.Done()
|
|
}
|
|
|
|
func TestExtractTreeConfinesEntries(t *testing.T) {
|
|
outside := t.TempDir()
|
|
for name, entries := range map[string][]*tar.Header{
|
|
"parent path": {{Name: "../escape", Typeflag: tar.TypeReg}},
|
|
"through a symlink": {{Name: "out", Typeflag: tar.TypeSymlink, Linkname: outside}, {Name: "out/escape", Typeflag: tar.TypeReg}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var archive bytes.Buffer
|
|
writer := tar.NewWriter(&archive)
|
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": strings.Repeat("a", 40)}}))
|
|
for _, entry := range entries {
|
|
require.NoError(t, writer.WriteHeader(entry))
|
|
}
|
|
require.NoError(t, writer.Close())
|
|
|
|
dir := t.TempDir()
|
|
_, err := extractTree(t.Context(), &archive, dir, false)
|
|
require.Error(t, err)
|
|
assert.NoFileExists(t, filepath.Join(dir, "escape"))
|
|
assert.NoFileExists(t, filepath.Join(outside, "escape"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTreeIntactFindsNamesThatAreNotUTF8(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("macOS and Windows store no name that is not UTF-8")
|
|
}
|
|
sha := strings.Repeat("a", 40)
|
|
var archive bytes.Buffer
|
|
writer := tar.NewWriter(&archive)
|
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": sha}}))
|
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: "caf\xe9.js", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1, Format: tar.FormatGNU}))
|
|
_, err := writer.Write([]byte("x"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
|
|
dir := t.TempDir()
|
|
_, err = extractTree(t.Context(), &archive, dir, false)
|
|
require.NoError(t, err)
|
|
require.FileExists(t, filepath.Join(dir, sha, "caf\xe9.js"))
|
|
assert.True(t, treeIntact(dir, sha))
|
|
}
|