···183183 SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11240"
184184 SPINDLE_S3_LOG_BUCKET: ""
185185 SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS: "false"
186186+ SPINDLE_CACHE_BACKEND: disk
186187 # route guest nix substitution + uploads through the local ncps cache.
187188 # ncps re-signs on serve with cache.local's key, so the guest trusts the
188189 # matching public key below (no signing happens in spindle itself).
+76
docs/DOCS.md
···965965- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source
966966 branch
967967968968+### Cache
969969+970970+The `cache` field lets a workflow persist directories across
971971+pipeline runs. Before the first step, the engine looks up
972972+each entry's key and extracts the matching archive into the
973973+workspace; after all steps succeed, the paths are archived
974974+again and stored back under the key.
975975+976976+- `key`: name this cache is saved under. Keys are scoped to
977977+ the repository and the engine specified.
978978+- `hash`: **optional** list of repo files (lockfiles,
979979+ manifests) whose content is folded into the key. The entry
980980+ is stored as `<key>-<hash of those files>`, so editing
981981+ `go.sum` automatically rotates the cache without bumping
982982+ the key by hand. Paths are relative to the repository
983983+ root and are read from git at the commit being built.
984984+- `paths`: paths to archive. Relative paths are anchored at
985985+ the repository checkout, the directory steps start in
986986+ (`/workspace/repo` on microvm, `/tangled/workspace` on
987987+ nixery). Absolute paths work too, for caching directories
988988+ outside the checkout, but note they name engine-specific
989989+ locations. All paths must be writable by the CI user and
990990+ contain no spaces.
991991+- `compression-level`: **optional** zstd level, `1`
992992+ (fastest) to `19` (smallest). defaults to (`5`).
993993+- `when`: **optional** save policy: `on-success` (the
994994+ default) or `always`.
995995+996996+When the exact key (or generation, with `hash`) misses, the
997997+newest older generation under the same key is restored.
998998+999999+```yaml
10001000+cache:
10011001+ - key: go-mod
10021002+ hash:
10031003+ - go.sum
10041004+ - go.mod
10051005+ paths:
10061006+ - .gocache
10071007+```
10081008+10091009+Caches are only restored and saved for trusted pipelines
10101010+(pushes and same-repository pull requests). Pipelines
10111011+building untrusted code, like pull requests from forks,
10121012+skip the cache entirely. Saving follows each entry's `when`
10131013+policy, except on timeout, when nothing is saved. A cache
10141014+miss or failure never fails the workflow.
10151015+10161016+The spindle operator chooses the storage backend. See
10171017+[Running spindle](#running-spindle). If no backend is
10181018+configured, `cache` entries are ignored.
10191019+9681020### Steps
96910219701022The `steps` field allows you to define what steps should run
···15091561 trusted public keys for those caches.
15101562- `SPINDLE_NIX_CACHE_UPLOAD_URL`: Cache URL that paths built
15111563 in the guest are uploaded to.
15641564+15651565+The generic CI cache (the workflow-level
15661566+[`cache`](#cache) field) is configured via prefix
15671567+`SPINDLE_CACHE_`.
15681568+15691569+- `SPINDLE_CACHE_BACKEND`: Storage backend, `disk` or `s3`
15701570+ (default: `""`, caching disabled).
15711571+- `SPINDLE_CACHE_DISK_DIR`: Directory for the `disk` backend
15721572+ (default: a `cache` directory next to the spindle
15731573+ database).
15741574+- `SPINDLE_CACHE_S3_BUCKET`: Unversioned bucket for the `s3`
15751575+ backend. Credentials come from the standard AWS chain and
15761576+ need `s3:GetBucketVersioning` in addition to object access.
15771577+- `SPINDLE_CACHE_S3_PREFIX`: Key prefix inside the bucket
15781578+ (default: `"spindle/cache"`).
15791579+- `SPINDLE_CACHE_RETENTION`: Time since the last restore or
15801580+ save before an entry is deleted (default: `720h`, or 30
15811581+ days). Set to `0` to keep entries indefinitely.
15821582+- `SPINDLE_CACHE_PRUNE_INTERVAL`: How often expired entries
15831583+ are deleted (default: `1h`).
15841584+15851585+Cache metadata and usage are tracked in spindle's SQLite
15861586+database. Storage backends contain opaque objects and are
15871587+never listed during lookup or cleanup.
1512158815131589### Running spindle
15141590
···3030 });
3131 # we don't include gnused, xxd etc. here because busybox has them
3232 # we want to keep the image this image small!
3333- guestTools = [nix bash git curl jq];
3333+ # zstd is not a busybox applet, and the spindle cache saves tar|zstd
3434+ guestTools = [nix bash git curl jq pkgsStatic.zstd];
34353536 # run by busybox at sysinit
3637 setupScript = writeText "spindle-setup" ''
···107107 updated_at text not null
108108 );
109109110110+ create table if not exists cache_entries (
111111+ id text primary key,
112112+ storage_key text unique not null,
113113+ owner_did text not null,
114114+ repo_did text not null,
115115+ engine text not null,
116116+ cache_key text not null,
117117+ cache_hash text not null,
118118+ size_bytes integer not null default 0,
119119+ state text not null check (state in ('pending', 'ready', 'deleting')),
120120+ created_at integer not null,
121121+ last_used_at integer not null
122122+ );
123123+124124+ create index if not exists cache_entries_lookup
125125+ on cache_entries (repo_did, engine, cache_key, cache_hash, created_at desc)
126126+ where state = 'ready';
127127+ create index if not exists cache_entries_ready_expiry
128128+ on cache_entries (last_used_at) where state = 'ready';
129129+ create index if not exists cache_entries_pending_expiry
130130+ on cache_entries (created_at) where state in ('pending', 'deleting');
131131+ create index if not exists cache_entries_owner_usage
132132+ on cache_entries (owner_did) where state in ('ready', 'deleting');
133133+110134 create table if not exists pipelines (
111135 id text primary key,
112136 repo_did text not null,
···136160 return nil, err
137161 }
138162139139- return &DB{db}, nil
163163+ return &DB{DB: db}, nil
140164}
141165142166func runMigrations(_ context.Context, conn *sql.Conn, logger *slog.Logger) error {
+333
spindle/engine/cache.go
···11+package engine
22+33+import (
44+ "bufio"
55+ "bytes"
66+ "context"
77+ "crypto/sha256"
88+ "database/sql"
99+ "encoding/hex"
1010+ "errors"
1111+ "fmt"
1212+ "io"
1313+ "log/slog"
1414+ "os/exec"
1515+ "strings"
1616+ "sync"
1717+ "time"
1818+1919+ "github.com/google/uuid"
2020+ "tangled.org/core/spindle/db"
2121+2222+ "tangled.org/core/spindle/models"
2323+ "tangled.org/core/spindle/storage"
2424+)
2525+2626+// cache log steps live below the setup step (-1)
2727+const (
2828+ CacheRestoreStepIdx = -2
2929+ CacheSaveStepIdx = -3
3030+)
3131+3232+type cacheStep struct {
3333+ name string
3434+ command string
3535+}
3636+3737+func (s cacheStep) Name() string { return s.name }
3838+func (s cacheStep) Command() string { return s.command }
3939+func (s cacheStep) Kind() models.StepKind { return models.StepKindSystem }
4040+4141+var (
4242+ CacheRestoreStep models.Step = cacheStep{name: "restore cache", command: "restore cached paths"}
4343+ CacheSaveStep models.Step = cacheStep{name: "save cache", command: "persist changed paths"}
4444+)
4545+4646+type CacheRunner interface {
4747+ RestoreCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error
4848+ SaveCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error
4949+}
5050+5151+type ResolvedCache struct {
5252+ Paths []string
5353+ Key string
5454+ Hash string
5555+ SaveKey string
5656+ RestoreID string
5757+ RestoreKey string
5858+ RestoreName string
5959+ CompressionLevel int
6060+ When string
6161+}
6262+6363+func (rc ResolvedCache) saveOn(failed bool) bool {
6464+ return rc.When == "always" || !failed
6565+}
6666+6767+const CacheExitNoPaths = 42
6868+6969+// avoids storing an empty archive when zstd is missing
7070+const CacheExitNoCompressor = 43
7171+7272+func AbsolutizePaths(paths []string, workspaceRoot string) []string {
7373+ abs := make([]string, 0, len(paths))
7474+ for _, p := range paths {
7575+ if !strings.HasPrefix(p, "/") {
7676+ p = workspaceRoot + "/" + p
7777+ }
7878+ abs = append(abs, p)
7979+ }
8080+ return abs
8181+}
8282+8383+// run tar from / so absolute paths survive extraction
8484+// tar exits nonzero on missing paths, so only existing ones reach it
8585+func CacheSaveScript(paths []string, workspaceRoot string, compressionLevel int) string {
8686+ trimmed := make([]string, 0, len(paths))
8787+ for _, p := range AbsolutizePaths(paths, workspaceRoot) {
8888+ trimmed = append(trimmed, strings.TrimPrefix(p, "/"))
8989+ }
9090+ tail := fmt.Sprintf(`tar -cf - -C / "$@" | %s`, CacheCompressCmd(compressionLevel))
9191+ return fmt.Sprintf(`set -o pipefail
9292+command -v zstd >/dev/null 2>&1 || { echo "zstd not found in image; cannot save cache" >&2; exit %d; }
9393+set --
9494+for p in %s; do [ -e "/$p" ] && set -- "$@" "$p"; done
9595+if [ $# -eq 0 ]; then echo "no cache paths exist; skipping" >&2; exit %d; fi
9696+%s`, CacheExitNoCompressor, strings.Join(trimmed, " "), CacheExitNoPaths, tail)
9797+}
9898+9999+func CacheCompressCmd(level int) string {
100100+ if level == 0 {
101101+ return "zstd -T0 -5"
102102+ }
103103+ return fmt.Sprintf("zstd -T0 -%d", level)
104104+}
105105+106106+// old entries might still be gzip, so detect them instead of trusting config
107107+func CacheDecompressCmd(br *bufio.Reader) string {
108108+ head, _ := br.Peek(4)
109109+ if bytes.HasPrefix(head, []byte{0x1f, 0x8b}) {
110110+ return "gzip -dc"
111111+ }
112112+ return "zstd -dc"
113113+}
114114+115115+// Put keeps draining the pipe after it returns, so the guest writer never
116116+// blocks on a full pipe.
117117+type CacheUpload struct {
118118+ Writer *io.PipeWriter
119119+ done chan error
120120+}
121121+122122+func NewCacheUpload(ctx context.Context, store storage.Storage, key string) *CacheUpload {
123123+ pr, pw := io.Pipe()
124124+ u := &CacheUpload{Writer: pw, done: make(chan error, 1)}
125125+ go func() {
126126+ err := store.Put(ctx, key, pr)
127127+ _, _ = io.Copy(io.Discard, pr)
128128+ u.done <- err
129129+ }()
130130+ return u
131131+}
132132+133133+func (u *CacheUpload) Abort(err error) {
134134+ u.Writer.CloseWithError(err)
135135+ <-u.done
136136+}
137137+138138+// storage treats EOF as a complete archive, so only a clean exec may Finish
139139+func (u *CacheUpload) Finish() error {
140140+ u.Writer.Close()
141141+ return <-u.done
142142+}
143143+144144+type indexedCacheStore struct {
145145+ storage.Storage
146146+ index *db.DB
147147+ logger *slog.Logger
148148+ mu sync.Mutex
149149+ entries map[string]string // storage key -> cache entry id
150150+}
151151+152152+func (s *indexedCacheStore) Get(ctx context.Context, key string) (io.ReadCloser, error) {
153153+ s.mu.Lock()
154154+ id, ok := s.entries[key]
155155+ s.mu.Unlock()
156156+ r, err := s.Storage.Get(ctx, key)
157157+ if err != nil {
158158+ if ok && errors.Is(err, storage.ErrNotExist) {
159159+ _ = s.index.DeleteCacheEntry(context.WithoutCancel(ctx), id)
160160+ }
161161+ return nil, err
162162+ }
163163+ if ok {
164164+ if err := s.index.TouchCacheEntry(ctx, id, time.Now()); err != nil {
165165+ s.logger.Warn("cache usage update failed", "id", id, "err", err)
166166+ }
167167+ }
168168+ return r, nil
169169+}
170170+171171+func (s *indexedCacheStore) Put(ctx context.Context, key string, r io.Reader) error {
172172+ s.mu.Lock()
173173+ id, ok := s.entries[key]
174174+ s.mu.Unlock()
175175+ if !ok {
176176+ return fmt.Errorf("cache metadata missing for %q", key)
177177+ }
178178+ counted := &countingReader{r: r}
179179+ if err := s.Storage.Put(ctx, key, counted); err != nil {
180180+ return err
181181+ }
182182+ superseded, err := s.index.MarkCacheEntryReady(ctx, id, counted.n, time.Now())
183183+ if err != nil {
184184+ _ = s.Storage.Delete(context.WithoutCancel(ctx), key)
185185+ return fmt.Errorf("mark cache ready: %w", err)
186186+ }
187187+ // completed saves leave the pending map so cleanup keeps their object
188188+ s.mu.Lock()
189189+ delete(s.entries, key)
190190+ s.mu.Unlock()
191191+ cleanupCtx := context.WithoutCancel(ctx)
192192+ for _, old := range superseded {
193193+ s.deleteEntry(cleanupCtx, old.StorageKey, old.ID, "replaced")
194194+ }
195195+ return nil
196196+}
197197+198198+func (s *indexedCacheStore) cleanup(ctx context.Context) {
199199+ s.mu.Lock()
200200+ defer s.mu.Unlock()
201201+ for key, id := range s.entries {
202202+ s.deleteEntry(ctx, key, id, "incomplete")
203203+ }
204204+}
205205+206206+func (s *indexedCacheStore) deleteEntry(ctx context.Context, key, id, reason string) {
207207+ if err := s.Storage.Delete(ctx, key); err != nil {
208208+ s.logger.Warn("delete "+reason+" cache failed", "key", key, "err", err)
209209+ return
210210+ }
211211+ if err := s.index.DeleteCacheEntry(ctx, id); err != nil {
212212+ s.logger.Warn("delete "+reason+" cache metadata failed", "id", id, "err", err)
213213+ }
214214+}
215215+216216+type countingReader struct {
217217+ r io.Reader
218218+ n int64
219219+}
220220+221221+func (r *countingReader) Read(p []byte) (int, error) {
222222+ n, err := r.r.Read(p)
223223+ r.n += int64(n)
224224+ return n, err
225225+}
226226+227227+func cacheStoreForRestore(base storage.Storage, index *db.DB, l *slog.Logger, entries []ResolvedCache) storage.Storage {
228228+ mapped := make(map[string]string, len(entries))
229229+ for _, entry := range entries {
230230+ if entry.RestoreKey != "" {
231231+ mapped[entry.RestoreKey] = entry.RestoreID
232232+ }
233233+ }
234234+ return &indexedCacheStore{Storage: base, index: index, logger: l, entries: mapped}
235235+}
236236+237237+func prepareCacheSaves(ctx context.Context, base storage.Storage, index *db.DB, l *slog.Logger, ownerDID, repoDID, engineName string, entries []ResolvedCache) (*indexedCacheStore, error) {
238238+ saveStore := &indexedCacheStore{
239239+ Storage: base,
240240+ index: index,
241241+ logger: l,
242242+ entries: make(map[string]string, len(entries)),
243243+ }
244244+ now := time.Now()
245245+ for i := range entries {
246246+ id := uuid.NewString()
247247+ entries[i].SaveKey = "objects/" + id
248248+ if err := index.InsertCacheEntry(ctx, db.CacheEntry{
249249+ ID: id,
250250+ StorageKey: entries[i].SaveKey,
251251+ OwnerDID: ownerDID,
252252+ RepoDID: repoDID,
253253+ Engine: engineName,
254254+ CacheKey: entries[i].Key,
255255+ CacheHash: entries[i].Hash,
256256+ State: "pending",
257257+ CreatedAt: now,
258258+ LastUsedAt: now,
259259+ }); err != nil {
260260+ saveStore.cleanup(context.WithoutCancel(ctx))
261261+ return nil, err
262262+ }
263263+ saveStore.entries[entries[i].SaveKey] = id
264264+ }
265265+ return saveStore, nil
266266+}
267267+268268+// on a hash miss, the newest older generation still warms the build
269269+// unusable entries degrade to a plain miss
270270+func ResolveCaches(ctx context.Context, l *slog.Logger, index *db.DB, repoDID, engine, repoPath, rev string, entries []models.CacheEntry) []ResolvedCache {
271271+ resolved := make([]ResolvedCache, 0, len(entries))
272272+ for _, entry := range entries {
273273+ hash := ""
274274+ if len(entry.Hash) > 0 && repoPath != "" {
275275+ sum, missing := hashKeyFiles(ctx, repoPath, rev, entry.Hash)
276276+ for _, m := range missing {
277277+ l.Warn("cache hash file not in repo", "key", entry.Key, "path", m)
278278+ }
279279+ hash = sum
280280+ }
281281+ rc := ResolvedCache{
282282+ Paths: entry.Paths,
283283+ Key: entry.Key,
284284+ Hash: hash,
285285+ CompressionLevel: entry.CompressionLevel,
286286+ When: entry.When,
287287+ }
288288+289289+ found, err := index.FindCacheEntry(ctx, repoDID, engine, entry.Key, hash)
290290+ if errors.Is(err, sql.ErrNoRows) && hash != "" {
291291+ found, err = index.FindFallbackCacheEntry(ctx, repoDID, engine, entry.Key, hash)
292292+ if err == nil {
293293+ rc.RestoreName = entry.Key + "-" + found.CacheHash
294294+ }
295295+ }
296296+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
297297+ l.Warn("cache lookup failed; entry will save but not restore", "key", entry.Key, "err", err)
298298+ } else if err == nil {
299299+ rc.RestoreID = found.ID
300300+ rc.RestoreKey = found.StorageKey
301301+ }
302302+ resolved = append(resolved, rc)
303303+ }
304304+ return resolved
305305+}
306306+307307+func hashKeyFiles(ctx context.Context, repoPath, rev string, paths []string) (string, []string) {
308308+ h := sha256.New()
309309+ var missing []string
310310+ hashed := 0
311311+ for _, p := range paths {
312312+ blob, err := gitBlobId(ctx, repoPath, rev, p)
313313+ if err != nil {
314314+ missing = append(missing, p)
315315+ continue
316316+ }
317317+ fmt.Fprintf(h, "%s=%s\n", p, blob)
318318+ hashed++
319319+ }
320320+ if hashed == 0 {
321321+ return "", missing
322322+ }
323323+ return hex.EncodeToString(h.Sum(nil))[:12], missing
324324+}
325325+326326+// sparse checkouts might not have the file, but the object database does
327327+func gitBlobId(ctx context.Context, repoPath, rev, path string) (string, error) {
328328+ out, err := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", rev+":"+path).Output()
329329+ if err != nil {
330330+ return "", err
331331+ }
332332+ return strings.TrimSpace(string(out)), nil
333333+}
+85
spindle/engine/cache_prune.go
···11+package engine
22+33+import (
44+ "context"
55+ "log/slog"
66+ "time"
77+88+ "tangled.org/core/spindle/db"
99+ "tangled.org/core/spindle/storage"
1010+)
1111+1212+const (
1313+ cachePruneBatch = 100
1414+ cachePendingMaxAge = time.Hour
1515+)
1616+1717+func StartCachePruner(ctx context.Context, l *slog.Logger, index *db.DB, store storage.Storage, retention, interval time.Duration) {
1818+ if store == nil || interval <= 0 {
1919+ return
2020+ }
2121+ go func() {
2222+ prune := func() {
2323+ total := 0
2424+ for {
2525+ n, err := PruneCaches(ctx, index, store, time.Now(), retention, cachePendingMaxAge, cachePruneBatch)
2626+ total += n
2727+ if err != nil {
2828+ l.Warn("cache prune failed", "count", total, "err", err)
2929+ return
3030+ }
3131+ if n < cachePruneBatch {
3232+ if total > 0 {
3333+ l.Info("pruned cache entries", "count", total)
3434+ }
3535+ return
3636+ }
3737+ }
3838+ }
3939+ prune()
4040+ ticker := time.NewTicker(interval)
4141+ defer ticker.Stop()
4242+ for {
4343+ select {
4444+ case <-ctx.Done():
4545+ return
4646+ case <-ticker.C:
4747+ prune()
4848+ }
4949+ }
5050+ }()
5151+}
5252+5353+func PruneCaches(ctx context.Context, index *db.DB, store storage.Storage, now time.Time, retention, pendingMaxAge time.Duration, limit int) (int, error) {
5454+ readyBefore := time.Unix(0, 0)
5555+ if retention > 0 {
5656+ readyBefore = now.Add(-retention)
5757+ }
5858+ entries, err := index.ExpiredCacheEntries(ctx, readyBefore, now.Add(-pendingMaxAge), limit)
5959+ if err != nil {
6060+ return 0, err
6161+ }
6262+ pruned := 0
6363+ for _, entry := range entries {
6464+ if entry.State != "deleting" {
6565+ claimed, err := index.ClaimCacheEntry(ctx, entry.ID, entry.State, entry.LastUsedAt)
6666+ if err != nil {
6767+ return pruned, err
6868+ }
6969+ if !claimed {
7070+ continue
7171+ }
7272+ }
7373+ if err := store.Delete(ctx, entry.StorageKey); err != nil {
7474+ if entry.State != "deleting" {
7575+ _ = index.RestoreCacheEntryState(context.WithoutCancel(ctx), entry.ID, entry.State)
7676+ }
7777+ return pruned, err
7878+ }
7979+ if err := index.DeleteCacheEntry(ctx, entry.ID); err != nil {
8080+ return pruned, err
8181+ }
8282+ pruned++
8383+ }
8484+ return pruned, nil
8585+}