diff --git a/cmd/atelet/internal/filecache/evict.go b/cmd/atelet/internal/filecache/evict.go new file mode 100644 index 0000000000..d4207f7c72 --- /dev/null +++ b/cmd/atelet/internal/filecache/evict.go @@ -0,0 +1,262 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "syscall" + "time" +) + +// EvictStats reports what an EvictUnused pass did (or, dry-run, would do). +type EvictStats struct { + // Retired counts entries renamed out of the cache's namespace: from + // that rename on, lookups miss and refetch, whether or not the later + // physical removal succeeded (FreedBytes tracks that part). In a dry + // run, the entries a real pass would have retired. + Retired int + // FreedBytes counts bytes actually returned to the filesystem: the sizes + // of retired entries whose data had no consumer hard links left and + // whose physical removal completed. An entry retired but not removed (a + // RemoveAll failure, reported in the returned error) is excluded; its + // bytes sit in a .rm-* dir until SweepDebris. In a dry run this is the + // would-free estimate. + FreedBytes int64 + // PendingBytes counts bytes of retired entries whose data is still + // hard-linked by a consumer: the cache's claim is gone, but the kernel + // frees the space only when the last consumer link is removed. + PendingBytes int64 + // SkippedYoung counts entries vetoed by the store's min age. + SkippedYoung int + // SkippedBusy counts entries vetoed at retire time: a hit or a fresh + // fetch moved their last-use clock after the pass listed them. + SkippedBusy int +} + +// evictCandidate is one listed entry, snapshotted lock-free at the start of +// a pass; retireEntry re-verifies it under the locks before touching it. +type evictCandidate struct { + dir string // entry dir name (the key hash) + mtime time.Time + size int64 + linked bool // data is a regular file some consumer still hard-links +} + +// EvictUnused frees cache space until targetBytes of actually-freeable +// bytes are reclaimed or no eligible entries remain, least-recently-used +// first. It never removes an entry younger than the store's min age, never +// races a fetch or a hit (both veto at retire time), and never breaks a +// consumer: an entry whose data is still hard-linked can be retired — its +// bytes count as pending, freed by the kernel when the last consumer link +// goes — so the worst outcome for any caller is a re-download. +// +// With dryRun, nothing is touched and the stats report what a real pass +// would have chosen. Passes are pressure-driven: the caller decides when +// and how much; a non-positive target is a no-op. +func (s *Store) EvictUnused(ctx context.Context, targetBytes int64, dryRun bool) (EvictStats, error) { + var stats EvictStats + if targetBytes <= 0 { + return stats, nil + } + s.evictMu.Lock() + defer s.evictMu.Unlock() + + candidates, err := s.listCandidates(ctx) + if err != nil { + return stats, err + } + + // Age gate, then order: entries nobody links first (evicting a linked + // entry frees nothing now), least recently used within each group. + now := time.Now() + eligible := candidates[:0] + for _, c := range candidates { + if now.Sub(c.mtime) < s.minAge { + stats.SkippedYoung++ + continue + } + eligible = append(eligible, c) + } + sort.Slice(eligible, func(i, j int) bool { + if eligible[i].linked != eligible[j].linked { + return !eligible[i].linked + } + return eligible[i].mtime.Before(eligible[j].mtime) + }) + + // Victim selection runs against selectedBytes, not stats.FreedBytes: + // freed is only credited once physical removal succeeds below. + type retiredEntry struct { + path string + freed int64 // c.size for unlinked victims, 0 for linked ones + } + var errs []error + var retired []retiredEntry + var selectedBytes int64 + for _, c := range eligible { + if selectedBytes >= targetBytes { + break + } + if err := ctx.Err(); err != nil { + errs = append(errs, err) + break + } + var freed int64 + if !c.linked { + freed = c.size + } + if !dryRun { + rmPath, ok, err := s.retireEntry(c) + if err != nil { + errs = append(errs, fmt.Errorf("while retiring entry %s: %w", c.dir, err)) + continue + } + if !ok { + stats.SkippedBusy++ + continue + } + retired = append(retired, retiredEntry{path: rmPath, freed: freed}) + } else { + stats.FreedBytes += freed + } + stats.Retired++ + stats.PendingBytes += c.size - freed + selectedBytes += freed + } + + // The slow physical deletion happens after all retires, outside hitMu + // and the singleflight, so hits and fetches never wait on it (evictMu + // stays held: only a concurrent pass would wait, and serializing passes + // is its job). A crash before it finishes leaves .rm-* dirs for + // SweepDebris, as does a removal failure here — those bytes are not + // counted freed. + for _, r := range retired { + if err := os.RemoveAll(r.path); err != nil { + errs = append(errs, fmt.Errorf("while removing retired entry %s: %w", filepath.Base(r.path), err)) + continue + } + stats.FreedBytes += r.freed + } + return stats, errors.Join(errs...) +} + +// listCandidates snapshots the published entries. Deliberately lock-free +// and therefore stale: retireEntry re-verifies every victim before acting, +// so racing hits and fetches only make a candidate disappear, never a +// wrong eviction. +func (s *Store) listCandidates(ctx context.Context) ([]evictCandidate, error) { + children, err := os.ReadDir(s.entriesDir()) + if err != nil { + return nil, fmt.Errorf("while listing entries: %w", err) + } + candidates := make([]evictCandidate, 0, len(children)) + for _, child := range children { + if err := ctx.Err(); err != nil { + return nil, err + } + if !child.IsDir() { + continue + } + entryDir := filepath.Join(s.entriesDir(), child.Name()) + fi, err := os.Stat(entryDir) + if err != nil { + continue // retired mid-listing + } + size, linked, err := s.sizeEntry(entryDir) + if err != nil { + continue // ditto + } + candidates = append(candidates, evictCandidate{ + dir: child.Name(), + mtime: fi.ModTime(), + size: size, + linked: linked, + }) + } + return candidates, nil +} + +// sizeEntry sums an entry's regular-file bytes and reports whether its data +// file carries consumer hard links (a directory-tree entry cannot, so it +// reports unlinked and relies on the min age and, later, a root set). +func (s *Store) sizeEntry(entryDir string) (size int64, linked bool, err error) { + err = filepath.WalkDir(entryDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + size += info.Size() + if path == filepath.Join(entryDir, dataName) { + if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 { + linked = true + } + } + return nil + }) + if err != nil { + return 0, false, err + } + return size, linked, nil +} + +// retireEntry removes c from the cache's namespace if it is still exactly +// the entry the pass listed, returning the renamed .rm-* path and whether +// it retired. It runs inside the key's singleflight — joining an in-flight +// fetch instead of racing it (the join's shared result leaves retired +// false) — and takes hitMu exclusively for the final re-check and rename, +// so a hit can never link out of an entry being retired. A moved last-use +// clock is a veto, not an error. +func (s *Store) retireEntry(c evictCandidate) (string, bool, error) { + var rmPath string + var retired bool + _, err, _ := s.sf.Do(c.dir, func() (any, error) { + s.hitMu.Lock() + defer s.hitMu.Unlock() + + entryDir := filepath.Join(s.entriesDir(), c.dir) + fi, err := os.Stat(entryDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil // already gone + } + if err != nil { + return nil, err + } + if !fi.ModTime().Equal(c.mtime) { + return nil, nil // hit or refetched since the listing: veto + } + // Unique suffix: a crashed pass may have left .rm--* behind and + // the key may have been refetched and retired again before a sweep. + p := filepath.Join(s.root, rmPrefix+c.dir+"-"+strconv.FormatInt(time.Now().UnixNano(), 36)) + if err := os.Rename(entryDir, p); err != nil { + return nil, err + } + rmPath, retired = p, true + return nil, nil + }) + return rmPath, retired, err +} diff --git a/cmd/atelet/internal/filecache/evict_test.go b/cmd/atelet/internal/filecache/evict_test.go new file mode 100644 index 0000000000..a285dd4feb --- /dev/null +++ b/cmd/atelet/internal/filecache/evict_test.go @@ -0,0 +1,307 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const evictAll = int64(1) << 40 + +// cacheEntry materializes an entry through the real retrieval path, backdates +// its last-use clock by age, and returns its key. keepLink controls whether a +// consumer hard link survives (dst removed = the actor dir was wiped). +func cacheEntry(t *testing.T, s *Store, name, content string, age time.Duration, keepLink bool) Key { + t.Helper() + key := URIKey("test://" + name) + fetch, _ := countingFetcher(content) + dst := dstPath(t, s, "evict-"+name) + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatalf("GetFileTo(%s): %v", name, err) + } + if !keepLink { + if err := os.Remove(dst); err != nil { + t.Fatal(err) + } + } + stale := time.Now().Add(-age) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + return key +} + +func entryExists(t *testing.T, s *Store, key Key) bool { + t.Helper() + _, err := os.Stat(s.entryDir(key)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + return err == nil +} + +// noRetiredLeftovers asserts the two-phase retire completed: no .rm-* dirs +// remain at the store root. +func noRetiredLeftovers(t *testing.T, s *Store) { + t.Helper() + children, err := os.ReadDir(s.root) + if err != nil { + t.Fatal(err) + } + for _, child := range children { + if strings.HasPrefix(child.Name(), rmPrefix) { + t.Errorf("retired dir %q left behind after pass", child.Name()) + } + } +} + +func TestEvictUnusedNonPositiveTargetIsNoop(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "kept", "x", time.Hour, false) + + stats, err := s.EvictUnused(context.Background(), 0, false) + if err != nil { + t.Fatalf("EvictUnused(0): %v", err) + } + if stats != (EvictStats{}) { + t.Errorf("stats = %+v, want zero", stats) + } + if !entryExists(t, s, key) { + t.Error("entry evicted by zero-target pass") + } +} + +func TestEvictUnusedTakesLeastRecentlyUsedAndStopsAtTarget(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + oldest := cacheEntry(t, s, "oldest", "aaaaa", 3*time.Hour, false) + middle := cacheEntry(t, s, "middle", "bbbbb", 2*time.Hour, false) + newest := cacheEntry(t, s, "newest", "ccccc", time.Hour, false) + + // A 1-byte target forces exactly one eviction, which must be the LRU. + stats, err := s.EvictUnused(context.Background(), 1, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes < 5 { + t.Errorf("stats = %+v, want Retired=1 and FreedBytes >= 5", stats) + } + if entryExists(t, s, oldest) { + t.Error("LRU entry survived") + } + if !entryExists(t, s, middle) || !entryExists(t, s, newest) { + t.Error("pass evicted beyond its target") + } + noRetiredLeftovers(t, s) +} + +func TestEvictUnusedRespectsMinAge(t *testing.T) { + s := newTestStore(t, WithMinAge(time.Hour)) + young := cacheEntry(t, s, "young", "x", 30*time.Minute, false) + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 0 || stats.SkippedYoung != 1 { + t.Errorf("stats = %+v, want Retired=0 SkippedYoung=1", stats) + } + if !entryExists(t, s, young) { + t.Error("entry younger than minAge evicted") + } +} + +func TestEvictUnusedPrefersUnlinkedOverOlderLinked(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + // The linked entry is older; pure LRU would take it first. The pass must + // prefer the unlinked one, whose bytes actually come back. + linked := cacheEntry(t, s, "linked", "xx", 3*time.Hour, true) + unlinked := cacheEntry(t, s, "unlinked", "yy", time.Hour, false) + + stats, err := s.EvictUnused(context.Background(), 1, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes == 0 || stats.PendingBytes != 0 { + t.Errorf("stats = %+v, want one eviction with freed bytes only", stats) + } + if entryExists(t, s, unlinked) { + t.Error("unlinked entry survived") + } + if !entryExists(t, s, linked) { + t.Error("linked entry evicted while an unlinked one satisfied the target") + } +} + +func TestEvictUnusedLinkedEntryIsSafeForConsumer(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := URIKey("test://held") + fetch, calls := countingFetcher("held bytes") + dst := dstPath(t, s, "held") + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes != 0 || stats.PendingBytes == 0 { + t.Errorf("stats = %+v, want one eviction counted as pending bytes", stats) + } + if entryExists(t, s, key) { + t.Error("entry still published after eviction") + } + // The consumer's link is untouched: eviction cost is a re-download, + // never a broken consumer. + got, err := os.ReadFile(dst) + if err != nil || string(got) != "held bytes" { + t.Errorf("consumer link after eviction: %q, %v", got, err) + } + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "held2"), fetch); err != nil { + t.Fatalf("GetFileTo after eviction: %v", err) + } + if calls.Load() != 2 { + t.Errorf("fetch ran %d times, want 2 (original + post-eviction)", calls.Load()) + } +} + +func TestEvictUnusedDryRunTouchesNothing(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + a := cacheEntry(t, s, "a", "aa", 2*time.Hour, false) + b := cacheEntry(t, s, "b", "bb", time.Hour, true) + + stats, err := s.EvictUnused(context.Background(), evictAll, true) + if err != nil { + t.Fatalf("EvictUnused(dryRun): %v", err) + } + if stats.Retired != 2 || stats.FreedBytes == 0 || stats.PendingBytes == 0 { + t.Errorf("stats = %+v, want both entries reported (one freed, one pending)", stats) + } + if !entryExists(t, s, a) || !entryExists(t, s, b) { + t.Error("dry run removed entries") + } + noRetiredLeftovers(t, s) +} + +func TestEvictUnusedRemovalFailureIsNotCountedFreed(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + s := newTestStore(t, WithMinAge(0)) + // A tree entry with a write-protected subdirectory: listing and the + // retire rename work, but RemoveAll cannot unlink the file inside. + key := publishTestEntry(t, s, "stuck", map[string]string{ + filepath.Join(dataName, "locked", "f"): "12345", + }) + locked := filepath.Join(s.entryDir(key), dataName, "locked") + if err := os.Chmod(locked, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { // let TempDir cleanup succeed wherever the dir ended up + matches, _ := filepath.Glob(filepath.Join(s.root, rmPrefix+"*", dataName, "locked")) + for _, m := range append(matches, locked) { + _ = os.Chmod(m, 0o700) + } + }) + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err == nil { + t.Fatal("EvictUnused succeeded despite unremovable entry, want error") + } + if stats.FreedBytes != 0 { + t.Errorf("FreedBytes = %d for a failed removal, want 0", stats.FreedBytes) + } + if stats.Retired != 1 { + t.Errorf("Retired = %d, want 1 (the retire itself succeeded)", stats.Retired) + } + if entryExists(t, s, key) { + t.Error("entry still published after retire") + } +} + +func TestRetireEntryVetoesWhenLastUseMoved(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "busy", "x", time.Hour, false) + + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + listed := evictCandidate{dir: key.dir, mtime: fi.ModTime(), size: 1} + + // A hit lands between the listing and the retire. + now := time.Now() + if err := os.Chtimes(s.entryDir(key), now, now); err != nil { + t.Fatal(err) + } + + rmPath, retired, err := s.retireEntry(listed) + if err != nil { + t.Fatalf("retireEntry: %v", err) + } + if retired || rmPath != "" { + t.Errorf("retireEntry retired a touched entry (rmPath=%q)", rmPath) + } + if !entryExists(t, s, key) { + t.Error("touched entry vanished") + } +} + +func TestRetireEntryRetiresUnchangedEntry(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "stale", "x", time.Hour, false) + + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + rmPath, retired, err := s.retireEntry(evictCandidate{dir: key.dir, mtime: fi.ModTime(), size: 1}) + if err != nil { + t.Fatalf("retireEntry: %v", err) + } + if !retired { + t.Fatal("retireEntry did not retire an unchanged entry") + } + if entryExists(t, s, key) { + t.Error("entry still published after retire") + } + if !strings.HasPrefix(filepath.Base(rmPath), rmPrefix) { + t.Errorf("retired path %q lacks the %q prefix", rmPath, rmPrefix) + } + if _, err := os.Stat(rmPath); err != nil { + t.Errorf("retired dir missing before removal phase: %v", err) + } + // An interrupted pass leaves the retired dir to the startup sweep. + stats, err := s.SweepDebris(context.Background()) + if err != nil { + t.Fatal(err) + } + if stats.RetiredRemoved != 1 { + t.Errorf("sweep stats = %+v, want RetiredRemoved=1", stats) + } +} diff --git a/cmd/atelet/internal/filecache/filecache.go b/cmd/atelet/internal/filecache/filecache.go new file mode 100644 index 0000000000..7dd25fe3a1 --- /dev/null +++ b/cmd/atelet/internal/filecache/filecache.go @@ -0,0 +1,269 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package filecache is a node-local disk cache of downloaded artifacts, +// keyed by opaque identities (see Key). Its contract: an artifact wanted by +// N concurrent callers is fetched once, publication into the cache is atomic +// and crash-safe, and cached bytes are evicted under byte-budget pressure +// without ever breaking a consumer. +// +// On-disk layout, under a store's root (which the store owns exclusively): +// +// entries// +// data # the cached file (or directory tree) +// meta.json # canonical key + creation time; debugging only +// tmp/ # in-flight fetches; same filesystem as entries/, so +// # publication is one atomic rename +// .rm-* # retired entries awaiting removal +// +// An entry directory's mtime is its last-use clock. Nothing on a correctness +// path reads meta.json: entries are matched to keys by hashing the keys. +// +// A crash can leave debris in tmp/ (a fetch that never finished) or .rm-* +// dirs (an eviction that renamed but never removed); SweepDebris reaps both +// and runs once at startup, before the store serves requests. Everything +// under entries/ is a complete, published entry. +package filecache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + entriesDirName = "entries" + tmpDirName = "tmp" + // rmPrefix marks a retired entry awaiting removal, at the store root (not + // under entries/, so a retired entry is invisible to lookups and GC + // listings the moment it is renamed). + rmPrefix = ".rm-" + + dataName = "data" + metaName = "meta.json" + + // defaultMinAge is the default eviction minimum age (see WithMinAge). + defaultMinAge = 10 * time.Minute + // defaultFetchTimeout is the default per-fetch bound (see + // WithFetchTimeout). Generous enough for multi-GiB artifacts on a busy + // node. + defaultFetchTimeout = 10 * time.Minute +) + +// Store is one on-disk cache. It is safe for concurrent use and assumes it +// is the only writer under its root (one atelet per node). +type Store struct { + root string + + // minAge vetoes eviction of any entry younger than this, covering the + // window between publication and the consumer's use becoming visible to + // GC (a hardlink's Nlink, or a root-set record). + minAge time.Duration + + // fetchTimeout bounds each fetch. Fetches run detached from the contexts + // of the callers waiting on them, so this is the only bound on how long + // one can run. + fetchTimeout time.Duration + + // sf collapses concurrent fetches of the same key into one flight. + // Eviction will retire entries inside the same flight, so a retire can + // never race a fetch of the key it is removing. + sf singleflight.Group + + // hitMu closes the hit-vs-evict window: held shared by the link-out path + // (stat, link, and last-use touch), exclusive by eviction's final + // re-check and retire rename, so an entry can never vanish between a + // hit's stat and its link. Uncontended except during an eviction pass. + hitMu sync.RWMutex + + // evictMu serializes EvictUnused passes (concurrent passes would fight + // over the same candidates for no benefit). + evictMu sync.Mutex +} + +// Option configures a Store. +type Option func(*Store) + +// WithMinAge sets the eviction minimum age. +func WithMinAge(d time.Duration) Option { + return func(s *Store) { s.minAge = d } +} + +// WithFetchTimeout sets the per-fetch bound. +func WithFetchTimeout(d time.Duration) Option { + return func(s *Store) { s.fetchTimeout = d } +} + +// New opens (creating if needed) the store rooted at root. +func New(root string, opts ...Option) (*Store, error) { + s := &Store{ + root: root, + minAge: defaultMinAge, + fetchTimeout: defaultFetchTimeout, + } + for _, opt := range opts { + opt(s) + } + // Entries are world-readable (their files get hard-linked into consumer + // dirs and, later, consumed in place); tmp holds unpublished fetches and + // stays private. + if err := os.MkdirAll(s.entriesDir(), 0o755); err != nil { + return nil, fmt.Errorf("while creating entries dir: %w", err) + } + if err := os.MkdirAll(s.tmpDir(), 0o700); err != nil { + return nil, fmt.Errorf("while creating tmp dir: %w", err) + } + return s, nil +} + +func (s *Store) entriesDir() string { return filepath.Join(s.root, entriesDirName) } +func (s *Store) tmpDir() string { return filepath.Join(s.root, tmpDirName) } + +// entryDir is the published location of k's entry. +func (s *Store) entryDir(k Key) string { return filepath.Join(s.entriesDir(), k.dir) } + +// dataPath is the published location of k's cached file (or tree). +func (s *Store) dataPath(k Key) string { return filepath.Join(s.entryDir(k), dataName) } + +// entryMeta is the debugging sidecar written next to an entry's data. It is +// never read on a correctness path; a missing or corrupt one affects +// nothing. +type entryMeta struct { + // Key is the canonical key string, so an operator staring at du output + // can tell what an entry holds. + Key string `json:"key"` + CreatedAt time.Time `json:"createdAt"` +} + +// writeEntryMeta writes the meta.json sidecar into an (unpublished) entry +// dir. +func writeEntryMeta(entryDir string, k Key, createdAt time.Time) error { + data, err := json.Marshal(entryMeta{Key: k.String(), CreatedAt: createdAt}) + if err != nil { + return fmt.Errorf("while marshaling entry meta: %w", err) + } + if err := os.WriteFile(filepath.Join(entryDir, metaName), data, 0o644); err != nil { + return fmt.Errorf("while writing entry meta: %w", err) + } + return nil +} + +// readEntryMeta loads an entry dir's meta.json sidecar. +func readEntryMeta(entryDir string) (entryMeta, error) { + data, err := os.ReadFile(filepath.Join(entryDir, metaName)) + if err != nil { + return entryMeta{}, fmt.Errorf("while reading entry meta: %w", err) + } + var m entryMeta + if err := json.Unmarshal(data, &m); err != nil { + return entryMeta{}, fmt.Errorf("while parsing entry meta: %w", err) + } + return m, nil +} + +// SweepStats reports what a SweepDebris pass removed. +type SweepStats struct { + // TmpRemoved counts removed tmp/ children (unfinished fetches). + TmpRemoved int + // RetiredRemoved counts removed .rm-* dirs (interrupted evictions). + RetiredRemoved int +} + +// SweepDebris removes crash debris: everything under tmp/ and every .rm-* +// dir at the store root. It runs once at startup, before the store serves +// requests; published entries are never touched. Removal failures are +// joined and reported after the sweep visits everything, so one bad path +// does not shadow the rest. +func (s *Store) SweepDebris(ctx context.Context) (SweepStats, error) { + var stats SweepStats + var errs []error + + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + errs = append(errs, fmt.Errorf("while listing tmp dir: %w", err)) + } + for _, child := range tmpChildren { + if err := ctx.Err(); err != nil { + return stats, err + } + if err := os.RemoveAll(filepath.Join(s.tmpDir(), child.Name())); err != nil { + errs = append(errs, fmt.Errorf("while removing tmp debris %q: %w", child.Name(), err)) + continue + } + stats.TmpRemoved++ + } + + rootChildren, err := os.ReadDir(s.root) + if err != nil { + errs = append(errs, fmt.Errorf("while listing store root: %w", err)) + } + for _, child := range rootChildren { + if err := ctx.Err(); err != nil { + return stats, err + } + if !strings.HasPrefix(child.Name(), rmPrefix) { + continue + } + if err := os.RemoveAll(filepath.Join(s.root, child.Name())); err != nil { + errs = append(errs, fmt.Errorf("while removing retired entry %q: %w", child.Name(), err)) + continue + } + stats.RetiredRemoved++ + } + + return stats, errors.Join(errs...) +} + +// TotalBytes sums the sizes of all published entries' regular files. It is +// the GC driver's usage measure against the store's byte budget. +func (s *Store) TotalBytes(ctx context.Context) (int64, error) { + var total int64 + err := filepath.WalkDir(s.entriesDir(), func(path string, d fs.DirEntry, err error) error { + if err != nil { + // An entry retired mid-walk is not an error; skip what vanished. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if err := ctx.Err(); err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + info, err := d.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + total += info.Size() + return nil + }) + if err != nil { + return 0, fmt.Errorf("while sizing entries: %w", err) + } + return total, nil +} diff --git a/cmd/atelet/internal/filecache/filecache_test.go b/cmd/atelet/internal/filecache/filecache_test.go new file mode 100644 index 0000000000..7576b184ea --- /dev/null +++ b/cmd/atelet/internal/filecache/filecache_test.go @@ -0,0 +1,213 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func newTestStore(t *testing.T, opts ...Option) *Store { + t.Helper() + s, err := New(filepath.Join(t.TempDir(), "cache"), opts...) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +// publishTestEntry plants a published entry with the given data files +// (relative name -> content), bypassing retrieval, and returns its key. +func publishTestEntry(t *testing.T, s *Store, name string, files map[string]string) Key { + t.Helper() + k := URIKey("test://" + name) + if len(files) == 0 { + if err := os.MkdirAll(s.entryDir(k), 0o755); err != nil { + t.Fatal(err) + } + } + for rel, content := range files { + path := filepath.Join(s.entryDir(k), rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o444); err != nil { + t.Fatal(err) + } + } + if err := writeEntryMeta(s.entryDir(k), k, time.Now()); err != nil { + t.Fatalf("writeEntryMeta: %v", err) + } + return k +} + +func TestNewCreatesLayout(t *testing.T) { + root := filepath.Join(t.TempDir(), "cache") + s, err := New(root) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, dir := range []string{s.entriesDir(), s.tmpDir()} { + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + t.Errorf("stat %q: err=%v, isDir=%v", dir, err, err == nil && fi.IsDir()) + } + } + + // Reopening an existing root keeps published entries. + k := publishTestEntry(t, s, "survivor", map[string]string{dataName: "x"}) + if _, err := New(root); err != nil { + t.Fatalf("New (reopen): %v", err) + } + if _, err := os.Stat(s.dataPath(k)); err != nil { + t.Errorf("entry lost across reopen: %v", err) + } +} + +func TestNewDefaultsAndOptions(t *testing.T) { + s := newTestStore(t) + if s.minAge != defaultMinAge { + t.Errorf("minAge = %v, want default %v", s.minAge, defaultMinAge) + } + if s.fetchTimeout != defaultFetchTimeout { + t.Errorf("fetchTimeout = %v, want default %v", s.fetchTimeout, defaultFetchTimeout) + } + + s = newTestStore(t, WithMinAge(time.Second), WithFetchTimeout(time.Minute)) + if s.minAge != time.Second { + t.Errorf("minAge = %v, want %v", s.minAge, time.Second) + } + if s.fetchTimeout != time.Minute { + t.Errorf("fetchTimeout = %v, want %v", s.fetchTimeout, time.Minute) + } +} + +func TestEntryMetaRoundTrip(t *testing.T) { + s := newTestStore(t) + k := publishTestEntry(t, s, "meta", map[string]string{dataName: "x"}) + + m, err := readEntryMeta(s.entryDir(k)) + if err != nil { + t.Fatalf("readEntryMeta: %v", err) + } + if m.Key != k.String() { + t.Errorf("meta key = %q, want %q", m.Key, k.String()) + } + if m.CreatedAt.IsZero() { + t.Error("meta createdAt is zero") + } +} + +func TestSweepDebris(t *testing.T) { + s := newTestStore(t) + kept := publishTestEntry(t, s, "kept", map[string]string{dataName: "x"}) + + // Unfinished fetches: a bare temp file and a temp extraction dir. + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(s.tmpDir(), "dl-2", "nested"), 0o700); err != nil { + t.Fatal(err) + } + // An eviction that renamed but never removed. + if err := os.MkdirAll(filepath.Join(s.root, rmPrefix+"deadbeef", "nested"), 0o755); err != nil { + t.Fatal(err) + } + + stats, err := s.SweepDebris(context.Background()) + if err != nil { + t.Fatalf("SweepDebris: %v", err) + } + if stats.TmpRemoved != 2 || stats.RetiredRemoved != 1 { + t.Errorf("stats = %+v, want TmpRemoved=2 RetiredRemoved=1", stats) + } + + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir not empty after sweep: %d children", len(tmpChildren)) + } + if _, err := os.Stat(filepath.Join(s.root, rmPrefix+"deadbeef")); !os.IsNotExist(err) { + t.Errorf("retired dir survived sweep: err=%v", err) + } + if _, err := os.Stat(s.dataPath(kept)); err != nil { + t.Errorf("published entry removed by sweep: %v", err) + } + + // A clean store sweeps to zero. + stats, err = s.SweepDebris(context.Background()) + if err != nil { + t.Fatalf("SweepDebris (clean): %v", err) + } + if stats != (SweepStats{}) { + t.Errorf("stats on clean store = %+v, want zero", stats) + } +} + +func TestSweepDebrisCanceled(t *testing.T) { + s := newTestStore(t) + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), nil, 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := s.SweepDebris(ctx); err == nil { + t.Error("SweepDebris with canceled ctx succeeded, want error") + } +} + +func TestTotalBytes(t *testing.T) { + s := newTestStore(t) + + total, err := s.TotalBytes(context.Background()) + if err != nil { + t.Fatalf("TotalBytes (empty): %v", err) + } + if total != 0 { + t.Errorf("TotalBytes on empty store = %d, want 0", total) + } + + publishTestEntry(t, s, "file", map[string]string{dataName: "12345"}) + publishTestEntry(t, s, "tree", map[string]string{ + filepath.Join(dataName, "runsc"): "1234567890", + filepath.Join(dataName, "gvisor-bin", "help"): "123", + }) + // Unpublished bytes in tmp/ must not count. + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), []byte("zzzz"), 0o600); err != nil { + t.Fatal(err) + } + + total, err = s.TotalBytes(context.Background()) + if err != nil { + t.Fatalf("TotalBytes: %v", err) + } + // 5 + 10 + 3 data bytes plus the two entries' meta.json sidecars. + var metaBytes int64 + for _, name := range []string{"file", "tree"} { + fi, err := os.Stat(filepath.Join(s.entryDir(URIKey("test://"+name)), metaName)) + if err != nil { + t.Fatal(err) + } + metaBytes += fi.Size() + } + if want := int64(18) + metaBytes; total != want { + t.Errorf("TotalBytes = %d, want %d", total, want) + } +} diff --git a/cmd/atelet/internal/filecache/getfileto.go b/cmd/atelet/internal/filecache/getfileto.go new file mode 100644 index 0000000000..d14f1f3865 --- /dev/null +++ b/cmd/atelet/internal/filecache/getfileto.go @@ -0,0 +1,191 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" + "time" +) + +// FileFetcher produces a single-file artifact at dstPath (creating the file +// itself, so it can seek and truncate for sparse output). It runs at most +// once per key across concurrent GetFileTo callers, detached from their +// contexts and bounded by the store's fetch timeout. Its error is delivered +// to every caller waiting on the flight, wrapped with %w so error +// classification (errors.Is) sees through the store. +type FileFetcher func(ctx context.Context, dstPath string) error + +// linkRetries bounds the publish-then-link loop in GetFileTo. An entry can +// be evicted between a flight completing and this caller's link only if it +// sat unlinked past the store's min age, so a single retry is already an +// anomaly; more than a few means something is deleting entries out from +// under the store. +const linkRetries = 3 + +// GetFileTo materializes the artifact identified by key at dst, fetching it +// with fetch if it is not cached. dst must be an absolute path that does not +// exist yet and lives on the cache's filesystem (the same mount, not just +// the same disk): on success it is a hard link to the read-only cache +// copy, so the caller keeps a valid file regardless of later eviction, and +// the entry is published mode 0444 so an in-place write fails loudly rather +// than corrupting the shared bytes. +// +// Concurrent calls for one key share a single fetch. A caller whose ctx is +// canceled returns early with ctx.Err() while the fetch keeps running for +// the others; there is no negative caching, so after a failed fetch the +// next call starts fresh. +func (s *Store) GetFileTo(ctx context.Context, key Key, dst string, fetch FileFetcher) error { + if key.isZero() { + return errors.New("filecache: zero Key (use a Key constructor)") + } + if !filepath.IsAbs(dst) { + return fmt.Errorf("filecache: destination %q is not an absolute path", dst) + } + if fetch == nil { + return errors.New("filecache: nil FileFetcher") + } + for attempt := 0; ; attempt++ { + linked, err := s.linkOut(key, dst) + if err != nil { + return err + } + if linked { + return nil + } + if attempt >= linkRetries { + return fmt.Errorf("entry for %v vanished after %d fetches; is something else deleting under the store root?", key, attempt) + } + if err := s.fetchFlight(ctx, key, fetch); err != nil { + return err + } + } +} + +// linkOut links key's published data file to dst and touches the entry's +// last-use clock, reporting false (and no error) on a cache miss. The +// shared hitMu holds eviction's final re-check out of the stat-to-link +// window, so a published entry cannot be retired mid-hit. +func (s *Store) linkOut(key Key, dst string) (bool, error) { + s.hitMu.RLock() + defer s.hitMu.RUnlock() + + if _, err := os.Stat(s.dataPath(key)); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("while checking cache for %v: %w", key, err) + } + if err := os.Link(s.dataPath(key), dst); err != nil { + if errors.Is(err, fs.ErrExist) { + return false, fmt.Errorf("destination %s already exists: %w", dst, err) + } + if errors.Is(err, syscall.EXDEV) { + return false, fmt.Errorf("destination %s is not on the cache's filesystem (link-out requires one filesystem): %w", dst, err) + } + return false, fmt.Errorf("while linking %v to %s: %w", key, dst, err) + } + // Last-use touch for eviction's LRU ordering; best-effort, a miss only + // ages the entry. + now := time.Now() + _ = os.Chtimes(s.entryDir(key), now, now) + return true, nil +} + +// fetchFlight runs (or joins) the singleflight fetch for key and waits for +// it or for ctx. The flight itself runs on a context detached from the +// callers' — bounded only by the store's fetch timeout — so one canceled +// caller never aborts a download other callers are waiting on. +func (s *Store) fetchFlight(ctx context.Context, key Key, fetch FileFetcher) error { + ch := s.sf.DoChan(key.dir, func() (any, error) { + return nil, s.fetchAndPublish(context.WithoutCancel(ctx), key, fetch) + }) + select { + case res := <-ch: + if res.Err != nil { + return fmt.Errorf("while fetching %v: %w", key, res.Err) + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// fetchAndPublish runs one fetch into tmp/ and atomically publishes the +// result under entries/. On any failure the temp dir is removed, so a bad +// fetch is never visible in the cache; a crash instead leaves it for +// SweepDebris. +func (s *Store) fetchAndPublish(ctx context.Context, key Key, fetch FileFetcher) error { + ctx, cancel := context.WithTimeout(ctx, s.fetchTimeout) + defer cancel() + + // A flight that completed between this caller's miss and this flight + // starting may have published already. + if _, err := os.Stat(s.dataPath(key)); err == nil { + return nil + } + + tmpDir, err := os.MkdirTemp(s.tmpDir(), key.dir+"-") + if err != nil { + return fmt.Errorf("while creating fetch temp dir: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(tmpDir) + } + }() + + dataPath := filepath.Join(tmpDir, dataName) + if err := fetch(ctx, dataPath); err != nil { + return err + } + fi, err := os.Stat(dataPath) + if err != nil { + return fmt.Errorf("fetcher succeeded but produced no file: %w", err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("fetcher produced %v, want a regular file", fi.Mode()) + } + + // Read-only before publication: cached bytes are shared through hard + // links, so an in-place write by any consumer must fail rather than + // poison the copy every later caller links. + if err := os.Chmod(dataPath, 0o444); err != nil { + return fmt.Errorf("while making fetched file read-only: %w", err) + } + if err := writeEntryMeta(tmpDir, key, time.Now()); err != nil { + return err + } + if err := os.Chmod(tmpDir, 0o755); err != nil { // MkdirTemp created it 0700 + return fmt.Errorf("while setting entry dir mode: %w", err) + } + if err := os.Rename(tmpDir, s.entryDir(key)); err != nil { + // Within one process the singleflight makes a rename race + // unreachable; tolerating a loss anyway keeps overlap with a + // crashed predecessor's published entry safe. + if errors.Is(err, syscall.EEXIST) || errors.Is(err, syscall.ENOTEMPTY) { + return nil + } + return fmt.Errorf("while publishing entry: %w", err) + } + published = true + return nil +} diff --git a/cmd/atelet/internal/filecache/getfileto_test.go b/cmd/atelet/internal/filecache/getfileto_test.go new file mode 100644 index 0000000000..574a5d6fc3 --- /dev/null +++ b/cmd/atelet/internal/filecache/getfileto_test.go @@ -0,0 +1,328 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +// countingFetcher returns a FileFetcher writing content, and a counter of +// how many times it ran. +func countingFetcher(content string) (FileFetcher, *atomic.Int32) { + var calls atomic.Int32 + return func(ctx context.Context, dstPath string) error { + calls.Add(1) + return os.WriteFile(dstPath, []byte(content), 0o600) + }, &calls +} + +// dstPath returns a fresh destination path (which must not exist yet) in a +// per-test consumer dir on the same filesystem as the store. +func dstPath(t *testing.T, s *Store, name string) string { + t.Helper() + dir := filepath.Join(s.root, "..", "consumer") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return filepath.Join(dir, name) +} + +func TestGetFileToMissThenHit(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, calls := countingFetcher("golden bytes") + + dst1 := dstPath(t, s, "d1") + if err := s.GetFileTo(context.Background(), key, dst1, fetch); err != nil { + t.Fatalf("GetFileTo (miss): %v", err) + } + got, err := os.ReadFile(dst1) + if err != nil || string(got) != "golden bytes" { + t.Fatalf("dst content = %q, %v; want %q", got, err, "golden bytes") + } + fi, err := os.Stat(dst1) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o444 { + t.Errorf("dst mode = %v, want 0444", fi.Mode().Perm()) + } + + dst2 := dstPath(t, s, "d2") + if err := s.GetFileTo(context.Background(), key, dst2, fetch); err != nil { + t.Fatalf("GetFileTo (hit): %v", err) + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } + + // Both destinations and the cache copy share one inode. + fi2, err := os.Stat(dst2) + if err != nil { + t.Fatal(err) + } + cfi, err := os.Stat(s.dataPath(key)) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(fi, fi2) || !os.SameFile(fi, cfi) { + t.Error("dst1, dst2, and cache copy are not one inode") + } + + // Nothing left in flight. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d leftover children", len(tmpChildren)) + } +} + +func TestGetFileToConcurrentCallersShareOneFetch(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(ctx context.Context, dstPath string) error { + calls.Add(1) + <-release // hold every caller in one flight + return os.WriteFile(dstPath, []byte("x"), 0o600) + } + + const n = 16 + errs := make([]error, n) + var started, done sync.WaitGroup + for i := range n { + started.Add(1) + done.Go(func() { + started.Done() + errs[i] = s.GetFileTo(context.Background(), key, dstPath(t, s, fmt.Sprintf("d%d", i)), fetch) + }) + } + started.Wait() + close(release) + done.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("caller %d: %v", i, err) + } + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } +} + +func TestGetFileToCanceledWaiterDoesNotAbortFlight(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + + var calls atomic.Int32 + entered := make(chan struct{}) + release := make(chan struct{}) + fetch := func(ctx context.Context, dstPath string) error { + calls.Add(1) + close(entered) + select { + case <-release: + case <-ctx.Done(): // must NOT fire on the caller's cancel + return ctx.Err() + } + return os.WriteFile(dstPath, []byte("x"), 0o600) + } + + ctx, cancel := context.WithCancel(context.Background()) + callerErr := make(chan error, 1) + go func() { + callerErr <- s.GetFileTo(ctx, key, dstPath(t, s, "canceled"), fetch) + }() + <-entered + cancel() + if err := <-callerErr; !errors.Is(err, context.Canceled) { + t.Fatalf("canceled caller returned %v, want context.Canceled", err) + } + + // The flight is still running detached; releasing it publishes the + // entry, and a later caller hits the cache with no second fetch. + close(release) + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "later"), fetch); err != nil { + t.Fatalf("GetFileTo after canceled waiter: %v", err) + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } +} + +func TestGetFileToFetchErrorReachesAllWaitersThenRetries(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetchErr := errors.New("bucket unreachable") + + var calls atomic.Int32 + release := make(chan struct{}) + failing := func(ctx context.Context, dstPath string) error { + calls.Add(1) + <-release + return fetchErr + } + + const n = 4 + errs := make([]error, n) + var started, done sync.WaitGroup + for i := range n { + started.Add(1) + done.Go(func() { + started.Done() + errs[i] = s.GetFileTo(context.Background(), key, dstPath(t, s, fmt.Sprintf("f%d", i)), failing) + }) + } + started.Wait() + close(release) + done.Wait() + + for i, err := range errs { + if !errors.Is(err, fetchErr) { + t.Errorf("caller %d: %v, want the fetch error", i, err) + } + } + if calls.Load() != 1 { + t.Fatalf("failing fetch ran %d times, want 1", calls.Load()) + } + + // No debris and no published entry after the failure. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d children after failed fetch", len(tmpChildren)) + } + if _, err := os.Stat(s.entryDir(key)); !os.IsNotExist(err) { + t.Errorf("entry published despite failed fetch: err=%v", err) + } + + // No negative caching: the next call fetches fresh and succeeds. + ok, okCalls := countingFetcher("recovered") + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "retry"), ok); err != nil { + t.Fatalf("GetFileTo (retry): %v", err) + } + if okCalls.Load() != 1 { + t.Errorf("retry fetch ran %d times, want 1", okCalls.Load()) + } +} + +func TestGetFileToRejectsExistingDst(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("x") + + dst := dstPath(t, s, "occupied") + if err := os.WriteFile(dst, []byte("previous"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.GetFileTo(context.Background(), key, dst, fetch); err == nil { + t.Fatal("GetFileTo to existing dst succeeded, want error") + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != "previous" { + t.Errorf("existing dst clobbered: %q, %v", got, err) + } +} + +func TestGetFileToRejectsBadArguments(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, calls := countingFetcher("x") + + if err := s.GetFileTo(context.Background(), Key{}, dstPath(t, s, "zero"), fetch); err == nil { + t.Error("GetFileTo with zero key succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, "", fetch); err == nil { + t.Error("GetFileTo with empty dst succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, "relative/dst", fetch); err == nil { + t.Error("GetFileTo with relative dst succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "nilfetch"), nil); err == nil { + t.Error("GetFileTo with nil fetcher succeeded, want error") + } + if calls.Load() != 0 { + t.Errorf("fetch ran %d times for rejected arguments, want 0", calls.Load()) + } +} + +func TestGetFileToFetcherProducingNoFileFails(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + noop := func(ctx context.Context, dstPath string) error { return nil } + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "empty"), noop); err == nil { + t.Fatal("GetFileTo with file-less fetcher succeeded, want error") + } + if _, err := os.Stat(s.entryDir(key)); !os.IsNotExist(err) { + t.Errorf("entry published despite file-less fetcher: err=%v", err) + } +} + +func TestGetFileToHitTouchesLastUse(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("x") + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "first"), fetch); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "second"), fetch); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + if !fi.ModTime().After(stale.Add(time.Minute)) { + t.Errorf("entry mtime %v not refreshed by hit (stale mark %v)", fi.ModTime(), stale) + } +} + +func TestGetFileToPublishedCopyIsWriteProtected(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("precious") + + dst := dstPath(t, s, "d") + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatal(err) + } + // The mutation tripwire: writing through the consumer's link must fail, + // not silently poison the shared copy. + if err := os.WriteFile(dst, []byte("mutated"), 0o444); err == nil { + t.Error("write through consumer link succeeded, want permission error") + } +} diff --git a/cmd/atelet/internal/filecache/key.go b/cmd/atelet/internal/filecache/key.go new file mode 100644 index 0000000000..72102f8e71 --- /dev/null +++ b/cmd/atelet/internal/filecache/key.go @@ -0,0 +1,77 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// Key identifies a cache entry. It is an identity, not an address: it says +// nothing about where the bytes come from (the fetcher does), only what they +// are, so two sources serving the same identity share one entry. +// +// Keys are built via constructors only. The zero Key is invalid. +type Key struct { + // canonical is the unambiguous string form, recorded in the entry's + // meta.json for debugging. A constructor-specific prefix keeps the key + // spaces disjoint (a sha256 digest can never equal a URI key). + canonical string + // dir is hex(sha256(canonical)): the entry's directory name under + // entries/. Hashing (rather than escaping the canonical form) yields a + // fixed-length, filesystem-safe name for arbitrarily long keys, and lets + // a GC root set be matched against entry dirs by hashing its keys. + dir string +} + +// keyPartSeparator joins URIKey parts unambiguously: NUL cannot appear in a +// URI or file name, so ("a/b","c") and ("a","b/c") canonicalize differently. +const keyPartSeparator = "\x00" + +// SHA256Key returns the key for a content-addressed artifact, identified by +// the lowercase hex sha256 of its bytes. Uppercase digits are normalized so +// equal digests always yield equal keys. +func SHA256Key(hexDigest string) (Key, error) { + d := strings.ToLower(hexDigest) + if len(d) != sha256.Size*2 { + return Key{}, fmt.Errorf("sha256 key: digest %q has length %d, want %d", hexDigest, len(d), sha256.Size*2) + } + if _, err := hex.DecodeString(d); err != nil { + return Key{}, fmt.Errorf("sha256 key: digest %q is not hex: %w", hexDigest, err) + } + return newKey("sha256:" + d), nil +} + +// URIKey returns the key for an artifact identified by an immutable source, +// e.g. URIKey(goldenSnapshotURI, fileName). The parts must identify content +// that never changes underneath them; the cache has no invalidation, so a +// republished URI would serve stale bytes forever. Callers pass at least one +// non-empty part. +func URIKey(parts ...string) Key { + return newKey("uri:" + strings.Join(parts, keyPartSeparator)) +} + +func newKey(canonical string) Key { + sum := sha256.Sum256([]byte(canonical)) + return Key{canonical: canonical, dir: hex.EncodeToString(sum[:])} +} + +// String returns the canonical form, for logs and meta.json. +func (k Key) String() string { return k.canonical } + +// isZero reports whether k was not built by a constructor. +func (k Key) isZero() bool { return k.dir == "" } diff --git a/cmd/atelet/internal/filecache/key_test.go b/cmd/atelet/internal/filecache/key_test.go new file mode 100644 index 0000000000..ad27d9647c --- /dev/null +++ b/cmd/atelet/internal/filecache/key_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "regexp" + "strings" + "testing" +) + +const testDigest = "af2a7458c2c05df1a01d0b2f335f4849a2de84e83160fefdc31a6266015642d4" + +var entryDirNameRE = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func TestSHA256Key(t *testing.T) { + k, err := SHA256Key(testDigest) + if err != nil { + t.Fatalf("SHA256Key(%q): %v", testDigest, err) + } + if want := "sha256:" + testDigest; k.String() != want { + t.Errorf("String() = %q, want %q", k.String(), want) + } + if !entryDirNameRE.MatchString(k.dir) { + t.Errorf("dir = %q, want 64 lowercase hex chars", k.dir) + } + + upper, err := SHA256Key(strings.ToUpper(testDigest)) + if err != nil { + t.Fatalf("SHA256Key(upper): %v", err) + } + if upper != k { + t.Errorf("uppercase digest yielded a different key: %q vs %q", upper.dir, k.dir) + } +} + +func TestSHA256KeyRejectsBadDigests(t *testing.T) { + for _, digest := range []string{ + "", + "abc123", // too short + testDigest + "00", // too long + testDigest[:63] + "g", // not hex + } { + if _, err := SHA256Key(digest); err == nil { + t.Errorf("SHA256Key(%q) succeeded, want error", digest) + } + } +} + +func TestURIKey(t *testing.T) { + base := URIKey("gs://bucket/golden-v3", "mem.img") + if base != URIKey("gs://bucket/golden-v3", "mem.img") { + t.Error("equal parts yielded different keys") + } + if !entryDirNameRE.MatchString(base.dir) { + t.Errorf("dir = %q, want 64 lowercase hex chars", base.dir) + } + + // Part boundaries must be unambiguous: shifting a separator across a + // part boundary, or adding an empty part, is a different identity. + distinct := []Key{ + base, + URIKey("gs://bucket/golden-v3/mem.img"), + URIKey("gs://bucket/golden-v3", "mem.img", ""), + URIKey("gs://bucket", "golden-v3/mem.img"), + } + for i, a := range distinct { + for j, b := range distinct { + if i != j && a == b { + t.Errorf("keys %d and %d collide: %q", i, j, a.dir) + } + } + } +} + +func TestKeySpacesAreDisjoint(t *testing.T) { + // A URI that happens to spell a digest must not collide with the + // content-addressed key for that digest. + sha, err := SHA256Key(testDigest) + if err != nil { + t.Fatal(err) + } + if uri := URIKey(testDigest); uri == sha { + t.Errorf("URIKey and SHA256Key collide for %q", testDigest) + } +}