diff --git a/README.md b/README.md index cb0beca..b81126b 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,85 @@ echo "SCCACHE_S3_KEY_PREFIX=cache/sccache" >> $GITHUB_ENV echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV ``` +### `cache` / `path` + +Available for Linux and Windows runners on jobs with a sticky-disk label. Use `snap=` for the default snapshot lineage or `snap=:` for a named lineage; the optional name must come first. Volume settings follow the size, for example `snap=go-cache:20gb:gp3:750mbs:6000iops`. The `apt`, `buildkit`, and `git` cache modes are Linux only. + +Persists package manager caches across jobs by bind-mounting them onto the job's sticky disk — a dedicated EBS volume that is snapshotted at job completion and restored (per repo, name, architecture, and branch) on the next job. No tarball upload/download: caches are available at native disk speed, with no size penalty on job duration. + +Example: + +```yaml +jobs: + build: + runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/snap=20gb + steps: + - uses: actions/checkout@v4 + - uses: runs-on/action@v2 + with: + cache: | + go + node +``` + +Supported cache modes and the directories they persist: + +| Mode | Aliases | Cached paths | +|---|---|---| +| `go` | `golang` | `~/.cache/go-build`, `~/go/pkg/mod` | +| `node` | `npm` | `~/.npm` | +| `yarn` | | `~/.cache/yarn` | +| `pnpm` | | `~/.pnpm-store` | +| `ruby` | `bundler` | `~/.bundle`, `vendor/bundle` | +| `rust` | `cargo` | `~/.cargo/registry`, `~/.cargo/git` | +| `python` | `pip` | `~/.cache/pip` | +| `uv` | | `~/.cache/uv` | +| `poetry` | | `~/.cache/pypoetry` | +| `apt` | | `/var/cache/apt/archives` | +| `buildkit` | `buildx` | BuildKit layer cache (dedicated `buildkitd` with state on the sticky disk, registered as the current buildx builder) | +| `git` | `checkout` | Git repository mirrors (local git proxy serving github.com fetches from the sticky disk) | +| `gradle` | | `~/.gradle/caches`, `~/.gradle/wrapper` | +| `maven` | | `~/.m2/repository` | +| `playwright` | | `~/.cache/ms-playwright` | + +The `buildkit` mode follows the dedicated-builder pattern: it starts a `buildkitd` daemon whose state (and the buildkit binaries themselves) live on the sticky disk, and registers it as the current buildx builder. The docker daemon is never touched. Use `docker buildx build` (or `docker/build-push-action`); add `--load` when you need the built image available to the local docker daemon (e.g. for `docker run`). `docker pull` and plain `docker build` on the default builder are not cached by this mode. + +#### `git` mode (fast checkouts) + +The `git` mode accelerates `actions/checkout` — and any other `git fetch`/`git clone` of a github.com repository during the job — without changing your checkout step. It starts a local git proxy whose bare repository mirrors live on the sticky disk, and rewrites `https://github.com/` fetch URLs to it (global `url.insteadOf`). The mirror syncs once per repo per job (only new objects cross the network); the fetch itself, including shallow `fetch-depth: 1` packs, is served at disk speed by `git upload-pack` on the runner. + +Unlike other modes, the `git` mode must run **before** `actions/checkout`: + +```yaml +jobs: + build: + runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/snap=20gb + steps: + - uses: runs-on/action@v2 + with: + cache: git + - uses: actions/checkout@v4 +``` + +To combine it with workspace-relative `path:` caching (which must run after checkout), run the action twice — the second invocation reuses the already-running proxy. + +Notes and limitations: + +* Mirror syncs authenticate with the `token` input (default: `${{ github.token }}`). Checking out **other private repositories** requires passing a PAT with access to them, since the rewritten URLs no longer match the credentials configured by `actions/checkout`. +* `git push` is pinned to upstream (`pushInsteadOf`) and never goes through the proxy; Git LFS and anything else the proxy cannot serve is transparently forwarded to github.com. If mirroring fails for any reason, fetches fall back to upstream — the mode never breaks a build. +* SSH remotes (`git@github.com:`) are not rewritten, and container jobs (`container:`) are not accelerated (the proxy listens on the host's loopback). GitHub Enterprise Server is not supported. Linux only. + +Use the `path` input to persist arbitrary additional paths (newline separated). Relative paths are resolved against the workspace, so run this action **after** `actions/checkout` when caching workspace-relative paths (e.g. `vendor/bundle`). + +**Disk pressure:** the buildkit cache is bounded by BuildKit's own garbage collection (capped at ~75% of the volume, keeping 20% free; least-recently-used layers are pruned automatically during builds). Other cache modes have no native GC: when the volume drops below 20% free space (or 10% free inodes), the post step emits a warning and a job summary with a per-cache breakdown — increase the `snap=` label size to fix. If a volume is ever critically full at job start (<5% free space or inodes), all caches on it are automatically reset so the job runs cold instead of failing with "no space left on device", and the next snapshot starts clean. + +Other related inputs: + +* `wait_timeout` - how long to wait for the sticky disk to be ready (default `5m`) +* `fail_on_missing` - fail the step when no sticky disk is available instead of continuing without cache (default `false`) + +The action sets a `cache-hit` output: `true` when every requested path was restored from a previous snapshot. + ## Development Make your source code changes in a commit, then rebuild and commit the generated binaries and JS files: diff --git a/action.yml b/action.yml index 62bf3c4..43a9f43 100644 --- a/action.yml +++ b/action.yml @@ -29,4 +29,28 @@ inputs: sccache: description: 'Enable sccache. Can take either "s3" (RunsOn S3 cache bucket) or be empty (disabled). You still need to setup sccache in your workflow, for instance with mozilla-actions/sccache-action.' required: false - default: '' \ No newline at end of file + default: '' + cache: + description: 'Newline/comma separated list of cache modes to persist on the sticky disk (go, node, yarn, pnpm, ruby, rust, python, uv, poetry, apt, buildkit, git, gradle, maven, playwright). Requires a snap= or snap=: label on the job; the optional name must come first, before the size and any volume settings. Linux and Windows (apt, buildkit and git modes are Linux only). Run this action after actions/checkout when caching workspace-relative paths. The git mode is the exception: it must run BEFORE actions/checkout, since it accelerates github.com fetches with git mirrors persisted on the sticky disk (combine both by running the action twice).' + required: false + default: '' + token: + description: 'GitHub token used by the git cache mode to sync repository mirrors from github.com. Supply a PAT if the job checks out private repositories other than the workflow repository.' + required: false + default: ${{ github.token }} + path: + description: 'Newline separated list of arbitrary paths to persist on the sticky disk. Requires a snap= or snap=: label on the job; the optional name must come first, before the size and any volume settings. Linux and Windows.' + required: false + default: '' + wait_timeout: + description: 'How long to wait for the sticky disk to be ready (Go duration, e.g. "5m")' + required: false + default: '5m' + fail_on_missing: + description: 'Fail the step if no sticky disk is available (no snap= label, or not ready before wait_timeout)' + required: false + default: 'false' + +outputs: + cache-hit: + description: 'Set to "true" when every requested cache path was restored from a previous snapshot, "false" otherwise' diff --git a/go.mod b/go.mod index ca22dd6..11c4bd7 100644 --- a/go.mod +++ b/go.mod @@ -24,4 +24,5 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect github.com/aws/smithy-go v1.24.2 // indirect + golang.org/x/sync v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 5bf542f..200fff6 100644 --- a/go.sum +++ b/go.sum @@ -34,3 +34,5 @@ github.com/guptarohit/asciigraph v0.8.1 h1:JBeHTGj2ntBODnZxLQhp+GQZdlZ/48S/m7J1i github.com/guptarohit/asciigraph v0.8.1/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/sethvargo/go-githubactions v1.3.2 h1:gkibLr/QjosgNWoCf1V58rTMRZw7xZtSB7dY4atbl1Y= github.com/sethvargo/go-githubactions v1.3.2/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= diff --git a/internal/config/config.go b/internal/config/config.go index 267b008..cdf73ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/sethvargo/go-githubactions" ) @@ -17,6 +18,10 @@ type Config struct { NetworkInterface string DiskDevice string Sccache string + Cache []string + CachePaths []string + CacheWaitTimeout time.Duration + CacheFailOnMissing bool ZctionsResultsURL string ZctionsCacheURL string ActionsResultsURL string @@ -63,6 +68,40 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) { cfg.Sccache = action.GetInput("sccache") + cacheInput := action.GetInput("cache") + if cacheInput != "" { + cfg.Cache = splitList(cacheInput) + } + + pathInput := action.GetInput("path") + if pathInput != "" { + for _, entry := range strings.Split(pathInput, "\n") { + entry = strings.TrimSpace(entry) + if entry != "" { + cfg.CachePaths = append(cfg.CachePaths, entry) + } + } + } + + cfg.CacheWaitTimeout = 5 * time.Minute + waitTimeoutStr := action.GetInput("wait_timeout") + if waitTimeoutStr != "" { + if timeout, err := time.ParseDuration(waitTimeoutStr); err == nil { + cfg.CacheWaitTimeout = timeout + } else { + action.Warningf("Error parsing 'wait_timeout' input '%s': %v. Using default 5m.", waitTimeoutStr, err) + } + } + + failOnMissingStr := action.GetInput("fail_on_missing") + if failOnMissingStr != "" { + var err error + cfg.CacheFailOnMissing, err = strconv.ParseBool(failOnMissingStr) + if err != nil { + action.Warningf("Error parsing 'fail_on_missing' input '%s': %v. Assuming false.", failOnMissingStr, err) + } + } + cfg.ZctionsResultsURL = os.Getenv("ZCTIONS_RESULTS_URL") cfg.ZctionsCacheURL = os.Getenv("ZCTIONS_CACHE_URL") cfg.ActionsResultsURL = os.Getenv("ACTIONS_RESULTS_URL") @@ -74,6 +113,8 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) { action.Infof("Input 'network_interface': %s", cfg.NetworkInterface) action.Infof("Input 'disk_device': %s", cfg.DiskDevice) action.Infof("Input 'sccache': %s", cfg.Sccache) + action.Infof("Input 'cache': %v", cfg.Cache) + action.Infof("Input 'path': %v", cfg.CachePaths) if cfg.ZctionsResultsURL != "" { action.Infof("ZCTIONS_RESULTS_URL is set: %s", cfg.ZctionsResultsURL) @@ -110,6 +151,26 @@ func (c *Config) HasSccache() bool { return c.IsUsingRunsOn() && c.IsUsingLinux() && c.Sccache != "" } +// HasStickyDiskCache reports whether sticky disk caching was requested. The +// Linux check happens inside the stickydisk package so non-Linux runners get +// an explicit warning instead of a silent skip. +func (c *Config) HasStickyDiskCache() bool { + return c.IsUsingRunsOn() && (len(c.Cache) > 0 || len(c.CachePaths) > 0) +} + +// splitList splits a newline and/or comma separated input into trimmed, +// non-empty entries. +func splitList(input string) []string { + var entries []string + for _, entry := range strings.FieldsFunc(input, func(r rune) bool { return r == '\n' || r == ',' }) { + entry = strings.TrimSpace(entry) + if entry != "" { + entries = append(entries, entry) + } + } + return entries +} + func (c *Config) IsUsingRunsOn() bool { return os.Getenv("RUNS_ON_RUNNER_NAME") != "" } diff --git a/internal/gitproxy/fallback.go b/internal/gitproxy/fallback.go new file mode 100644 index 0000000..198fb53 --- /dev/null +++ b/internal/gitproxy/fallback.go @@ -0,0 +1,81 @@ +package gitproxy + +import ( + "io" + "net/http" +) + +// hopHeaders are connection-level headers that must not be forwarded. +var hopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Te", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +// forwardUpstream transparently replays the request against the real host +// (https://{host}{rest}), streaming the response back. Used for everything +// the mirror cannot or should not serve: LFS endpoints, receive-pack, +// requests for objects missing from the mirror, or mirror failures. The +// client's own Authorization header travels with the request, so upstream +// enforces the exact same permissions as a direct call. +func (s *Server) forwardUpstream(w http.ResponseWriter, r *http.Request, t target, body io.Reader, reason string) { + url := s.upstreamBase(t.host) + t.rest + if r.URL.RawQuery != "" { + url += "?" + r.URL.RawQuery + } + + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + req.Header = r.Header.Clone() + for _, h := range hopHeaders { + req.Header.Del(h) + } + req.Host = t.host + + // RoundTrip (not http.Client) so redirects pass through to the git/LFS + // client untouched, exactly as if it had talked to upstream directly. + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + s.log.Error("upstream forward failed", "url", url, "reason", reason, "err", err) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + s.log.Info("forwarded upstream", "url", url, "reason", reason, "status", resp.StatusCode) + header := w.Header() + for k, vv := range resp.Header { + for _, v := range vv { + header.Add(k, v) + } + } + for _, h := range hopHeaders { + header.Del(h) + } + header.Set(statusHeader, "upstream-"+reason) + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(&flushWriter{w}, resp.Body); err != nil { + s.log.Warn("upstream response copy interrupted", "url", url, "err", err) + } +} + +// flushWriter flushes after each write so pack data streams to the git +// client instead of buffering the whole response. +type flushWriter struct { + w http.ResponseWriter +} + +func (fw *flushWriter) Write(p []byte) (int, error) { + n, err := fw.w.Write(p) + if f, ok := fw.w.(http.Flusher); ok { + f.Flush() + } + return n, err +} diff --git a/internal/gitproxy/gitproxy.go b/internal/gitproxy/gitproxy.go new file mode 100644 index 0000000..cb128d4 --- /dev/null +++ b/internal/gitproxy/gitproxy.go @@ -0,0 +1,211 @@ +package gitproxy + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/cgi" + "os/exec" + "slices" + "strings" + "time" +) + +const ( + // DefaultStaleAfter keeps mirrors fresh within a job: the first request + // per repo always syncs (lastSync is in-memory, fresh per process), and + // anything fetched again more than 2s later re-syncs. + DefaultStaleAfter = 2 * time.Second + + statusHeader = "X-Git-Proxy-Status" +) + +// Options configures the proxy server. +type Options struct { + // MirrorDir is the mirror storage root (on the sticky disk). + MirrorDir string + // AllowedHosts are the upstream hosts the proxy will serve/forward. + AllowedHosts []string + // StaleAfter bounds how long a mirror is served without re-syncing. + StaleAfter time.Duration + Logger *slog.Logger +} + +// Server routes git smart-HTTP requests: upload-pack traffic is served from +// local mirrors via `git http-backend`, everything else (LFS, receive-pack, +// unknown endpoints) is transparently forwarded upstream so nothing breaks. +type Server struct { + opts Options + mirror *Mirror + cgi *cgi.Handler + log *slog.Logger + + // upstreamBase maps a host to the scheme+host prefix used for mirror + // syncs and forwarded requests. Tests override it to point at a fixture + // server; production always talks https to the real host. + upstreamBase func(host string) string +} + +// NewServer creates the proxy server, verifying git is available. +func NewServer(opts Options) (*Server, error) { + if opts.Logger == nil { + opts.Logger = slog.Default() + } + if opts.StaleAfter <= 0 { + opts.StaleAfter = DefaultStaleAfter + } + if len(opts.AllowedHosts) == 0 { + opts.AllowedHosts = []string{"github.com"} + } + gitPath, err := exec.LookPath("git") + if err != nil { + return nil, fmt.Errorf("git not found in PATH: %w", err) + } + mirror, err := NewMirror(opts.MirrorDir, opts.StaleAfter, opts.Logger) + if err != nil { + return nil, err + } + return &Server{ + opts: opts, + mirror: mirror, + log: opts.Logger, + upstreamBase: func(host string) string { return "https://" + host }, + // git http-backend implements the smart-HTTP protocol exactly + // (including protocol v2). The runner's global/system git config is + // masked so the action's own insteadOf rewrites never apply here. + cgi: &cgi.Handler{ + Path: gitPath, + Args: []string{"http-backend"}, + Env: []string{ + "GIT_PROJECT_ROOT=" + opts.MirrorDir, + "GIT_HTTP_EXPORT_ALL=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + }, + InheritEnv: []string{"PATH"}, + }, + }, nil +} + +// Handler returns the proxy's HTTP handler. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + return + } + + target, err := s.resolveTarget(r) + if err != nil { + s.log.Error("resolve target failed", "path", r.URL.Path, "err", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + switch target.kind { + case kindInfoRefs: + s.handleInfoRefs(w, r, target) + case kindUploadPack: + s.handleUploadPack(w, r, target) + default: + // LFS batch/locks, receive-pack, dumb-protocol paths, anything + // else: forward upstream verbatim so nothing breaks. + s.forwardUpstream(w, r, target, r.Body, "unhandled-endpoint") + } + }) +} + +type requestKind int + +const ( + kindInfoRefs requestKind = iota + kindUploadPack + kindFallback +) + +// target describes a parsed request path /{host}/{owner}/{repo}[.git]/. +type target struct { + kind requestKind + host string + owner, repo string + // rest is the path after /{host}, with the original repo spelling, + // used when forwarding upstream. + rest string +} + +func (t target) repoKey() string { + return fmt.Sprintf("%s/%s/%s", t.host, t.owner, t.repo) +} + +// cgiPath is the canonical path handed to git http-backend, matching the +// on-disk mirror layout /{host}/{owner}/{repo}.git. +func (t target) cgiPath(suffix string) string { + return "/" + t.host + "/" + t.owner + "/" + t.repo + ".git" + suffix +} + +func (s *Server) resolveTarget(r *http.Request) (target, error) { + parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return target{}, fmt.Errorf("invalid path %q: expected /{host}/{owner}/{repo}/...", r.URL.Path) + } + t := target{host: parts[0], rest: "/" + parts[1]} + if !slices.Contains(s.opts.AllowedHosts, t.host) { + return target{}, fmt.Errorf("upstream %q not in allowed list", t.host) + } + + repoPath := t.rest + switch { + case strings.HasSuffix(repoPath, "/info/refs") && r.Method == http.MethodGet && r.URL.Query().Get("service") == "git-upload-pack": + t.kind = kindInfoRefs + repoPath = strings.TrimSuffix(repoPath, "/info/refs") + case strings.HasSuffix(repoPath, "/git-upload-pack") && r.Method == http.MethodPost: + t.kind = kindUploadPack + repoPath = strings.TrimSuffix(repoPath, "/git-upload-pack") + default: + t.kind = kindFallback + return t, nil + } + + repoPath = strings.TrimSuffix(strings.TrimPrefix(repoPath, "/"), ".git") + segs := strings.Split(repoPath, "/") + if len(segs) != 2 || segs[0] == "" || segs[1] == "" { + return target{}, fmt.Errorf("invalid repo path %q: expected {owner}/{repo}", repoPath) + } + t.owner, t.repo = segs[0], segs[1] + return t, nil +} + +// handleInfoRefs syncs the mirror (clone or fetch, auth forwarded from the +// client) then serves the ref advertisement locally. Every git fetch starts +// with info/refs, so this is the once-per-job upstream sync point. +func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request, t target) { + upstreamURL := fmt.Sprintf("%s/%s/%s.git", s.upstreamBase(t.host), t.owner, t.repo) + repoPath, status, err := s.mirror.EnsureRepo(r.Context(), t.host, t.owner, t.repo, upstreamURL, r.Header.Get("Authorization")) + if err != nil { + // Never break a build because mirroring failed: forward the request + // upstream and let git talk to the real host. Subsequent + // upload-pack requests fall back too (missing wants, see below). + s.log.Warn("mirror unavailable, forwarding upstream", "repo", t.repoKey(), "err", err) + s.forwardUpstream(w, r, t, r.Body, "mirror-error") + return + } + s.log.Info("info/refs", "repo", t.repoKey(), "status", status, "path", repoPath) + w.Header().Set(statusHeader, string(status)) + s.serveCGI(w, r, t.cgiPath("/info/refs")) +} + +// serveCGI hands the request to git http-backend at the canonical repo path. +func (s *Server) serveCGI(w http.ResponseWriter, r *http.Request, canonicalPath string) { + req := r.Clone(r.Context()) + req.URL.Path = canonicalPath + s.cgi.ServeHTTP(w, req) +} + +// RunFromEnv builds a Server from RUNS_ON_GIT_PROXY_* environment variables +// and serves it until the context is cancelled or a termination signal +// arrives. Used by the hidden --git-proxy-serve entrypoint. +func RunFromEnv(ctx context.Context) error { + return runFromEnv(ctx) +} diff --git a/internal/gitproxy/gitproxy_test.go b/internal/gitproxy/gitproxy_test.go new file mode 100644 index 0000000..5f168e2 --- /dev/null +++ b/internal/gitproxy/gitproxy_test.go @@ -0,0 +1,340 @@ +package gitproxy + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// gitCmd runs a git command with the user's config masked, failing the test +// on error. +func gitCmd(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +// newUpstreamRepo creates a repo with one commit under dir/{owner}/{repo}.git +// and returns its file:// base and head SHA. +func newUpstreamRepo(t *testing.T) (base string, headSHA string) { + t.Helper() + dir := t.TempDir() + work := filepath.Join(dir, "work") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatal(err) + } + gitCmd(t, work, "init", "-b", "main", ".") + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, work, "add", ".") + gitCmd(t, work, "commit", "-m", "initial") + headSHA = strings.TrimSpace(gitCmd(t, work, "rev-parse", "HEAD")) + + repoDir := filepath.Join(dir, "owner", "repo.git") + gitCmd(t, dir, "clone", "--bare", work, repoDir) + return "file://" + dir, headSHA +} + +func newTestServer(t *testing.T, upstreamBase string) *Server { + t.Helper() + server, err := NewServer(Options{ + MirrorDir: t.TempDir(), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + if upstreamBase != "" { + server.upstreamBase = func(host string) string { return upstreamBase } + } + return server +} + +func TestResolveTarget(t *testing.T) { + server := newTestServer(t, "") + tests := []struct { + method, path, query string + kind requestKind + owner, repo string + wantErr bool + }{ + {"GET", "/github.com/foo/bar/info/refs", "service=git-upload-pack", kindInfoRefs, "foo", "bar", false}, + {"GET", "/github.com/foo/bar.git/info/refs", "service=git-upload-pack", kindInfoRefs, "foo", "bar", false}, + {"POST", "/github.com/foo/bar.git/git-upload-pack", "", kindUploadPack, "foo", "bar", false}, + // receive-pack and LFS endpoints fall through to upstream + {"GET", "/github.com/foo/bar/info/refs", "service=git-receive-pack", kindFallback, "", "", false}, + {"POST", "/github.com/foo/bar.git/git-receive-pack", "", kindFallback, "", "", false}, + {"POST", "/github.com/foo/bar.git/info/lfs/objects/batch", "", kindFallback, "", "", false}, + {"GET", "/gitlab.com/foo/bar/info/refs", "service=git-upload-pack", 0, "", "", true}, + {"GET", "/github.com", "", 0, "", "", true}, + } + for _, tt := range tests { + r := httptest.NewRequest(tt.method, tt.path+"?"+tt.query, nil) + target, err := server.resolveTarget(r) + if tt.wantErr { + if err == nil { + t.Errorf("%s %s: expected error", tt.method, tt.path) + } + continue + } + if err != nil { + t.Errorf("%s %s: unexpected error: %v", tt.method, tt.path, err) + continue + } + if target.kind != tt.kind || target.owner != tt.owner || target.repo != tt.repo { + t.Errorf("%s %s: got kind=%d owner=%q repo=%q, want kind=%d owner=%q repo=%q", + tt.method, tt.path, target.kind, target.owner, target.repo, tt.kind, tt.owner, tt.repo) + } + } +} + +// pkt builds a pkt-line for the given payload. +func pkt(payload string) string { + return fmt.Sprintf("%04x%s", len(payload)+4, payload) +} + +func TestParseWants(t *testing.T) { + sha1 := strings.Repeat("a", 40) + sha2 := strings.Repeat("b", 40) + + // Protocol v0/v1 framing: wants, flush, done. + v0 := pkt("want "+sha1+" multi_ack_detailed side-band-64k\n") + pkt("want "+sha2+"\n") + pkt("deepen 1") + "0000" + pkt("done\n") + if got := parseWants([]byte(v0), ""); len(got) != 2 || got[0] != sha1 || got[1] != sha2 { + t.Errorf("v0 wants = %v", got) + } + + // Protocol v2 framing: wants come after a delim-pkt (0001), which must + // not terminate parsing. + v2 := pkt("command=fetch") + pkt("agent=git/2.51") + "0001" + pkt("thin-pack\n") + pkt("want "+sha1+"\n") + pkt("deepen 1\n") + "0000" + if got := parseWants([]byte(v2), ""); len(got) != 1 || got[0] != sha1 { + t.Errorf("v2 wants = %v", got) + } + + // v2 ls-refs command has no wants: served locally. + lsRefs := pkt("command=ls-refs") + "0001" + pkt("peel\n") + pkt("ref-prefix refs/heads/\n") + "0000" + if got := parseWants([]byte(lsRefs), ""); len(got) != 0 { + t.Errorf("ls-refs wants = %v", got) + } + + // Gzip-compressed body. + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write([]byte(v2)) + gz.Close() + if got := parseWants(buf.Bytes(), "gzip"); len(got) != 1 || got[0] != sha1 { + t.Errorf("gzip wants = %v", got) + } + + // Malformed input parses to nothing without panicking. + if got := parseWants([]byte("zzzz-not-pkt-lines"), ""); len(got) != 0 { + t.Errorf("malformed wants = %v", got) + } +} + +func TestMirrorEnsureRepo(t *testing.T) { + upstreamBase, headSHA := newUpstreamRepo(t) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + mirror, err := NewMirror(t.TempDir(), 50*time.Millisecond, log) + if err != nil { + t.Fatal(err) + } + upstreamURL := upstreamBase + "/owner/repo.git" + ctx := t.Context() + + repoPath, status, err := mirror.EnsureRepo(ctx, "github.com", "owner", "repo", upstreamURL, "") + if err != nil || status != StatusClone { + t.Fatalf("first EnsureRepo: status=%s err=%v", status, err) + } + + // The clone must allow exact-SHA and filtered fetches: actions/checkout + // fetches commit SHAs, sparse checkouts use --filter. + for _, key := range []string{"uploadpack.allowanysha1inwant", "uploadpack.allowfilter"} { + out := gitCmd(t, repoPath, "config", key) + if strings.TrimSpace(out) != "true" { + t.Errorf("%s = %q, want true", key, out) + } + } + + if _, status, _ = mirror.EnsureRepo(ctx, "github.com", "owner", "repo", upstreamURL, ""); status != StatusHit { + t.Errorf("fresh EnsureRepo: status=%s, want %s", status, StatusHit) + } + + time.Sleep(60 * time.Millisecond) + if _, status, _ = mirror.EnsureRepo(ctx, "github.com", "owner", "repo", upstreamURL, ""); status != StatusSync { + t.Errorf("stale EnsureRepo: status=%s, want %s", status, StatusSync) + } + + if !mirror.HasObject(ctx, repoPath, headSHA) { + t.Errorf("HasObject(%s) = false, want true", headSHA) + } + if mirror.HasObject(ctx, repoPath, strings.Repeat("0", 40)) { + t.Errorf("HasObject(zeros) = true, want false") + } +} + +// TestServerServesGitClients exercises the full stack (router → mirror → +// git http-backend CGI) with real git clients: full clone, shallow clone, +// exact-SHA fetch and filtered fetch, over smart HTTP protocol v2. +func TestServerServesGitClients(t *testing.T) { + upstreamBase, headSHA := newUpstreamRepo(t) + server := newTestServer(t, upstreamBase) + ts := httptest.NewServer(server.Handler()) + defer ts.Close() + repoURL := ts.URL + "/github.com/owner/repo" + + t.Run("full clone", func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dst") + gitCmd(t, t.TempDir(), "clone", repoURL, dst) + if data, err := os.ReadFile(filepath.Join(dst, "README.md")); err != nil || string(data) != "hello\n" { + t.Errorf("README.md = %q, err=%v", data, err) + } + }) + + t.Run("shallow clone", func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dst") + gitCmd(t, t.TempDir(), "clone", "--depth", "1", repoURL, dst) + if sha := strings.TrimSpace(gitCmd(t, dst, "rev-parse", "HEAD")); sha != headSHA { + t.Errorf("HEAD = %s, want %s", sha, headSHA) + } + }) + + t.Run("exact SHA fetch like actions/checkout", func(t *testing.T) { + dst := t.TempDir() + gitCmd(t, dst, "init", ".") + gitCmd(t, dst, "remote", "add", "origin", repoURL) + gitCmd(t, dst, "fetch", "--depth", "1", "origin", "+"+headSHA+":refs/remotes/origin/main") + }) + + t.Run("filtered fetch", func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dst") + gitCmd(t, t.TempDir(), "clone", "--filter=blob:none", repoURL, dst) + }) + + t.Run("status header", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/github.com/owner/repo/info/refs?service=git-upload-pack") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + status := resp.Header.Get(statusHeader) + if !strings.HasPrefix(status, "mirror-") { + t.Errorf("%s = %q, want mirror-*", statusHeader, status) + } + }) +} + +// TestServerFallbacks verifies that anything the mirror cannot serve is +// transparently forwarded upstream with auth and body intact. +func TestServerFallbacks(t *testing.T) { + type recorded struct { + method, path, auth, body string + } + var requests []recorded + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, recorded{r.Method, r.URL.Path, r.Header.Get("Authorization"), string(body)}) + w.Header().Set("X-Upstream", "yes") + // The "missing" repo 404s like a real host would, so the proxy's own + // mirror clone fails rather than dumb-http-cloning an empty repo. + if strings.Contains(r.URL.Path, "/missing/") { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "upstream response") + })) + defer upstream.Close() + + server := newTestServer(t, upstream.URL) + ts := httptest.NewServer(server.Handler()) + defer ts.Close() + + t.Run("LFS batch endpoint", func(t *testing.T) { + requests = nil + req, _ := http.NewRequest("POST", ts.URL+"/github.com/owner/repo.git/info/lfs/objects/batch", strings.NewReader(`{"operation":"download"}`)) + req.Header.Set("Authorization", "Basic abc123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if string(body) != "upstream response" || resp.Header.Get("X-Upstream") != "yes" { + t.Errorf("response not passed through: %q", body) + } + if got := resp.Header.Get(statusHeader); got != "upstream-unhandled-endpoint" { + t.Errorf("%s = %q", statusHeader, got) + } + if len(requests) != 1 || requests[0].path != "/owner/repo.git/info/lfs/objects/batch" || + requests[0].auth != "Basic abc123" || requests[0].body != `{"operation":"download"}` { + t.Errorf("upstream saw %+v", requests) + } + }) + + t.Run("missing want forwards upload-pack upstream", func(t *testing.T) { + requests = nil + // Seed a mirror so the local path would otherwise be taken. + mirrorRepo := server.mirror.RepoPath("github.com", "owner", "repo") + if err := os.MkdirAll(filepath.Dir(mirrorRepo), 0o755); err != nil { + t.Fatal(err) + } + gitCmd(t, t.TempDir(), "init", "--bare", mirrorRepo) + + body := pkt("command=fetch") + "0001" + pkt("want "+strings.Repeat("c", 40)+"\n") + "0000" + resp, err := http.Post(ts.URL+"/github.com/owner/repo.git/git-upload-pack", "application/x-git-upload-pack-request", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got := resp.Header.Get(statusHeader); got != "upstream-missing-want" { + t.Errorf("%s = %q", statusHeader, got) + } + if len(requests) != 1 || requests[0].path != "/owner/repo.git/git-upload-pack" || requests[0].body != body { + t.Errorf("upstream saw %+v", requests) + } + }) + + t.Run("mirror clone failure forwards info/refs upstream", func(t *testing.T) { + requests = nil + // missing/repo does not exist upstream (the fixture serves HTTP but + // git clone against it fails), so EnsureRepo errors out. + resp, err := http.Get(ts.URL + "/github.com/missing/repo/info/refs?service=git-upload-pack") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got := resp.Header.Get(statusHeader); got != "upstream-mirror-error" { + t.Errorf("%s = %q", statusHeader, got) + } + // The 404 from upstream passes through untouched. + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } + if len(requests) == 0 || requests[len(requests)-1].path != "/missing/repo/info/refs" { + t.Errorf("upstream saw %+v", requests) + } + }) +} diff --git a/internal/gitproxy/mirror.go b/internal/gitproxy/mirror.go new file mode 100644 index 0000000..f785709 --- /dev/null +++ b/internal/gitproxy/mirror.go @@ -0,0 +1,276 @@ +// Package gitproxy implements a local git smart-HTTP proxy backed by bare +// repository mirrors stored on the sticky disk. Fetch URLs are rewritten to +// it via git's url..insteadOf mechanism, so an unmodified +// actions/checkout (or any git fetch of a github.com repo) is served from +// local storage, with only new objects crossing the network once per job. +// +// Adapted from github.com/crohr/smart-git-proxy (internal/mirror, +// internal/gitproxy). Protocol serving is delegated to `git http-backend` +// (see cgi.go) instead of a hand-rolled smart-HTTP implementation, and the +// shared-proxy pack cache was dropped: a per-job proxy serves each pack once. +package gitproxy + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// Status indicates what EnsureRepo had to do before a repo could be served. +type Status string + +const ( + StatusHit Status = "mirror-hit" // served from an existing fresh mirror + StatusClone Status = "mirror-clone" // had to clone a new mirror + StatusSync Status = "mirror-sync" // had to sync a stale mirror + StatusStale Status = "mirror-stale" // sync failed, serving stale data +) + +// Mirror manages bare git repository mirrors under a root directory. +type Mirror struct { + root string + staleAfter time.Duration + log *slog.Logger + + group singleflight.Group + maintGroup singleflight.Group + lastSync sync.Map // map[repoKey]time.Time +} + +// NewMirror creates a mirror manager rooted at root. lastSync tracking is +// in-memory only: a proxy process is fresh per job, so the first request for +// each repo always syncs from upstream — guaranteeing that the just-pushed +// SHA a workflow runs against is present in the mirror. +func NewMirror(root string, staleAfter time.Duration, log *slog.Logger) (*Mirror, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create mirror root: %w", err) + } + return &Mirror{root: root, staleAfter: staleAfter, log: log}, nil +} + +// RepoPath returns the filesystem path for a repo mirror. +func (m *Mirror) RepoPath(host, owner, repo string) string { + return filepath.Join(m.root, host, owner, repo+".git") +} + +// EnsureRepo ensures the mirror exists and is fresh enough to serve. +// authHeader is the Authorization header value from the client request (can +// be empty). Returns the path to the bare repo and what had to be done. +func (m *Mirror) EnsureRepo(ctx context.Context, host, owner, repo, upstreamURL, authHeader string) (string, Status, error) { + start := time.Now() + repoPath := m.RepoPath(host, owner, repo) + key := fmt.Sprintf("%s/%s/%s", host, owner, repo) + + // Clone goes through singleflight so a concurrent request never sees a + // half-created directory: it waits for the in-flight clone instead. + result, err, _ := m.group.Do("clone:"+key, func() (interface{}, error) { + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + if err := m.cloneRepo(ctx, repoPath, upstreamURL, authHeader); err != nil { + return StatusClone, err + } + m.lastSync.Store(key, time.Now()) + return StatusClone, nil + } + return StatusHit, nil + }) + if err != nil { + return "", "", err + } + if result.(Status) == StatusClone { + m.log.Info("mirror cloned", "repo", key, "duration_ms", time.Since(start).Milliseconds()) + return repoPath, StatusClone, nil + } + + if m.isStale(key) { + _, err, _ := m.group.Do("sync:"+key, func() (interface{}, error) { + return nil, m.syncRepo(ctx, repoPath, upstreamURL, authHeader) + }) + if err != nil { + // For repos cloned with auth, a sync failure likely means the + // token is bad: surface it rather than serving stale private data. + if m.requiresAuth(repoPath) { + return "", "", fmt.Errorf("authentication required: %w", err) + } + m.log.Warn("sync failed, serving stale", "repo", key, "err", err) + return repoPath, StatusStale, nil + } + m.lastSync.Store(key, time.Now()) + m.scheduleOptimize(repoPath, false) + m.log.Info("mirror synced", "repo", key, "duration_ms", time.Since(start).Milliseconds()) + return repoPath, StatusSync, nil + } + + // Fresh mirror: still validate auth for private repos so an + // unauthenticated client cannot read a previously-mirrored private repo. + if m.requiresAuth(repoPath) { + if err := m.validateAuth(ctx, upstreamURL, authHeader); err != nil { + return "", "", fmt.Errorf("authentication required: %w", err) + } + } + return repoPath, StatusHit, nil +} + +// isStale returns true if the repo needs syncing. +func (m *Mirror) isStale(key string) bool { + lastSync, ok := m.lastSync.Load(key) + if !ok { + return true + } + return time.Since(lastSync.(time.Time)) > m.staleAfter +} + +// requiresAuth checks if a repo was cloned with authentication. +func (m *Mirror) requiresAuth(repoPath string) bool { + _, err := os.Stat(filepath.Join(repoPath, ".requires-auth")) + return err == nil +} + +// validateAuth validates the auth token can access the upstream repo. +func (m *Mirror) validateAuth(ctx context.Context, upstreamURL, authHeader string) error { + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", "-q", upstreamURL, "HEAD") + cmd.Env = gitEnv(authHeader) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git ls-remote failed: %w\noutput: %s", err, output) + } + return nil +} + +// cloneRepo creates a new bare mirror. +func (m *Mirror) cloneRepo(ctx context.Context, repoPath, upstreamURL, authHeader string) error { + m.log.Info("cloning mirror", "path", repoPath, "hasAuth", authHeader != "") + if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil { + return fmt.Errorf("create parent dir: %w", err) + } + + // Disable GC and delta recompression during clone to reduce CPU/memory + // pressure: the received pack is stored as-is, optimizeRepo repacks later. + args := []string{ + "-c", "gc.auto=0", + "-c", "core.compression=0", + "-c", "pack.window=0", + "-c", "pack.depth=0", + "-c", "pack.deltaCacheSize=1", + "-c", "pack.threads=1", + "clone", "--mirror", "--", upstreamURL, repoPath, + } + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = gitEnv(authHeader) + if output, err := cmd.CombinedOutput(); err != nil { + // git may leave a partial directory behind; remove it so the next + // attempt does not mistake it for a valid mirror. + os.RemoveAll(repoPath) + return fmt.Errorf("git clone failed: %w\noutput: %s", err, output) + } + + // actions/checkout fetches exact commit SHAs and sparse checkouts fetch + // with --filter=blob:none; upload-pack rejects both unless the mirror + // repo config allows them. + for _, kv := range [][2]string{ + {"uploadpack.allowAnySHA1InWant", "true"}, + {"uploadpack.allowFilter", "true"}, + } { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "config", kv[0], kv[1]) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git config %s failed: %w\noutput: %s", kv[0], err, output) + } + } + + if authHeader != "" { + if err := os.WriteFile(filepath.Join(repoPath, ".requires-auth"), []byte("1"), 0o644); err != nil { + m.log.Warn("failed to mark repo as requiring auth", "path", repoPath, "err", err) + } + } + + m.scheduleOptimize(repoPath, true) + return nil +} + +// syncRepo fetches updates from upstream. +func (m *Mirror) syncRepo(ctx context.Context, repoPath, upstreamURL, authHeader string) error { + start := time.Now() + args := []string{ + "-C", repoPath, + "-c", "gc.auto=0", + "-c", "core.compression=0", + "-c", "pack.window=0", + "-c", "pack.depth=0", + "-c", "pack.deltaCacheSize=1", + "-c", "pack.threads=1", + "fetch", "--all", "--prune", "--force", + } + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = gitEnv(authHeader) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch failed: %w\noutput: %s", err, output) + } + m.log.Debug("sync complete", "path", repoPath, "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// HasObject reports whether the mirror repo contains the given object. +func (m *Mirror) HasObject(ctx context.Context, repoPath, oid string) bool { + return exec.CommandContext(ctx, "git", "--git-dir", repoPath, "cat-file", "-e", oid).Run() == nil +} + +// optimizeRepo runs maintenance tasks: bitmaps and commit-graphs make +// upload-pack on warm jobs fast for large repos, and persist in the snapshot. +// If full is true (after clone), repack with bitmap; otherwise (after sync) +// only midx+commit-graph. +func (m *Mirror) optimizeRepo(ctx context.Context, repoPath string, full bool) { + // Avoid lock contention if another git process is writing commit-graph. + if _, err := os.Stat(filepath.Join(repoPath, "objects", "info", "commit-graph.lock")); err == nil { + return + } + if full { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "repack", "-a", "-d", "-b", "--write-bitmap-index") + if output, err := cmd.CombinedOutput(); err != nil { + m.log.Warn("git repack failed", "path", repoPath, "err", err, "output", string(output)) + } + } + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "commit-graph", "write", "--reachable") + if output, err := cmd.CombinedOutput(); err != nil { + m.log.Warn("git commit-graph write failed", "path", repoPath, "err", err, "output", string(output)) + } + cmd = exec.CommandContext(ctx, "git", "-C", repoPath, "multi-pack-index", "write", "--bitmap") + if output, err := cmd.CombinedOutput(); err != nil { + m.log.Warn("git multi-pack-index write failed", "path", repoPath, "err", err, "output", string(output)) + } +} + +// scheduleOptimize runs optimizeRepo in the background with a per-repo +// singleflight to avoid concurrent maintenance. +func (m *Mirror) scheduleOptimize(repoPath string, full bool) { + go func() { + m.maintGroup.Do(repoPath, func() (interface{}, error) { + m.optimizeRepo(context.Background(), repoPath, full) + return nil, nil + }) + }() +} + +// gitEnv returns the environment for git commands talking to upstream. The +// global/system config MUST be masked: once the action has installed its +// url.insteadOf rewrites, an upstream fetch reading the runner's global +// config would loop back into this proxy. +func gitEnv(authHeader string) []string { + env := append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + if authHeader != "" { + env = append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.extraheader", + fmt.Sprintf("GIT_CONFIG_VALUE_0=Authorization: %s", authHeader), + ) + } + return env +} diff --git a/internal/gitproxy/serve.go b/internal/gitproxy/serve.go new file mode 100644 index 0000000..0ee22c6 --- /dev/null +++ b/internal/gitproxy/serve.go @@ -0,0 +1,114 @@ +package gitproxy + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" +) + +// Environment variables understood by the --git-proxy-serve entrypoint. +const ( + EnvMirrorDir = "RUNS_ON_GIT_PROXY_MIRROR_DIR" + EnvStateFile = "RUNS_ON_GIT_PROXY_STATE_FILE" + EnvDebug = "RUNS_ON_GIT_PROXY_DEBUG" +) + +// State is what the proxy publishes once it is listening; the action's setup +// step polls the state file, and the post step uses it to find pid and port. +type State struct { + Port int `json:"port"` + PID int `json:"pid"` +} + +// ReadStateFile loads a previously published proxy state. +func ReadStateFile(path string) (State, error) { + var st State + data, err := os.ReadFile(path) + if err != nil { + return st, err + } + if err := json.Unmarshal(data, &st); err != nil { + return st, fmt.Errorf("parse %s: %w", path, err) + } + if st.Port <= 0 { + return st, fmt.Errorf("invalid port %d in %s", st.Port, path) + } + return st, nil +} + +// runFromEnv serves the proxy on an ephemeral localhost port until SIGTERM, +// publishing {port, pid} to the state file once ready. +func runFromEnv(ctx context.Context) error { + mirrorDir := os.Getenv(EnvMirrorDir) + stateFile := os.Getenv(EnvStateFile) + if mirrorDir == "" || stateFile == "" { + return fmt.Errorf("%s and %s must be set", EnvMirrorDir, EnvStateFile) + } + + level := slog.LevelInfo + if os.Getenv(EnvDebug) != "" { + level = slog.LevelDebug + } + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) + + server, err := NewServer(Options{MirrorDir: mirrorDir, Logger: log}) + if err != nil { + return err + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("listen: %w", err) + } + port := ln.Addr().(*net.TCPAddr).Port + + if err := writeStateFile(stateFile, State{Port: port, PID: os.Getpid()}); err != nil { + ln.Close() + return err + } + defer os.Remove(stateFile) + log.Info("git proxy listening", "port", port, "mirrorDir", mirrorDir) + + ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) + defer stop() + + srv := &http.Server{Handler: server.Handler()} + errCh := make(chan error, 1) + go func() { errCh <- srv.Serve(ln) }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + log.Info("git proxy shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) +} + +// writeStateFile publishes the state atomically (temp file + rename) so the +// polling parent never reads a partial file. +func writeStateFile(path string, st State) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.Marshal(st) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/gitproxy/uploadpack.go b/internal/gitproxy/uploadpack.go new file mode 100644 index 0000000..5c3c361 --- /dev/null +++ b/internal/gitproxy/uploadpack.go @@ -0,0 +1,122 @@ +package gitproxy + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "regexp" + "strconv" +) + +// maxParseBody bounds how much of an upload-pack request we buffer for want +// inspection. Checkout-style requests (few wants, no haves) are tiny; bodies +// beyond this are negotiations from clients that already have most objects, +// which the mirror can always serve. +const maxParseBody = 4 << 20 + +var wantRe = regexp.MustCompile(`(?m)^want ([0-9a-f]{40,64})`) + +// handleUploadPack serves a POST git-upload-pack request from the local +// mirror, unless a wanted object is absent (e.g. a SHA orphaned by a +// force-push between the workflow event and now): those requests are +// forwarded upstream so the fetch behaves exactly like a vanilla checkout. +func (s *Server) handleUploadPack(w http.ResponseWriter, r *http.Request, t target) { + repoPath := s.mirror.RepoPath(t.host, t.owner, t.repo) + + body, overflow, err := bufferBody(r.Body, maxParseBody) + if err != nil { + s.log.Error("read upload-pack body failed", "repo", t.repoKey(), "err", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Restore the body for whichever handler serves the request. + restored := io.Reader(bytes.NewReader(body)) + if overflow != nil { + restored = io.MultiReader(restored, overflow) + } + + if overflow == nil { + for _, want := range parseWants(body, r.Header.Get("Content-Encoding")) { + if !s.mirror.HasObject(r.Context(), repoPath, want) { + s.log.Warn("want not in mirror, forwarding upstream", "repo", t.repoKey(), "want", want) + s.forwardUpstream(w, r, t, restored, "missing-want") + return + } + } + } + + req := r.Clone(r.Context()) + req.Body = io.NopCloser(restored) + s.serveCGI(w, req, t.cgiPath("/git-upload-pack")) +} + +// bufferBody reads up to limit bytes. When the body is larger, the buffered +// prefix is returned along with the unread remainder as overflow. +func bufferBody(body io.Reader, limit int) ([]byte, io.Reader, error) { + buf := bytes.NewBuffer(nil) + n, err := io.CopyN(buf, body, int64(limit)+1) + if err != nil && err != io.EOF { + return nil, nil, err + } + if n > int64(limit) { + return buf.Bytes(), body, nil + } + return buf.Bytes(), nil, nil +} + +// parseWants extracts wanted object IDs from an upload-pack request body, +// handling gzip bodies and both protocol v0/v1 and v2 framing. Best-effort: +// an empty result means "serve locally" (e.g. protocol v2 ls-refs commands +// have no wants). +func parseWants(body []byte, contentEncoding string) []string { + if contentEncoding == "gzip" { + gz, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil + } + decoded, err := io.ReadAll(io.LimitReader(gz, maxParseBody)) + if err != nil { + return nil + } + body = decoded + } + var wants []string + for _, match := range wantRe.FindAllSubmatch(pktPayload(body), -1) { + wants = append(wants, string(match[1])) + } + return wants +} + +// pktPayload strips pkt-line framing, concatenating payloads separated by +// newlines. Flush (0000), delim (0001) and response-end (0002) packets are +// skipped — protocol v2 places wants after a delim packet, so treating +// special packets as terminators would hide them. Tolerant to malformed +// input: returns the raw bytes when nothing parses. +func pktPayload(b []byte) []byte { + var out []byte + i := 0 + for i+4 <= len(b) { + n64, err := strconv.ParseInt(string(b[i:i+4]), 16, 32) + if err != nil { + break + } + n := int(n64) + i += 4 + if n < 4 { + continue + } + if i+n-4 > len(b) { + break + } + out = append(out, b[i:i+n-4]...) + if len(out) > 0 && out[len(out)-1] != '\n' { + out = append(out, '\n') + } + i += n - 4 + } + if len(out) == 0 { + return b + } + return out +} diff --git a/internal/stickydisk/apt.go b/internal/stickydisk/apt.go new file mode 100644 index 0000000..d128222 --- /dev/null +++ b/internal/stickydisk/apt.go @@ -0,0 +1,32 @@ +package stickydisk + +import ( + "os" + "os/exec" + "strings" + + "github.com/sethvargo/go-githubactions" +) + +const aptKeepArchivesConfig = `APT::Keep-Downloaded-Packages "true"; +Binary::apt::APT::Keep-Downloaded-Packages "true"; +` + +// configureApt makes the apt archives cache usable across jobs: apt requires +// the partial/ subdir to exist, and default image configs (docker-clean) +// delete downloaded packages after install. +func configureApt(action *githubactions.Action) error { + if err := runLogged(action, "sudo", "mkdir", "-p", "/var/cache/apt/archives/partial"); err != nil { + return err + } + if err := runLogged(action, "sudo", "rm", "-f", "/etc/apt/apt.conf.d/docker-clean"); err != nil { + return err + } + + action.Infof("Writing /etc/apt/apt.conf.d/99runs-on-keep-archives") + cmd := exec.Command("sudo", "tee", "/etc/apt/apt.conf.d/99runs-on-keep-archives") + cmd.Stdin = strings.NewReader(aptKeepArchivesConfig) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/stickydisk/buildkit.go b/internal/stickydisk/buildkit.go new file mode 100644 index 0000000..551ada1 --- /dev/null +++ b/internal/stickydisk/buildkit.go @@ -0,0 +1,176 @@ +package stickydisk + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/sethvargo/go-githubactions" +) + +const ( + buildkitVersion = "v0.31.1" + buildkitSocketDir = "/run/runs-on" + buildkitSocketPath = buildkitSocketDir + "/buildkitd.sock" + buildkitBuilderName = "runs-on" + buildkitLogFile = "/tmp/buildkitd.log" + buildkitStartWait = 30 * time.Second + buildkitStopWait = 20 * time.Second +) + +// buildkitGCConfig bounds the buildkit cache relative to the sticky disk so +// the volume never fills up: GC prunes least-recently-used layers during +// builds once maxUsedSpace/minFreeSpace are crossed. +func buildkitGCConfig() string { + return fmt.Sprintf(`[worker.oci] + gc = true + reservedSpace = "10%%" + maxUsedSpace = "75%%" + minFreeSpace = "%d%%" +`, warnFreePct) +} + +func buildkitTarballURL(version, arch string) string { + return fmt.Sprintf("https://github.com/moby/buildkit/releases/download/%s/buildkit-%s.linux-%s.tar.gz", version, version, arch) +} + +// setupBuildkit runs a dedicated buildkitd daemon whose state lives on the +// sticky disk, and registers it as the current buildx builder. The docker +// daemon is never touched: only buildx builds are accelerated, and images +// need `--load` to end up in the local daemon. +func setupBuildkit(action *githubactions.Action, mountRoot string) (hit bool, err error) { + if err := exec.Command("docker", "buildx", "version").Run(); err != nil { + return false, fmt.Errorf("docker buildx is not available: %w", err) + } + + binDir := filepath.Join(mountRoot, "buildkit", "bin") + stateRoot := filepath.Join(mountRoot, "buildkit", "root") + hit = dirNonEmpty(stateRoot) + + if err := os.MkdirAll(binDir, 0755); err != nil { + return hit, fmt.Errorf("failed to create %s: %w", binDir, err) + } + if err := os.MkdirAll(stateRoot, 0755); err != nil { + return hit, fmt.Errorf("failed to create %s: %w", stateRoot, err) + } + + // Download buildkit binaries once per lineage: they are cached on the + // sticky disk itself, so warm jobs download nothing. + buildkitd := filepath.Join(binDir, "buildkitd") + if _, statErr := os.Stat(buildkitd); os.IsNotExist(statErr) { + if err := downloadBuildkit(action, binDir); err != nil { + return hit, err + } + } else { + action.Infof("Using buildkit binaries cached on sticky disk (%s)", binDir) + } + + if err := runLogged(action, "sudo", "mkdir", "-p", buildkitSocketDir); err != nil { + return hit, err + } + + // GC config sized relative to the volume, regenerated each job so + // threshold changes in the action roll out to existing lineages. + configFile := filepath.Join(mountRoot, "buildkit", "buildkitd.toml") + if err := os.WriteFile(configFile, []byte(buildkitGCConfig()), 0644); err != nil { + return hit, fmt.Errorf("failed to write %s: %w", configFile, err) + } + + // Start buildkitd detached, state on the sticky disk. --group docker + // makes the socket accessible to the runner user (member of docker group). + action.Infof("Starting buildkitd %s (root: %s)", buildkitVersion, stateRoot) + startCmd := fmt.Sprintf("nohup %s --config %s --root %s --addr unix://%s --group docker > %s 2>&1 &", + buildkitd, configFile, stateRoot, buildkitSocketPath, buildkitLogFile) + if err := runLogged(action, "sudo", "sh", "-c", startCmd); err != nil { + return hit, err + } + + if err := waitForBuildkit(buildkitStartWait); err != nil { + if log, readErr := os.ReadFile(buildkitLogFile); readErr == nil { + action.Errorf("buildkitd log:\n%s", tailString(string(log), 2000)) + } + return hit, err + } + + // Register (or replace) the buildx builder and make it the current one. + _ = exec.Command("docker", "buildx", "rm", "--force", buildkitBuilderName).Run() + if err := runLogged(action, "docker", "buildx", "create", + "--name", buildkitBuilderName, + "--driver", "remote", + "--use", + "unix://"+buildkitSocketPath); err != nil { + return hit, err + } + + action.Infof("Buildx builder '%s' ready (remote buildkitd on sticky disk). Use `docker buildx build` (add --load to get images into the local docker daemon).", buildkitBuilderName) + return hit, nil +} + +// downloadBuildkit fetches the pinned buildkit release tarball and extracts +// buildkitd + buildctl into binDir. +func downloadBuildkit(action *githubactions.Action, binDir string) error { + arch := runtime.GOARCH + if arch != "amd64" && arch != "arm64" { + return fmt.Errorf("unsupported architecture for buildkit: %s", arch) + } + url := buildkitTarballURL(buildkitVersion, arch) + tarball := filepath.Join(os.TempDir(), "buildkit.tar.gz") + action.Infof("Downloading %s", url) + if err := runLogged(action, "curl", "-fsSL", "-o", tarball, url); err != nil { + return err + } + defer os.Remove(tarball) + // Tarball layout: bin/buildkitd, bin/buildctl, bin/buildkit-runc, ... + return runLogged(action, "tar", "-xzf", tarball, "-C", binDir, "--strip-components=1", "bin/buildkitd", "bin/buildctl") +} + +// waitForBuildkit polls the buildkitd unix socket until it accepts +// connections or the timeout elapses. +func waitForBuildkit(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + conn, err := net.DialTimeout("unix", buildkitSocketPath, time.Second) + if err == nil { + conn.Close() + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("buildkitd did not become ready within %s (socket: %s)", timeout, buildkitSocketPath) + } + time.Sleep(250 * time.Millisecond) + } +} + +// stopBuildkit gracefully stops buildkitd so its state is consistent before +// the sticky disk is unmounted and snapshotted. +func stopBuildkit(action *githubactions.Action) error { + if exec.Command("pgrep", "-x", "buildkitd").Run() != nil { + return nil + } + action.Infof("Stopping buildkitd before sticky disk snapshot...") + if err := runLogged(action, "sudo", "pkill", "-TERM", "-x", "buildkitd"); err != nil { + return err + } + deadline := time.Now().Add(buildkitStopWait) + for time.Now().Before(deadline) { + if exec.Command("pgrep", "-x", "buildkitd").Run() != nil { + action.Infof("buildkitd stopped.") + return nil + } + time.Sleep(500 * time.Millisecond) + } + action.Warningf("buildkitd did not stop within %s, killing it", buildkitStopWait) + return runLogged(action, "sudo", "pkill", "-KILL", "-x", "buildkitd") +} + +// tailString returns at most the last n bytes of s. +func tailString(s string, n int) string { + if len(s) <= n { + return s + } + return "..." + s[len(s)-n:] +} diff --git a/internal/stickydisk/git.go b/internal/stickydisk/git.go new file mode 100644 index 0000000..9d6c9ab --- /dev/null +++ b/internal/stickydisk/git.go @@ -0,0 +1,237 @@ +package stickydisk + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/runs-on/action/internal/gitproxy" + "github.com/sethvargo/go-githubactions" +) + +const ( + gitProxyStateFile = "/tmp/runs-on/git-proxy/state.json" + gitProxyLogFile = "/tmp/runs-on-git-proxy.log" + gitProxyStartWait = 10 * time.Second + gitProxyStopWait = 10 * time.Second +) + +// gitMirrorDir is where repo mirrors live on the sticky disk, so they are +// snapshotted with the rest of the volume. +func gitMirrorDir(mountRoot string) string { + return filepath.Join(mountRoot, "git", "mirrors") +} + +// setupGit accelerates git fetches (most notably an unmodified +// actions/checkout step running AFTER this action) by starting a local git +// proxy backed by mirrors on the sticky disk, and rewriting github.com fetch +// URLs to it via global git config. Pushes are pinned to upstream, and +// anything the proxy cannot serve (LFS, receive-pack) is forwarded to +// github.com, so nothing breaks when the mirror is cold or unavailable. +func setupGit(action *githubactions.Action, mountRoot string) (hit bool, err error) { + if serverURL := os.Getenv("GITHUB_SERVER_URL"); serverURL != "" && serverURL != "https://github.com" { + action.Infof("git cache mode only supports github.com (GITHUB_SERVER_URL=%s), skipping.", serverURL) + return false, nil + } + + mirrorDir := gitMirrorDir(mountRoot) + if repo := os.Getenv("GITHUB_REPOSITORY"); repo != "" { + hit = dirNonEmpty(filepath.Join(mirrorDir, "github.com", repo+".git")) + } + + state, err := ensureGitProxy(action, mirrorDir) + if err != nil { + return hit, err + } + + // Only rewrite URLs once the proxy is confirmed healthy: a failed setup + // must leave git completely untouched. + if err := configureGitProxyRewrites(action, state.Port); err != nil { + return hit, err + } + + action.Infof("Git mirror proxy ready on 127.0.0.1:%d (mirrors: %s). github.com fetches are served from the sticky disk; run this action BEFORE actions/checkout to accelerate it.", state.Port, mirrorDir) + return hit, nil +} + +// ensureGitProxy starts the proxy (re-executing this binary with the hidden +// --git-proxy-serve flag) unless a previous instance from this job is still +// healthy, and waits for it to publish its state file. +func ensureGitProxy(action *githubactions.Action, mirrorDir string) (gitproxy.State, error) { + if state, err := gitproxy.ReadStateFile(gitProxyStateFile); err == nil && gitProxyHealthy(state.Port) { + action.Infof("Reusing running git proxy on port %d", state.Port) + return state, nil + } + + self, err := os.Executable() + if err != nil { + return gitproxy.State{}, fmt.Errorf("cannot locate action binary: %w", err) + } + + logFile, err := os.OpenFile(gitProxyLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return gitproxy.State{}, fmt.Errorf("cannot open proxy log file: %w", err) + } + defer logFile.Close() + + os.Remove(gitProxyStateFile) + cmd := exec.Command(self, "--git-proxy-serve") + cmd.Env = append(os.Environ(), + gitproxy.EnvMirrorDir+"="+mirrorDir, + gitproxy.EnvStateFile+"="+gitProxyStateFile, + ) + if os.Getenv("RUNNER_DEBUG") == "1" { + cmd.Env = append(cmd.Env, gitproxy.EnvDebug+"=1") + } + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = detachedProcAttr() + if err := cmd.Start(); err != nil { + return gitproxy.State{}, fmt.Errorf("failed to start git proxy: %w", err) + } + // The proxy must outlive this step: it serves fetches for the whole job + // and is stopped by the post step. + if err := cmd.Process.Release(); err != nil { + return gitproxy.State{}, fmt.Errorf("failed to detach git proxy: %w", err) + } + + deadline := time.Now().Add(gitProxyStartWait) + for { + if state, err := gitproxy.ReadStateFile(gitProxyStateFile); err == nil && gitProxyHealthy(state.Port) { + return state, nil + } + if time.Now().After(deadline) { + logProxyTail(action) + return gitproxy.State{}, fmt.Errorf("git proxy did not become ready within %s", gitProxyStartWait) + } + time.Sleep(200 * time.Millisecond) + } +} + +func gitProxyHealthy(port int) bool { + client := http.Client{Timeout: time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", port)) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// configureGitProxyRewrites installs the global git config that routes +// github.com fetches through the proxy: +// - insteadOf rewrites https://github.com/ fetch URLs to the proxy +// (SSH remotes are deliberately left alone: they use their own keys) +// - pushInsteadOf pins pushes to upstream (it takes precedence over +// insteadOf for pushes), so `git push` never touches the proxy and keeps +// using actions/checkout's persisted credential +// - extraheader authenticates requests to the proxy, which forwards the +// header to github.com when syncing mirrors. Needed because after the URL +// rewrite, checkout's own http.https://github.com/.extraheader no longer +// matches the effective URL. +func configureGitProxyRewrites(action *githubactions.Action, port int) error { + proxyBase := fmt.Sprintf("http://127.0.0.1:%d/", port) + if err := gitConfigSet(fmt.Sprintf("url.%sgithub.com/.insteadOf", proxyBase), "https://github.com/"); err != nil { + return err + } + if err := gitConfigSet("url.https://github.com/.pushInsteadOf", "https://github.com/"); err != nil { + return err + } + if token := action.GetInput("token"); token != "" { + b64 := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) + action.AddMask(b64) + if err := gitConfigSet(fmt.Sprintf("http.%s.extraheader", proxyBase), "AUTHORIZATION: basic "+b64); err != nil { + return err + } + } else { + action.Warningf("No token input available: private repo mirroring will not work.") + } + return nil +} + +// gitConfigSet writes a global git config entry. Errors deliberately omit +// the value, which may embed credentials. +func gitConfigSet(key, value string) error { + if output, err := exec.Command("git", "config", "--global", "--replace-all", key, value).CombinedOutput(); err != nil { + return fmt.Errorf("git config %s failed: %w\noutput: %s", key, err, output) + } + return nil +} + +// stopGit removes the URL rewrites then stops the proxy, in that order: git +// config must never point at a dead proxy. Runs in the post step, before the +// runner's job-completed hook unmounts and snapshots the sticky disk. +func stopGit(action *githubactions.Action) error { + var errs []string + + if err := removeGitProxyRewrites(); err != nil { + errs = append(errs, err.Error()) + } + + if state, err := gitproxy.ReadStateFile(gitProxyStateFile); err == nil { + if err := terminateGitProxy(action, state.PID); err != nil { + errs = append(errs, err.Error()) + } + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + logProxyTail(action) + } + if len(errs) > 0 { + return fmt.Errorf("git cache mode cleanup: %s", strings.Join(errs, "; ")) + } + return nil +} + +// removeGitProxyRewrites clears every config entry pointing at the proxy, +// discovered by scanning for the loopback URL rather than trusting the state +// file, plus the push pin. +func removeGitProxyRewrites() error { + out, _ := exec.Command("git", "config", "--global", "--name-only", "--get-regexp", `^(url|http)\.http://127\.0\.0\.1:`).Output() + for _, name := range strings.Fields(string(out)) { + if output, err := exec.Command("git", "config", "--global", "--unset-all", name).CombinedOutput(); err != nil { + return fmt.Errorf("git config --unset-all %s failed: %w\noutput: %s", name, err, output) + } + } + // Exit code 5 means the key was not set; anything else is a real error. + if output, err := exec.Command("git", "config", "--global", "--unset-all", "url.https://github.com/.pushInsteadOf").CombinedOutput(); err != nil { + if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 5 { + return fmt.Errorf("git config --unset-all pushInsteadOf failed: %w\noutput: %s", err, output) + } + } + return nil +} + +// terminateGitProxy asks the proxy to shut down gracefully and escalates to +// SIGKILL if it lingers. +func terminateGitProxy(action *githubactions.Action, pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + if err := signalTerm(proc); err != nil { + // Already gone. + return nil + } + action.Infof("Stopping git proxy (pid %d)...", pid) + deadline := time.Now().Add(gitProxyStopWait) + for time.Now().Before(deadline) { + if !processAlive(proc) { + return nil + } + time.Sleep(200 * time.Millisecond) + } + action.Warningf("git proxy did not stop within %s, killing it", gitProxyStopWait) + return proc.Kill() +} + +func logProxyTail(action *githubactions.Action) { + if log, err := os.ReadFile(gitProxyLogFile); err == nil && len(log) > 0 { + action.Infof("git proxy log:\n%s", tailString(string(log), 4000)) + } +} diff --git a/internal/stickydisk/git_proc_unix.go b/internal/stickydisk/git_proc_unix.go new file mode 100644 index 0000000..2a7a51b --- /dev/null +++ b/internal/stickydisk/git_proc_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package stickydisk + +import ( + "os" + "syscall" +) + +// detachedProcAttr puts the git proxy in its own session so it survives the +// action step's process group. +func detachedProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} + +func signalTerm(proc *os.Process) error { + return proc.Signal(syscall.SIGTERM) +} + +// processAlive probes the process with signal 0. +func processAlive(proc *os.Process) bool { + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/stickydisk/git_proc_windows.go b/internal/stickydisk/git_proc_windows.go new file mode 100644 index 0000000..2b1d6c5 --- /dev/null +++ b/internal/stickydisk/git_proc_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package stickydisk + +import ( + "os" + "syscall" +) + +// The git cache mode is Linux-only (modes with a Setup function are skipped +// on Windows by supportedOn); these stubs only keep the package compiling. + +func detachedProcAttr() *syscall.SysProcAttr { + return nil +} + +func signalTerm(proc *os.Process) error { + return proc.Kill() +} + +func processAlive(proc *os.Process) bool { + return false +} diff --git a/internal/stickydisk/git_test.go b/internal/stickydisk/git_test.go new file mode 100644 index 0000000..669b72a --- /dev/null +++ b/internal/stickydisk/git_test.go @@ -0,0 +1,83 @@ +package stickydisk + +import ( + "encoding/base64" + "os" + "os/exec" + "strings" + "testing" + + "github.com/sethvargo/go-githubactions" +) + +// isolatedGitConfig points the global git config at a temp file so tests +// never touch the developer's real configuration. +func isolatedGitConfig(t *testing.T) string { + t.Helper() + cfg := t.TempDir() + "/gitconfig" + t.Setenv("GIT_CONFIG_GLOBAL", cfg) + t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") + return cfg +} + +func globalConfigNames(t *testing.T) string { + t.Helper() + out, _ := exec.Command("git", "config", "--global", "--list").Output() + return string(out) +} + +func TestConfigureAndRemoveGitProxyRewrites(t *testing.T) { + isolatedGitConfig(t) + t.Setenv("INPUT_TOKEN", "test-token") + action := githubactions.New(githubactions.WithWriter(os.Stderr)) + + if err := configureGitProxyRewrites(action, 8123); err != nil { + t.Fatal(err) + } + + cfg := globalConfigNames(t) + wantB64 := base64.StdEncoding.EncodeToString([]byte("x-access-token:test-token")) + for _, want := range []string{ + "url.http://127.0.0.1:8123/github.com/.insteadof=https://github.com/", + "url.https://github.com/.pushinsteadof=https://github.com/", + "http.http://127.0.0.1:8123/.extraheader=AUTHORIZATION: basic " + wantB64, + } { + if !strings.Contains(cfg, want) { + t.Errorf("global config missing %q, got:\n%s", want, cfg) + } + } + + if err := removeGitProxyRewrites(); err != nil { + t.Fatal(err) + } + cfg = globalConfigNames(t) + for _, gone := range []string{"127.0.0.1", "pushinsteadof"} { + if strings.Contains(cfg, gone) { + t.Errorf("global config still contains %q after cleanup:\n%s", gone, cfg) + } + } +} + +// removeGitProxyRewrites must be a no-op (not an error) when nothing was +// configured: the post step always runs, even after a failed setup. +func TestRemoveGitProxyRewritesIdempotent(t *testing.T) { + isolatedGitConfig(t) + if err := removeGitProxyRewrites(); err != nil { + t.Fatal(err) + } +} + +// A failed or skipped setup must leave the global git config untouched. +func TestSetupGitSkipsGHES(t *testing.T) { + cfgFile := isolatedGitConfig(t) + t.Setenv("GITHUB_SERVER_URL", "https://ghes.example.com") + action := githubactions.New(githubactions.WithWriter(os.Stderr)) + + hit, err := setupGit(action, t.TempDir()) + if err != nil || hit { + t.Fatalf("setupGit = (%v, %v), want (false, nil)", hit, err) + } + if _, err := os.Stat(cfgFile); !os.IsNotExist(err) { + t.Errorf("global git config was written on GHES skip") + } +} diff --git a/internal/stickydisk/modes.go b/internal/stickydisk/modes.go new file mode 100644 index 0000000..5795e7b --- /dev/null +++ b/internal/stickydisk/modes.go @@ -0,0 +1,115 @@ +package stickydisk + +import ( + "sort" + "strings" + + "github.com/sethvargo/go-githubactions" +) + +// CacheMode describes the directories a given package manager expects to be +// persisted. Paths starting with "~/" are relative to the runner user's home +// directory, relative paths are resolved against GITHUB_WORKSPACE, absolute +// paths are used as-is. +type CacheMode struct { + Name string + // Paths to persist on the sticky disk. + Paths []string + // WindowsPaths override Paths on Windows runners, where several package + // managers cache under ~/AppData instead of ~/.cache. Empty means Paths + // apply on Windows too. + WindowsPaths []string + // Root indicates the cached paths are owned by root (e.g. apt). + // Root modes are Linux-only. + Root bool + // Post runs after the paths have been bind-mounted (e.g. apt config fixups). + Post func(action *githubactions.Action) error + // Setup, when set, replaces the Paths bind-mount mechanism entirely: the + // mode owns its whole setup (e.g. buildkit runs a daemon off the disk). + Setup func(action *githubactions.Action, mountRoot string) (hit bool, err error) + // PostJob runs during the action's post step, before the sticky disk is + // unmounted and snapshotted (e.g. stop buildkitd for a consistent state). + PostJob func(action *githubactions.Action) error +} + +var cacheModes = map[string]CacheMode{ + "go": {Name: "go", Paths: []string{"~/.cache/go-build", "~/go/pkg/mod"}, WindowsPaths: []string{"~/AppData/Local/go-build", "~/go/pkg/mod"}}, + "node": {Name: "node", Paths: []string{"~/.npm"}, WindowsPaths: []string{"~/AppData/Local/npm-cache"}}, + "yarn": {Name: "yarn", Paths: []string{"~/.cache/yarn"}, WindowsPaths: []string{"~/AppData/Local/Yarn/Cache"}}, + "pnpm": {Name: "pnpm", Paths: []string{"~/.pnpm-store"}, WindowsPaths: []string{"~/AppData/Local/pnpm/store"}}, + "ruby": {Name: "ruby", Paths: []string{"~/.bundle", "vendor/bundle"}}, + "rust": {Name: "rust", Paths: []string{"~/.cargo/registry", "~/.cargo/git"}}, + "python": {Name: "python", Paths: []string{"~/.cache/pip"}, WindowsPaths: []string{"~/AppData/Local/pip/cache"}}, + "uv": {Name: "uv", Paths: []string{"~/.cache/uv"}, WindowsPaths: []string{"~/AppData/Local/uv/cache"}}, + "poetry": {Name: "poetry", Paths: []string{"~/.cache/pypoetry"}, WindowsPaths: []string{"~/AppData/Local/pypoetry/Cache"}}, + "apt": {Name: "apt", Paths: []string{"/var/cache/apt/archives"}, Root: true, Post: configureApt}, + "buildkit": {Name: "buildkit", Setup: setupBuildkit, PostJob: stopBuildkit}, + "git": {Name: "git", Setup: setupGit, PostJob: stopGit}, + "gradle": {Name: "gradle", Paths: []string{"~/.gradle/caches", "~/.gradle/wrapper"}}, + "maven": {Name: "maven", Paths: []string{"~/.m2/repository"}}, + "playwright": {Name: "playwright", Paths: []string{"~/.cache/ms-playwright"}, WindowsPaths: []string{"~/AppData/Local/ms-playwright"}}, +} + +var modeAliases = map[string]string{ + "golang": "go", + "npm": "node", + "pip": "python", + "bundler": "ruby", + "cargo": "rust", + "buildx": "buildkit", + "checkout": "git", +} + +// pathsFor returns the cache paths for the given OS. +func (m CacheMode) pathsFor(goos string) []string { + if goos == "windows" && len(m.WindowsPaths) > 0 { + return m.WindowsPaths + } + return m.Paths +} + +// supportedOn reports whether the mode works on the given OS: root-owned +// paths (apt) and daemon setups (buildkit) are Linux-only. +func (m CacheMode) supportedOn(goos string) bool { + if goos != "windows" { + return true + } + return !m.Root && m.Setup == nil +} + +// ValidModes returns the sorted list of supported cache mode names. +func ValidModes() []string { + names := make([]string, 0, len(cacheModes)) + for name := range cacheModes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ResolveModes maps user-provided mode names (already split into a list) to +// their canonical CacheMode definitions, deduplicating and preserving order. +// Unknown modes are returned separately so callers can warn about them. +func ResolveModes(inputs []string) (modes []CacheMode, unknown []string) { + seen := map[string]bool{} + for _, input := range inputs { + name := strings.ToLower(strings.TrimSpace(input)) + if name == "" { + continue + } + if canonical, ok := modeAliases[name]; ok { + name = canonical + } + mode, ok := cacheModes[name] + if !ok { + unknown = append(unknown, input) + continue + } + if seen[name] { + continue + } + seen[name] = true + modes = append(modes, mode) + } + return modes, unknown +} diff --git a/internal/stickydisk/mount.go b/internal/stickydisk/mount.go new file mode 100644 index 0000000..6625aba --- /dev/null +++ b/internal/stickydisk/mount.go @@ -0,0 +1,57 @@ +package stickydisk + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/sethvargo/go-githubactions" +) + +var unsafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +// resolveTarget expands "~/" against the given home directory and resolves +// relative paths against the workspace directory. +func resolveTarget(target, home, workspace string) string { + switch { + case target == "~": + return home + case strings.HasPrefix(target, "~/"): + return filepath.Join(home, target[2:]) + case filepath.IsAbs(target): + return filepath.Clean(target) + default: + return filepath.Join(workspace, target) + } +} + +// sourceDirName returns a readable, collision-safe directory name on the +// sticky disk for the given resolved target path. +func sourceDirName(target string) string { + sanitized := unsafeChars.ReplaceAllString(strings.Trim(target, "/"), "-") + if len(sanitized) > 60 { + sanitized = sanitized[len(sanitized)-60:] + } + sum := sha256.Sum256([]byte(target)) + return fmt.Sprintf("%s-%s", strings.Trim(sanitized, "-"), hex.EncodeToString(sum[:])[:8]) +} + +// dirNonEmpty reports whether the given directory exists and contains entries. +func dirNonEmpty(path string) bool { + entries, err := os.ReadDir(path) + return err == nil && len(entries) > 0 +} + +func runLogged(action *githubactions.Action, name string, args ...string) error { + action.Infof("Running: %s %s", name, strings.Join(args, " ")) + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s failed: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/stickydisk/mount_unix.go b/internal/stickydisk/mount_unix.go new file mode 100644 index 0000000..9319cf0 --- /dev/null +++ b/internal/stickydisk/mount_unix.go @@ -0,0 +1,92 @@ +//go:build !windows + +package stickydisk + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/sethvargo/go-githubactions" +) + +// isMountpoint reports whether the given path is already a mountpoint. +func isMountpoint(path string) bool { + return exec.Command("mountpoint", "-q", path).Run() == nil +} + +// cacheMount persists target on the sticky disk by bind-mounting a +// runner-owned (or root-owned) directory from the volume onto it. +// The returned hit indicates whether the source directory already had content +// (i.e. was restored from a previous snapshot). +func cacheMount(action *githubactions.Action, mountRoot, target string, rootOwned bool) (hit bool, err error) { + src := filepath.Join(mountRoot, "mounts", sourceDirName(target)) + hit = dirNonEmpty(src) + + if isMountpoint(target) { + action.Warningf("Path %s is already a mountpoint, skipping", target) + return hit, nil + } + + // The sticky disk root is owned by the runner user, so no sudo needed here. + if err := os.MkdirAll(src, 0755); err != nil { + return hit, fmt.Errorf("failed to create source dir %s: %w", src, err) + } + if rootOwned { + if err := runLogged(action, "sudo", "chown", "root:root", src); err != nil { + return hit, err + } + } + + // Create the target directory. Try without sudo first (home/workspace + // paths), falling back to sudo for system paths. Track the topmost + // missing ancestor so ownership can be fixed on everything we create. + if _, statErr := os.Stat(target); os.IsNotExist(statErr) { + firstMissing := target + for { + parent := filepath.Dir(firstMissing) + if parent == firstMissing { + break + } + if _, err := os.Stat(parent); err == nil { + break + } + firstMissing = parent + } + if err := os.MkdirAll(target, 0755); err != nil { + if err := runLogged(action, "sudo", "mkdir", "-p", target); err != nil { + return hit, err + } + if !rootOwned { + owner := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + if err := runLogged(action, "sudo", "chown", "-R", owner, firstMissing); err != nil { + return hit, err + } + } + } + } + + if err := runLogged(action, "sudo", "mount", "--bind", src, target); err != nil { + return hit, err + } + + return hit, nil +} + +// removeCacheDir deletes a cache directory on the sticky disk. Contents may be +// root-owned (apt mode), hence sudo. +func removeCacheDir(action *githubactions.Action, dir string) error { + return runLogged(action, "sudo", "rm", "-rf", dir) +} + +// duCacheDirs returns a du -sh breakdown of the given directories. Some +// contents may be root-owned (apt mode), hence sudo. +func duCacheDirs(targets []string) (string, error) { + out, err := exec.Command("sudo", append([]string{"du", "-sh"}, targets...)...).CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/stickydisk/mount_windows.go b/internal/stickydisk/mount_windows.go new file mode 100644 index 0000000..eb2a45b --- /dev/null +++ b/internal/stickydisk/mount_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package stickydisk + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/sethvargo/go-githubactions" +) + +// cacheMount persists target on the sticky disk with an NTFS junction from +// target to a directory on the volume (junctions work across volumes and need +// no admin rights, unlike directory symlinks). The returned hit indicates +// whether the source directory already had content (i.e. was restored from a +// previous snapshot). +// +// Unlike a bind mount, a junction cannot shadow an existing directory: a +// pre-existing target is moved aside (content preserved) before linking. +func cacheMount(action *githubactions.Action, mountRoot, target string, rootOwned bool) (hit bool, err error) { + _ = rootOwned // no root-owned cache modes on Windows + + src := filepath.Join(mountRoot, "mounts", sourceDirName(target)) + hit = dirNonEmpty(src) + + if err := os.MkdirAll(src, 0o755); err != nil { + return hit, fmt.Errorf("failed to create source dir %s: %w", src, err) + } + + if fi, statErr := os.Lstat(target); statErr == nil { + if fi.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + // Stale junction from a previous job on a reused instance (its + // sticky disk is gone): replace it. + if err := os.Remove(target); err != nil { + return hit, fmt.Errorf("failed to remove existing link %s: %w", target, err) + } + } else { + backup := strings.TrimRight(target, `\/`) + ".before-stickydisk" + _ = os.RemoveAll(backup) + if err := os.Rename(target, backup); err != nil { + return hit, fmt.Errorf("failed to move existing %s aside: %w", target, err) + } + action.Infof("Moved existing %s to %s", target, backup) + } + } else if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return hit, fmt.Errorf("failed to create parent of %s: %w", target, err) + } + + if err := runLogged(action, "cmd", "/c", "mklink", "/J", target, src); err != nil { + return hit, err + } + return hit, nil +} + +// removeCacheDir deletes a cache directory on the sticky disk. Everything on +// the volume is runner-owned on Windows. +func removeCacheDir(_ *githubactions.Action, dir string) error { + return os.RemoveAll(dir) +} + +// duCacheDirs returns a du -sh style breakdown of the given directories, +// computed in-process (no du on Windows). +func duCacheDirs(targets []string) (string, error) { + var b strings.Builder + for _, target := range targets { + fmt.Fprintf(&b, "%s\t%s\n", humanBytes(dirSizeBytes(target)), target) + } + return strings.TrimSpace(b.String()), nil +} + +func dirSizeBytes(dir string) uint64 { + var total uint64 + _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, err := d.Info(); err == nil { + total += uint64(info.Size()) + } + return nil + }) + return total +} + +func humanBytes(n uint64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fG", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fM", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fK", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%dB", n) + } +} diff --git a/internal/stickydisk/pressure.go b/internal/stickydisk/pressure.go new file mode 100644 index 0000000..9e39f98 --- /dev/null +++ b/internal/stickydisk/pressure.go @@ -0,0 +1,120 @@ +package stickydisk + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sethvargo/go-githubactions" +) + +const ( + warnFreePct = 20 + criticalFreePct = 5 + warnFreeInodePct = 10 + criticalFreeInodePct = 5 +) + +type diskStats struct { + totalBytes uint64 + freeBytes uint64 + totalInodes uint64 + freeInodes uint64 +} + +func (d diskStats) freePct() float64 { + if d.totalBytes == 0 { + return 100 + } + return float64(d.freeBytes) / float64(d.totalBytes) * 100 +} + +func (d diskStats) freeInodePct() float64 { + if d.totalInodes == 0 { + return 100 + } + return float64(d.freeInodes) / float64(d.totalInodes) * 100 +} + +func (d diskStats) critical() bool { + return d.freePct() < criticalFreePct || d.freeInodePct() < criticalFreeInodePct +} + +func (d diskStats) underPressure() bool { + return d.freePct() < warnFreePct || d.freeInodePct() < warnFreeInodePct +} + +func (d diskStats) String() string { + usedGB := float64(d.totalBytes-d.freeBytes) / (1 << 30) + totalGB := float64(d.totalBytes) / (1 << 30) + return fmt.Sprintf("%.1f/%.1fGB used, %.1f%% free, %.1f%% inodes free", usedGB, totalGB, d.freePct(), d.freeInodePct()) +} + +// checkCritical resets all caches on the sticky disk when the volume is +// critically full (space or inodes). Restoring a snapshot of a ~full volume +// would otherwise make every subsequent build fail with ENOSPC: wiping the +// caches lets this job run cold and the next snapshot start clean. +// Must run before cache-hit detection so wiped modes report a miss. +func checkCritical(action *githubactions.Action, mountRoot string) { + stats, err := statDisk(mountRoot) + if err != nil { + action.Warningf("Could not check sticky disk usage: %v", err) + return + } + if !stats.critical() { + return + } + action.Warningf("Sticky disk is critically full (%s): resetting all caches on the volume so this and future jobs can run. Consider a larger snap= size.", stats) + // buildkit/bin is kept: it is small and saves a re-download. + for _, dir := range []string{filepath.Join(mountRoot, "mounts"), filepath.Join(mountRoot, "buildkit", "root"), gitMirrorDir(mountRoot)} { + if _, err := os.Stat(dir); os.IsNotExist(err) { + continue + } + if err := removeCacheDir(action, dir); err != nil { + action.Warningf("Failed to reset %s: %v", dir, err) + } + } +} + +// warnOnPressure logs a usage summary in the post step and emits a warning +// plus a job summary when the volume is running low on space or inodes. +func warnOnPressure(action *githubactions.Action, mountRoot string) { + stats, err := statDisk(mountRoot) + if err != nil { + action.Warningf("Could not check sticky disk usage: %v", err) + return + } + action.Infof("Sticky disk usage: %s", stats) + if !stats.underPressure() { + return + } + + breakdown := usageBreakdown(mountRoot) + action.Warningf("Sticky disk is running low on space (%s). The volume is snapshotted as-is: increase the snap= label size (e.g. snap=40gb) or reduce cache usage, otherwise caches will be automatically reset once the volume is critically full.", stats) + summary := fmt.Sprintf("### :warning: Sticky disk cache almost full\n\n%s\n\n```\n%s\n```\n\nIncrease the `snap=` label size (e.g. `snap=40gb`) or reduce cache usage. Caches are automatically reset when the volume becomes critically full (<%d%% free).\n", stats, breakdown, criticalFreePct) + action.AddStepSummary(summary) +} + +// usageBreakdown returns a du -sh style breakdown of the cache directories. +func usageBreakdown(mountRoot string) string { + var targets []string + if entries, err := os.ReadDir(filepath.Join(mountRoot, "mounts")); err == nil { + for _, entry := range entries { + targets = append(targets, filepath.Join(mountRoot, "mounts", entry.Name())) + } + } + if _, err := os.Stat(filepath.Join(mountRoot, "buildkit", "root")); err == nil { + targets = append(targets, filepath.Join(mountRoot, "buildkit", "root")) + } + if _, err := os.Stat(gitMirrorDir(mountRoot)); err == nil { + targets = append(targets, gitMirrorDir(mountRoot)) + } + if len(targets) == 0 { + return "(no cache directories)" + } + out, err := duCacheDirs(targets) + if err != nil { + return fmt.Sprintf("(du failed: %v)", err) + } + return out +} diff --git a/internal/stickydisk/pressure_unix.go b/internal/stickydisk/pressure_unix.go new file mode 100644 index 0000000..2dedf79 --- /dev/null +++ b/internal/stickydisk/pressure_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package stickydisk + +import ( + "fmt" + "syscall" +) + +func statDisk(path string) (diskStats, error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return diskStats{}, fmt.Errorf("statfs %s: %w", path, err) + } + return diskStats{ + totalBytes: st.Blocks * uint64(st.Bsize), + freeBytes: st.Bavail * uint64(st.Bsize), + totalInodes: st.Files, + freeInodes: st.Ffree, + }, nil +} diff --git a/internal/stickydisk/pressure_windows.go b/internal/stickydisk/pressure_windows.go new file mode 100644 index 0000000..bdae6fd --- /dev/null +++ b/internal/stickydisk/pressure_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package stickydisk + +import ( + "fmt" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW") +) + +func statDisk(path string) (diskStats, error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return diskStats{}, err + } + var freeAvailable, total, totalFree uint64 + ret, _, callErr := procGetDiskFreeSpaceExW.Call( + uintptr(unsafe.Pointer(p)), + uintptr(unsafe.Pointer(&freeAvailable)), + uintptr(unsafe.Pointer(&total)), + uintptr(unsafe.Pointer(&totalFree)), + ) + if ret == 0 { + return diskStats{}, fmt.Errorf("GetDiskFreeSpaceEx %s: %w", path, callErr) + } + // NTFS has no fixed inode table; zero totals read as 100%% free inodes. + return diskStats{totalBytes: total, freeBytes: freeAvailable}, nil +} diff --git a/internal/stickydisk/stickydisk.go b/internal/stickydisk/stickydisk.go new file mode 100644 index 0000000..ec55fbc --- /dev/null +++ b/internal/stickydisk/stickydisk.go @@ -0,0 +1,260 @@ +package stickydisk + +import ( + "fmt" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sethvargo/go-githubactions" +) + +const ( + defaultWaitTimeout = 5 * time.Minute + readyPollInterval = 500 * time.Millisecond +) + +// defaultReadyFile mirrors the agent's STICKYDISK_READY_FILE; only a fallback, +// the agent publishes RUNS_ON_STICKYDISK_READY_FILE. +func defaultReadyFile() string { + if runtime.GOOS == "windows" { + return `C:\runs-on\stickydisk.ready` + } + return "/runs-on/stickydisk.ready" +} + +func supportedOS() bool { + return runtime.GOOS == "linux" || runtime.GOOS == "windows" +} + +// Options configures the sticky disk cache setup. +type Options struct { + // Modes is the list of cache modes from the `cache` input. + Modes []string + // Paths is the list of arbitrary paths from the `path` input. + Paths []string + // WaitTimeout bounds how long to wait for the sticky disk to be ready. + WaitTimeout time.Duration + // FailOnMissing makes the action fail when no sticky disk is available. + FailOnMissing bool +} + +type mountResult struct { + Target string + Hit bool + Err error +} + +// Configure bind-mounts the requested cache directories onto the job's sticky +// disk. It requires a `snap=[:]` label on the job. Mount failures +// are reported as warnings; only a missing/unready sticky disk combined with +// FailOnMissing returns an error. +func Configure(action *githubactions.Action, opts Options) error { + if !supportedOS() { + action.Warningf("Sticky disk cache is only supported on Linux and Windows runners, skipping.") + action.SetOutput("cache-hit", "false") + return nil + } + + readyFile := os.Getenv("RUNS_ON_STICKYDISK_READY_FILE") + if readyFile == "" { + readyFile = defaultReadyFile() + } + + if os.Getenv("RUNS_ON_STICKYDISK_DIR") == "" { + if _, err := os.Stat(readyFile); os.IsNotExist(err) { + return missing(action, opts, "No sticky disk detected. Add a snap=[:] label to your runs-on labels to enable the cache.") + } + } + + timeout := opts.WaitTimeout + if timeout <= 0 { + timeout = defaultWaitTimeout + } + mountRoot, err := waitForReady(action, readyFile, timeout) + if err != nil { + return missing(action, opts, err.Error()) + } + action.Infof("Sticky disk ready at %s (name: %s)", mountRoot, os.Getenv("RUNS_ON_STICKYDISK_NAME")) + + // Self-heal a critically full volume before cache-hit detection: wiped + // caches report a miss and the next snapshot starts clean. + checkCritical(action, mountRoot) + + home, err := os.UserHomeDir() + if err != nil { + if runtime.GOOS == "windows" { + home = `C:\Users\runner` + } else { + home = "/home/runner" + } + } + workspace := os.Getenv("GITHUB_WORKSPACE") + if workspace == "" { + workspace, _ = os.Getwd() + } + + modes, unknown := ResolveModes(opts.Modes) + for _, name := range unknown { + action.Warningf("Unknown cache mode '%s'. Valid modes: %s", name, strings.Join(ValidModes(), ", ")) + } + + // Resolve all targets, deduplicating by absolute path. + type mountSpec struct { + target string + root bool + } + var specs []mountSpec + var posts []func(action *githubactions.Action) error + var results []mountResult + seen := map[string]bool{} + addTarget := func(path string, root bool) { + resolved := resolveTarget(path, home, workspace) + if seen[resolved] { + return + } + seen[resolved] = true + specs = append(specs, mountSpec{target: resolved, root: root}) + } + for _, mode := range modes { + if !mode.supportedOn(runtime.GOOS) { + action.Warningf("Cache mode '%s' is not supported on Windows runners, skipping.", mode.Name) + continue + } + // Modes with a Setup function own their whole setup (no bind mounts). + if mode.Setup != nil { + hit, err := mode.Setup(action, mountRoot) + if err != nil { + action.Warningf("Failed to set up %s cache: %v", mode.Name, err) + } + results = append(results, mountResult{Target: mode.Name, Hit: hit && err == nil, Err: err}) + continue + } + for _, path := range mode.pathsFor(runtime.GOOS) { + addTarget(path, mode.Root) + } + if mode.Post != nil { + posts = append(posts, mode.Post) + } + } + for _, path := range opts.Paths { + if strings.TrimSpace(path) == "" { + continue + } + addTarget(strings.TrimSpace(path), false) + } + + if len(specs) == 0 && len(results) == 0 { + action.Warningf("No cache paths to set up.") + action.SetOutput("cache-hit", "false") + return nil + } + + for _, spec := range specs { + hit, err := cacheMount(action, mountRoot, spec.target, spec.root) + if err != nil { + action.Warningf("Failed to set up cache for %s: %v", spec.target, err) + } + results = append(results, mountResult{Target: spec.target, Hit: hit && err == nil, Err: err}) + } + + for _, post := range posts { + if err := post(action); err != nil { + action.Warningf("Cache post-setup step failed: %v", err) + } + } + + allHit := true + action.Infof("Sticky disk cache summary:") + for _, res := range results { + status := "restored" + if res.Err != nil { + status = "error" + } else if !res.Hit { + status = "empty" + } + if !res.Hit { + allHit = false + } + action.Infof(" %-10s %s", status, res.Target) + } + action.SetOutput("cache-hit", strconv.FormatBool(allHit)) + return nil +} + +// missing handles the no-sticky-disk case: a warning by default, an error +// when FailOnMissing is set. +func missing(action *githubactions.Action, opts Options, msg string) error { + action.SetOutput("cache-hit", "false") + if opts.FailOnMissing { + return fmt.Errorf("%s", msg) + } + action.Warningf("%s Continuing without cache.", msg) + return nil +} + +// waitForReady polls the ready file until it exists or the timeout elapses, +// returning the sticky disk mount root. +func waitForReady(action *githubactions.Action, readyFile string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + logged := false + for { + content, err := os.ReadFile(readyFile) + if err == nil { + mountRoot := strings.TrimSpace(string(content)) + if mountRoot == "" { + mountRoot = os.Getenv("RUNS_ON_STICKYDISK_DIR") + } + if mountRoot == "" { + return "", fmt.Errorf("sticky disk ready file %s is empty and RUNS_ON_STICKYDISK_DIR is not set", readyFile) + } + return mountRoot, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf("sticky disk was not ready after %s (ready file: %s)", timeout, readyFile) + } + if !logged { + action.Infof("Waiting up to %s for sticky disk to be ready...", timeout) + logged = true + } + time.Sleep(readyPollInterval) + } +} + +// PostJob runs the post-step hooks of the requested cache modes (e.g. stop +// buildkitd), before the sticky disk is unmounted and snapshotted by the +// runner's job-completed hook. +func PostJob(action *githubactions.Action, modeNames []string) { + if !supportedOS() { + return + } + modes, _ := ResolveModes(modeNames) + for _, mode := range modes { + if mode.PostJob == nil || !mode.supportedOn(runtime.GOOS) { + continue + } + if err := mode.PostJob(action); err != nil { + action.Warningf("Post-job hook for %s cache failed: %v", mode.Name, err) + } + } +} + +// DisplayUsage prints sticky disk timings and per-mount disk usage. Intended +// for the post-execution phase, while the volume is still mounted. +func DisplayUsage(action *githubactions.Action) { + if timingsFile := os.Getenv("RUNS_ON_STICKYDISK_TIMINGS_FILE"); timingsFile != "" { + if content, err := os.ReadFile(timingsFile); err == nil { + action.Infof("Sticky disk timings: %s", strings.TrimSpace(string(content))) + } + } + mountRoot := os.Getenv("RUNS_ON_STICKYDISK_DIR") + if mountRoot == "" { + return + } + if _, err := os.Stat(mountRoot); err != nil { + return + } + warnOnPressure(action, mountRoot) +} diff --git a/internal/stickydisk/stickydisk_test.go b/internal/stickydisk/stickydisk_test.go new file mode 100644 index 0000000..4a2ab4f --- /dev/null +++ b/internal/stickydisk/stickydisk_test.go @@ -0,0 +1,172 @@ +package stickydisk + +import ( + "strings" + "testing" +) + +func TestResolveModes(t *testing.T) { + modes, unknown := ResolveModes([]string{"go", "NPM", " ruby ", "bogus", "golang", ""}) + if len(unknown) != 1 || unknown[0] != "bogus" { + t.Errorf("expected unknown [bogus], got %v", unknown) + } + var names []string + for _, m := range modes { + names = append(names, m.Name) + } + // golang is an alias of go, already seen: deduplicated + expected := []string{"go", "node", "ruby"} + if strings.Join(names, ",") != strings.Join(expected, ",") { + t.Errorf("expected modes %v, got %v", expected, names) + } +} + +func TestResolveTarget(t *testing.T) { + home := "/home/runner" + workspace := "/home/runner/work/repo/repo" + tests := []struct { + input string + expected string + }{ + {"~/.npm", "/home/runner/.npm"}, + {"~", "/home/runner"}, + {"/var/cache/apt/archives", "/var/cache/apt/archives"}, + {"vendor/bundle", "/home/runner/work/repo/repo/vendor/bundle"}, + {"~/go/pkg/mod", "/home/runner/go/pkg/mod"}, + } + for _, tt := range tests { + if got := resolveTarget(tt.input, home, workspace); got != tt.expected { + t.Errorf("resolveTarget(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestSourceDirName(t *testing.T) { + a := sourceDirName("/home/runner/.cache/go-build") + b := sourceDirName("/home/runner/.cache/go_build") + if a == b { + t.Errorf("expected distinct names for distinct paths, got %q for both", a) + } + if !strings.HasPrefix(a, "home-runner-.cache-go-build-") { + t.Errorf("expected readable prefix, got %q", a) + } + if a != sourceDirName("/home/runner/.cache/go-build") { + t.Errorf("expected deterministic name") + } + // long paths keep a bounded name + long := sourceDirName("/" + strings.Repeat("a/", 100) + "leaf") + if len(long) > 70 { + t.Errorf("expected bounded length, got %d chars: %q", len(long), long) + } +} + +func TestBuildkitMode(t *testing.T) { + for _, name := range []string{"buildkit", "buildx"} { + modes, unknown := ResolveModes([]string{name}) + if len(unknown) != 0 || len(modes) != 1 { + t.Fatalf("expected %s to resolve to one mode, got modes=%v unknown=%v", name, modes, unknown) + } + mode := modes[0] + if mode.Name != "buildkit" { + t.Errorf("expected canonical name buildkit, got %s", mode.Name) + } + if mode.Setup == nil || mode.PostJob == nil { + t.Errorf("expected buildkit mode to define Setup and PostJob") + } + if len(mode.Paths) != 0 { + t.Errorf("expected buildkit mode to have no bind-mount paths, got %v", mode.Paths) + } + } +} + +func TestBuildkitTarballURL(t *testing.T) { + url := buildkitTarballURL("v0.31.1", "arm64") + expected := "https://github.com/moby/buildkit/releases/download/v0.31.1/buildkit-v0.31.1.linux-arm64.tar.gz" + if url != expected { + t.Errorf("got %q, want %q", url, expected) + } +} + +func TestDiskStatsThresholds(t *testing.T) { + gb := uint64(1 << 30) + tests := []struct { + name string + stats diskStats + pressure bool + critical bool + }{ + {"plenty free", diskStats{totalBytes: 100 * gb, freeBytes: 50 * gb, totalInodes: 1000, freeInodes: 900}, false, false}, + {"low space", diskStats{totalBytes: 100 * gb, freeBytes: 15 * gb, totalInodes: 1000, freeInodes: 900}, true, false}, + {"critical space", diskStats{totalBytes: 100 * gb, freeBytes: 3 * gb, totalInodes: 1000, freeInodes: 900}, true, true}, + {"low inodes only", diskStats{totalBytes: 100 * gb, freeBytes: 50 * gb, totalInodes: 1000, freeInodes: 80}, true, false}, + {"critical inodes only", diskStats{totalBytes: 100 * gb, freeBytes: 50 * gb, totalInodes: 1000, freeInodes: 30}, true, true}, + {"empty stats", diskStats{}, false, false}, + } + for _, tt := range tests { + if got := tt.stats.underPressure(); got != tt.pressure { + t.Errorf("%s: underPressure() = %v, want %v", tt.name, got, tt.pressure) + } + if got := tt.stats.critical(); got != tt.critical { + t.Errorf("%s: critical() = %v, want %v", tt.name, got, tt.critical) + } + } +} + +func TestStatDiskSmoke(t *testing.T) { + stats, err := statDisk(t.TempDir()) + if err != nil { + t.Fatalf("statDisk failed: %v", err) + } + if stats.totalBytes == 0 { + t.Errorf("expected non-zero totalBytes") + } +} + +func TestBuildkitGCConfig(t *testing.T) { + config := buildkitGCConfig() + for _, expected := range []string{"[worker.oci]", "gc = true", `maxUsedSpace = "75%"`, `minFreeSpace = "20%"`} { + if !strings.Contains(config, expected) { + t.Errorf("expected %q in config:\n%s", expected, config) + } + } +} + +func TestValidModesContainsCore(t *testing.T) { + valid := strings.Join(ValidModes(), ",") + for _, name := range []string{"go", "node", "ruby", "rust", "python", "apt"} { + if !strings.Contains(valid, name) { + t.Errorf("expected %s in valid modes, got %s", name, valid) + } + } +} + +func TestModePathsForWindows(t *testing.T) { + golang := cacheModes["go"] + linuxPaths := strings.Join(golang.pathsFor("linux"), ",") + if !strings.Contains(linuxPaths, "~/.cache/go-build") { + t.Errorf("unexpected linux paths: %s", linuxPaths) + } + winPaths := strings.Join(golang.pathsFor("windows"), ",") + if !strings.Contains(winPaths, "~/AppData/Local/go-build") || !strings.Contains(winPaths, "~/go/pkg/mod") { + t.Errorf("unexpected windows paths: %s", winPaths) + } + // Modes without WindowsPaths keep their default paths on Windows. + rust := cacheModes["rust"] + if strings.Join(rust.pathsFor("windows"), ",") != strings.Join(rust.Paths, ",") { + t.Errorf("rust paths should be identical on windows") + } +} + +func TestModeSupportedOn(t *testing.T) { + for _, name := range []string{"apt", "buildkit"} { + if cacheModes[name].supportedOn("windows") { + t.Errorf("%s must not be supported on windows", name) + } + if !cacheModes[name].supportedOn("linux") { + t.Errorf("%s must be supported on linux", name) + } + } + if !cacheModes["go"].supportedOn("windows") { + t.Error("go mode must be supported on windows") + } +} diff --git a/main-linux-amd64 b/main-linux-amd64 index d482fda..00eb14f 100755 Binary files a/main-linux-amd64 and b/main-linux-amd64 differ diff --git a/main-linux-arm64 b/main-linux-arm64 index 33592a6..adb8242 100755 Binary files a/main-linux-arm64 and b/main-linux-arm64 differ diff --git a/main-windows-amd64.exe b/main-windows-amd64.exe index 69ea072..0d9ce50 100755 Binary files a/main-windows-amd64.exe and b/main-windows-amd64.exe differ diff --git a/main.go b/main.go index 9807287..95453a6 100644 --- a/main.go +++ b/main.go @@ -3,13 +3,17 @@ package main import ( "context" "flag" + "fmt" + "os" "github.com/runs-on/action/internal/cache" "github.com/runs-on/action/internal/config" "github.com/runs-on/action/internal/costs" "github.com/runs-on/action/internal/env" + "github.com/runs-on/action/internal/gitproxy" "github.com/runs-on/action/internal/monitoring" "github.com/runs-on/action/internal/sccache" + "github.com/runs-on/action/internal/stickydisk" "github.com/sethvargo/go-githubactions" ) @@ -38,6 +42,18 @@ func handleMainExecution(action *githubactions.Action, ctx context.Context) { } } + // Configure sticky disk cache mounts if requested + if cfg.HasStickyDiskCache() { + if err := stickydisk.Configure(action, stickydisk.Options{ + Modes: cfg.Cache, + Paths: cfg.CachePaths, + WaitTimeout: cfg.CacheWaitTimeout, + FailOnMissing: cfg.CacheFailOnMissing, + }); err != nil { + action.Fatalf("Failed to configure sticky disk cache: %v", err) + } + } + // Configure CloudWatch metrics if requested if cfg.HasMetrics() { if err := monitoring.GenerateCloudWatchConfig(action, cfg.Metrics, cfg.NetworkInterface, cfg.DiskDevice); err != nil { @@ -71,14 +87,30 @@ func handlePostExecution(action *githubactions.Action, ctx context.Context) { monitoring.GenerateMetricsSummary(action, cfg.Metrics, "chart", cfg.NetworkInterface, cfg.DiskDevice) } + // Run cache mode post-job hooks (e.g. stop buildkitd) before the sticky + // disk is unmounted and snapshotted, then display usage + if cfg.HasStickyDiskCache() { + stickydisk.PostJob(action, cfg.Cache) + stickydisk.DisplayUsage(action) + } + action.Infof("Post-execution phase finished.") } func main() { ctx := context.Background() postFlag := flag.Bool("post", false, "Indicates the post-execution phase") + gitProxyFlag := flag.Bool("git-proxy-serve", false, "Run the local git mirror proxy (internal, spawned by the git cache mode)") flag.Parse() + if *gitProxyFlag { + if err := gitproxy.RunFromEnv(ctx); err != nil { + fmt.Fprintf(os.Stderr, "git proxy failed: %v\n", err) + os.Exit(1) + } + return + } + action := githubactions.New() if *postFlag {