Red phase for #51: covers strict cache_max_bytes parsing (invalid explicit values abort naming key and value), the computed default of max(75% of free space, 500 MiB) via an injectable free-space probe, explicit-value-no-floor, zero-disables-cache, size accounting over source blobs and variants, LRU eviction under the limit, the multi-referenced blob case, write-pressure and periodic eviction triggers, and startup reconciliation. Minimal API skeletons keep the tree compiling and lint-clean; only the new tests fail.
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// discardLogger returns a logger that swallows all output, for tests
|
|
// that exercise code paths which log.
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
|
// explicitly configured cache_max_bytes value is used exactly as
|
|
// given: the 500 MiB floor applies only to the computed default, never
|
|
// to explicit values.
|
|
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
|
}
|
|
|
|
if c.CacheMaxBytes != 1024 {
|
|
t.Errorf("CacheMaxBytes = %d, want 1024 (no floor for explicit values)",
|
|
c.CacheMaxBytes)
|
|
}
|
|
}
|
|
|
|
// TestCacheMaxBytesZeroIsValidAndDisablesCache verifies that an
|
|
// explicit zero is a valid value (it disables the disk cache), not an
|
|
// error.
|
|
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
|
}
|
|
|
|
if c.CacheMaxBytes != 0 {
|
|
t.Errorf("CacheMaxBytes = %d, want 0", c.CacheMaxBytes)
|
|
}
|
|
}
|
|
|
|
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
|
|
// 32-bit range parse correctly (the field is an int64 byte count).
|
|
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 10737418240\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("large cache_max_bytes must be accepted, got error: %v", err)
|
|
}
|
|
|
|
if c.CacheMaxBytes != 10737418240 {
|
|
t.Errorf("CacheMaxBytes = %d, want 10737418240", c.CacheMaxBytes)
|
|
}
|
|
}
|
|
|
|
// TestCacheMaxBytesInvalidValuesAbortStartup verifies that a SET but
|
|
// invalid cache_max_bytes value aborts startup naming the key and the
|
|
// offending value, per the no-silent-fallback rule: defaults apply
|
|
// only to omitted keys.
|
|
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
|
|
|
cases := []struct {
|
|
name string
|
|
yaml string
|
|
// wantErrSubstrings must all appear in the error message.
|
|
wantErrSubstrings []string
|
|
}{
|
|
{
|
|
name: "negative",
|
|
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "-1024"},
|
|
},
|
|
{
|
|
name: "float",
|
|
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "3.5"},
|
|
},
|
|
{
|
|
name: "non-numeric string",
|
|
yaml: signingKeyLine + "cache_max_bytes: banana\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "banana"},
|
|
},
|
|
{
|
|
name: "explicit null",
|
|
yaml: signingKeyLine + "cache_max_bytes: null\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
|
},
|
|
{
|
|
name: "bare key no value",
|
|
yaml: signingKeyLine + "cache_max_bytes:\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
|
},
|
|
{
|
|
name: "boolean",
|
|
yaml: signingKeyLine + "cache_max_bytes: true\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes", "true"},
|
|
},
|
|
{
|
|
name: "list",
|
|
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
|
|
wantErrSubstrings: []string{"cache_max_bytes"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c, err := configFromYAML(t, tc.yaml)
|
|
if err == nil {
|
|
t.Fatalf("config with %s cache_max_bytes must abort startup, got config: %+v",
|
|
tc.name, c)
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
for _, want := range tc.wantErrSubstrings {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
|
// computed default is 75% of the probed free space when that exceeds
|
|
// the floor.
|
|
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
|
// 4 GiB free -> 3 GiB default.
|
|
probe := func(string) (uint64, error) { return 4294967296, nil }
|
|
|
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
|
if err != nil {
|
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
|
}
|
|
|
|
if got != 3221225472 {
|
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)", got)
|
|
}
|
|
}
|
|
|
|
// TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault
|
|
// verifies that when 75% of free space is below 500 MiB, the computed
|
|
// default is floored at DefaultCacheMaxBytesFloor.
|
|
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
freeBytes uint64
|
|
}{
|
|
{name: "100 MiB free", freeBytes: 104857600},
|
|
{name: "zero free", freeBytes: 0},
|
|
{name: "just below floor threshold", freeBytes: 699050665},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
|
|
|
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
|
if err != nil {
|
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
|
}
|
|
|
|
if got != DefaultCacheMaxBytesFloor {
|
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want floor %d",
|
|
got, DefaultCacheMaxBytesFloor)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeDefaultCacheMaxBytesPropagatesProbeError verifies that a
|
|
// failing free-space probe produces an error naming the config key,
|
|
// instead of a silently wrong default.
|
|
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
|
probe := func(string) (uint64, error) { return 0, errors.New("statfs failed") }
|
|
|
|
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
|
if err == nil {
|
|
t.Fatal("probe failure must produce an error, got nil")
|
|
}
|
|
|
|
t.Logf("got expected error: %v", err)
|
|
|
|
if !strings.Contains(err.Error(), "cache_max_bytes") {
|
|
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestResolveCacheMaxBytesComputesDefaultWhenOmitted verifies that an
|
|
// omitted cache_max_bytes key resolves to the computed default, that
|
|
// the probe is pointed at <state_dir>/cache/ (which must be created
|
|
// first so statfs measures the right filesystem), and that the result
|
|
// lands on the Config.
|
|
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
|
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
|
if err != nil {
|
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
|
}
|
|
|
|
c.StateDir = t.TempDir()
|
|
wantCacheDir := filepath.Join(c.StateDir, "cache")
|
|
|
|
var probedPath string
|
|
|
|
// 4 GiB free -> 3 GiB default.
|
|
probe := func(path string) (uint64, error) {
|
|
probedPath = path
|
|
|
|
return 4294967296, nil
|
|
}
|
|
|
|
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
|
}
|
|
|
|
if c.CacheMaxBytes != 3221225472 {
|
|
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472", c.CacheMaxBytes)
|
|
}
|
|
|
|
if probedPath != wantCacheDir {
|
|
t.Errorf("free space probed at %q, want cache directory %q", probedPath, wantCacheDir)
|
|
}
|
|
|
|
info, err := os.Stat(wantCacheDir)
|
|
if err != nil || !info.IsDir() {
|
|
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
|
wantCacheDir, info, err)
|
|
}
|
|
}
|
|
|
|
// TestResolveCacheMaxBytesDoesNotOverrideExplicitValue verifies that
|
|
// an explicitly configured value survives resolution untouched and
|
|
// that the free-space probe is never consulted for it.
|
|
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
|
|
|
c, err := configFromYAML(t, yamlContent)
|
|
if err != nil {
|
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
|
}
|
|
|
|
c.StateDir = t.TempDir()
|
|
|
|
probe := func(string) (uint64, error) {
|
|
t.Error("free-space probe must not be consulted for explicit values")
|
|
|
|
return 0, errors.New("probe must not be called")
|
|
}
|
|
|
|
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
|
}
|
|
|
|
if c.CacheMaxBytes != 1024 {
|
|
t.Errorf("CacheMaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
|
c.CacheMaxBytes)
|
|
}
|
|
}
|