Files
2026-08-05 16:34:41 -04:00

219 lines
8.1 KiB
Go

package file_test
import (
"bytes"
"context"
"errors"
"io"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
"hauler.dev/go/hauler/v2/pkg/artifacts"
"hauler.dev/go/hauler/v2/pkg/artifacts/file"
"hauler.dev/go/hauler/v2/pkg/getter"
)
// countingGetter counts every Open call and, when failUntil > 0, fails the
// first failUntil calls before succeeding -- used to prove LayerCache
// doesn't permanently poison a path after a failed fetch.
type countingGetter struct {
data []byte
opens int32
mu sync.Mutex
failCount int
}
func (g *countingGetter) Open(ctx context.Context, u *url.URL) (io.ReadCloser, error) {
atomic.AddInt32(&g.opens, 1)
g.mu.Lock()
shouldFail := g.failCount > 0
if shouldFail {
g.failCount--
}
g.mu.Unlock()
if shouldFail {
return nil, errors.New("simulated transient fetch failure")
}
return io.NopCloser(bytes.NewReader(g.data)), nil
}
func (g *countingGetter) Detect(u *url.URL) bool { return true }
func (g *countingGetter) Name(u *url.URL) string { return "shared" }
func (g *countingGetter) Config(u *url.URL) artifacts.Config {
return artifacts.ToConfig(struct {
Reference string `json:"reference"`
}{u.String()}, artifacts.WithConfigMediaType("application/vnd.test.config"))
}
func newCountingClient(g *countingGetter, nameOverride string) *getter.Client {
return &getter.Client{
Options: getter.ClientOptions{NameOverride: nameOverride},
Getters: map[string]getter.Getter{"mock": g},
}
}
// TestLayerCache_DedupesFetchesAcrossFileInstances proves that two File
// instances sharing the same source Path -- e.g. two Files entries pointing
// at the identical URL, one plain and one with a name override, the shape
// testdata/hauler-manifest-pipeline.yaml uses -- fetch the underlying
// content exactly once when they share a *file.LayerCache via
// file.WithLayerCacheContext, even though each independently computes its
// own manifest.
func TestLayerCache_DedupesFetchesAcrossFileInstances(t *testing.T) {
g := &countingGetter{data: []byte("shared content")}
cache := file.NewLayerCache()
baseCtx := file.WithLayerCacheContext(context.Background(), cache)
f1 := file.NewFile("mock://shared/path", file.WithClient(newCountingClient(g, "")), file.WithContext(baseCtx))
f2 := file.NewFile("mock://shared/path", file.WithClient(newCountingClient(g, "renamed.sh")), file.WithContext(baseCtx))
if _, err := f1.Layers(); err != nil {
t.Fatalf("f1.Layers(): %v", err)
}
if _, err := f2.Layers(); err != nil {
t.Fatalf("f2.Layers(): %v", err)
}
if got := atomic.LoadInt32(&g.opens); got != 1 {
t.Errorf("expected exactly 1 Open call (layer.FromOpener opens once and reuses the digest as diffID) from a single shared fetch, got %d", got)
}
// Each instance still computes its own correct Title annotation despite
// sharing the underlying fetched layer.
m1, err := f1.Manifest()
if err != nil {
t.Fatalf("f1.Manifest(): %v", err)
}
m2, err := f2.Manifest()
if err != nil {
t.Fatalf("f2.Manifest(): %v", err)
}
if got := m1.Layers[0].Annotations["org.opencontainers.image.title"]; got != "shared" {
t.Errorf("f1 title = %q, want %q", got, "shared")
}
if got := m2.Layers[0].Annotations["org.opencontainers.image.title"]; got != "renamed.sh" {
t.Errorf("f2 title = %q, want %q", got, "renamed.sh")
}
}
// TestLayerCache_DoesNotPoisonPathAfterFailure proves a failed fetch is not
// cached forever: a second File instance for the same path (simulating a
// retry.Operation retry, which reuses compute()'s memoization only within a
// single File -- a fresh File, as a new job attempt would construct, must
// still be able to succeed once the underlying transient failure clears).
func TestLayerCache_DoesNotPoisonPathAfterFailure(t *testing.T) {
g := &countingGetter{data: []byte("recovered content"), failCount: 1}
cache := file.NewLayerCache()
baseCtx := file.WithLayerCacheContext(context.Background(), cache)
f1 := file.NewFile("mock://flaky/path", file.WithClient(newCountingClient(g, "")), file.WithContext(baseCtx))
if _, err := f1.Layers(); err == nil {
t.Fatal("expected f1.Layers() to fail on the simulated first attempt, got nil error")
}
f2 := file.NewFile("mock://flaky/path", file.WithClient(newCountingClient(g, "")), file.WithContext(baseCtx))
if _, err := f2.Layers(); err != nil {
t.Fatalf("expected f2.Layers() to succeed after the transient failure cleared, got: %v", err)
}
if got := atomic.LoadInt32(&g.opens); got != 2 {
t.Errorf("expected exactly 2 Open calls (1 failed attempt + 1 for the succeeding attempt, since layer.FromOpener now opens once), got %d", got)
}
}
// TestLayerCache_NilCacheInContext_FetchesNormally proves compute() falls
// back to a direct, uncached fetch when no LayerCache is attached to ctx --
// the common case (a single `store add file` call, or any File built
// without file.WithLayerCacheContext).
func TestLayerCache_NilCacheInContext_FetchesNormally(t *testing.T) {
g := &countingGetter{data: []byte("content")}
f := file.NewFile("mock://uncached/path", file.WithClient(newCountingClient(g, "")))
if _, err := f.Layers(); err != nil {
t.Fatalf("Layers(): %v", err)
}
if got := atomic.LoadInt32(&g.opens); got != 1 {
t.Errorf("expected 1 Open call (layer.FromOpener opens once and reuses the digest as diffID), got %d", got)
}
}
// TestLayerCache_DedupesConcurrentOverlappingFetches proves the dedup also
// holds when two File instances for the same path race concurrently (not
// just sequentially, as TestLayerCache_DedupesFetchesAcrossFileInstances
// exercises) -- the shape a --concurrency > 1 sync run produces when a
// manifest lists the same source twice. Uses a blocking getter to force
// genuine overlap: both goroutines are guaranteed to be inside compute() at
// the same time before either is allowed to finish.
func TestLayerCache_DedupesConcurrentOverlappingFetches(t *testing.T) {
g := &blockingGetter{release: make(chan struct{}), data: []byte("raced content")}
cache := file.NewLayerCache()
baseCtx := file.WithLayerCacheContext(context.Background(), cache)
var opens int32
countingOpen := func(ctx context.Context, u *url.URL) (io.ReadCloser, error) {
atomic.AddInt32(&opens, 1)
return g.Open(ctx, u)
}
countingGetterFn := &fnGetter{open: countingOpen, name: "raced"}
newClient := func(nameOverride string) *getter.Client {
return &getter.Client{
Options: getter.ClientOptions{NameOverride: nameOverride},
Getters: map[string]getter.Getter{"mock": countingGetterFn},
}
}
f1 := file.NewFile("mock://raced/path", file.WithClient(newClient("")), file.WithContext(baseCtx))
f2 := file.NewFile("mock://raced/path", file.WithClient(newClient("second.sh")), file.WithContext(baseCtx))
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() { defer wg.Done(); _, err := f1.Layers(); errs <- err }()
go func() { defer wg.Done(); _, err := f2.Layers(); errs <- err }()
// Give both goroutines a chance to actually enter Open (and block on
// g.release) before releasing them -- proving they were genuinely
// in-flight concurrently, not accidentally serialized by the test.
time.Sleep(100 * time.Millisecond)
close(g.release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("Layers(): %v", err)
}
}
if got := atomic.LoadInt32(&opens); got != 1 {
t.Errorf("expected exactly 1 Open call (one shared fetch, opened once by layer.FromOpener) despite two concurrently-racing File instances, got %d", got)
}
}
// fnGetter adapts a plain Open func into a getter.Getter for tests that need
// to wrap another getter's Open behavior (e.g. to count calls) without
// re-implementing Detect/Name/Config.
type fnGetter struct {
open func(context.Context, *url.URL) (io.ReadCloser, error)
name string
}
func (g *fnGetter) Open(ctx context.Context, u *url.URL) (io.ReadCloser, error) {
return g.open(ctx, u)
}
func (g *fnGetter) Detect(u *url.URL) bool { return true }
func (g *fnGetter) Name(u *url.URL) string { return g.name }
func (g *fnGetter) Config(u *url.URL) artifacts.Config {
return artifacts.ToConfig(struct {
Reference string `json:"reference"`
}{u.String()}, artifacts.WithConfigMediaType("application/vnd.test.config"))
}