package engine import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/spindle/models" "tangled.org/core/workflow" ) func identityTestCommit(t *testing.T, dir string, files map[string]string, msg string) string { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } run := func(args ...string) string { t.Helper() cmd := exec.Command("git", append([]string{ "-C", dir, "-c", "user.email=t@t", "-c", "user.name=t", "-c", "init.defaultBranch=main", }, args...)...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return strings.TrimSpace(string(out)) } if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { run("init") } for name, content := range files { p := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } } run("add", "-A") run("commit", "-m", msg) return run("rev-parse", "HEAD") } func identityTestPush(sha, ref string) *tangled.Pipeline_TriggerMetadata { return &tangled.Pipeline_TriggerMetadata{ Kind: string(workflow.TriggerKindPush), Push: &tangled.Pipeline_PushTriggerData{NewSha: sha, Ref: ref}, } } func identityTestPlan(t *testing.T, c CacheController, repoDid syntax.DID, name, ref, sha string, entries []models.CacheEntry) []models.CacheBinding { t.Helper() bindings, err := c.Plan(context.Background(), &models.Pipeline{ RepoDid: repoDid, TrustedSource: true, TriggerMetadata: identityTestPush(sha, ref), }, &models.Workflow{Name: name, Engine: "microvm", Caches: entries}) if err != nil { t.Fatalf("Plan: %v", err) } return bindings } func identityTestSave(t *testing.T, c CacheController, b models.CacheBinding) { t.Helper() if err := c.Apply(context.Background(), CacheUpdate{ Action: CacheStored, ID: b.SaveID, Ref: b.SaveKey, SizeBytes: 100, Checksum: "sum-" + b.SaveID, }); err != nil { t.Fatalf("mark %q ready: %v", b.Key, err) } } func newIdentityTestController(t *testing.T) (CacheController, string, syntax.DID, string) { t.Helper() d := newCacheTestDB(t) repoDid, _ := addCacheTestRepo(t, d) root := t.TempDir() repoPath := filepath.Join(root, repoDid.String()) c := NewLocalCacheController(d, &fakeStorage{objects: make(map[string][]byte)}, root, 0, 0, discardLogger) return c, root, repoDid, repoPath } func TestCacheIdentitySharedAcrossCommits(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) entries := []models.CacheEntry{{Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}}} revA := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":1}`}, "A") a := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries)[0] if a.RestoreKey != "" { t.Fatalf("first run restored %q, want a miss", a.RestoreKey) } identityTestSave(t, c, a) revB := identityTestCommit(t, repoPath, map[string]string{"file.txt": "console.log(1)"}, "B: code only") b := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revB, entries)[0] if b.RestoreKey != a.SaveKey { t.Fatalf("commit B restore = %q, want previous commit's %q", b.RestoreKey, a.SaveKey) } } func TestCacheIdentityRestoresPreviousGeneration(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) entries := []models.CacheEntry{{Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}}} revA := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":1}`}, "A") a := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries)[0] identityTestSave(t, c, a) revC := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":2}`}, "C: manifest bump") got := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revC, entries)[0] if got.Hash == a.Hash { t.Fatal("manifest bump did not rotate the hash") } if got.RestoreKey != a.SaveKey { t.Fatalf("restore = %q, want the previous generation %q", got.RestoreKey, a.SaveKey) } if got.RestoreName == "" { t.Fatal("fallback restore missing a name") } } func TestCacheIdentityDoesNotCrossEntries(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) entries := []models.CacheEntry{ {Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}}, {Key: "tools", Hash: []string{"go.mod"}, Paths: []string{"/cache/tools"}}, } revA := identityTestCommit(t, repoPath, map[string]string{ "go.sum": `{"v":1}`, "go.mod": "lockfileVersion: '9.0'", }, "A") first := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries) if first[0].Key != "deps" || first[1].Key != "tools" { t.Fatalf("unexpected binding order: %q, %q", first[0].Key, first[1].Key) } identityTestSave(t, c, first[0]) identityTestSave(t, c, first[1]) // only deps' hashed file changes, so only deps rotates revB := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":2}`}, "B") second := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revB, entries) if second[0].RestoreKey != first[0].SaveKey { t.Fatalf("deps restore = %q, want its own %q (not tools' %q)", second[0].RestoreKey, first[0].SaveKey, first[1].SaveKey) } if second[1].RestoreKey != first[1].SaveKey { t.Fatalf("tools restore = %q, want its own %q", second[1].RestoreKey, first[1].SaveKey) } } func TestCacheIdentityFallbackDoesNotCrossKeys(t *testing.T) { d := newCacheTestDB(t) repoDid, _ := addCacheTestRepo(t, d) root := t.TempDir() repoPath := filepath.Join(root, repoDid.String()) controller := NewLocalCacheController(d, &fakeStorage{objects: make(map[string][]byte)}, root, 0, 0, discardLogger) rev := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":1}`}, "A") // sibling key, saved after anything this workflow has saved insertCacheTestEntry(t, d, cacheTestEntry( "sibling", repoDid.String(), "microvm/"+models.CacheNamespace(), cacheIdentityKey("build.yml", "refs/heads/main", "tools"), "sibling-hash", "ready", time.Now().Add(time.Hour), )) got := identityTestPlan(t, controller, repoDid, "build.yml", "refs/heads/main", rev, []models.CacheEntry{ {Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}}, })[0] if got.RestoreKey != "" { t.Fatalf("deps restored %q, want a miss", got.RestoreKey) } } func TestCacheIdentityScopedToRefAndWorkflow(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) entries := []models.CacheEntry{{Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}}} revA := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":1}`}, "A") a := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries)[0] identityTestSave(t, c, a) if got := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/other", revA, entries)[0]; got.RestoreKey != "" { t.Fatalf("cache leaked across refs: %q", got.RestoreKey) } if got := identityTestPlan(t, c, repoDid, "other.yml", "refs/heads/main", revA, entries)[0]; got.RestoreKey != "" { t.Fatalf("cache leaked across workflows: %q", got.RestoreKey) } } func TestCacheIdentityFallbackOptOut(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) off := false entries := []models.CacheEntry{{Key: "deps", Hash: []string{"go.sum"}, Paths: []string{"/cache/deps"}, Fallback: &off}} revA := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":1}`}, "A") a := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries)[0] identityTestSave(t, c, a) revC := identityTestCommit(t, repoPath, map[string]string{"go.sum": `{"v":2}`}, "C") if got := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revC, entries)[0]; got.RestoreKey != "" { t.Fatalf("opted-out cache restored %q", got.RestoreKey) } } func TestCacheIdentityNoHashSharedAcrossCommits(t *testing.T) { c, _, repoDid, repoPath := newIdentityTestController(t) entries := []models.CacheEntry{{Key: "always", Paths: []string{"/cache/always"}}} revA := identityTestCommit(t, repoPath, map[string]string{"file.txt": "a"}, "A") a := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revA, entries)[0] identityTestSave(t, c, a) revB := identityTestCommit(t, repoPath, map[string]string{"file.txt": "b"}, "B") if got := identityTestPlan(t, c, repoDid, "build.yml", "refs/heads/main", revB, entries)[0]; got.RestoreKey != a.SaveKey { t.Fatalf("restore = %q, want %q", got.RestoreKey, a.SaveKey) } } func TestCacheIdentityMissingHashFiles(t *testing.T) { d := newCacheTestDB(t) repoPath := filepath.Join(t.TempDir(), "repo") rev := identityTestCommit(t, repoPath, map[string]string{"go.mod": "module x"}, "A") ctx := context.Background() partial := resolveCachesIdentity(ctx, discardLogger, d, "did:plc:repo", "microvm", repoPath, rev, "wf", "refs/heads/main", []models.CacheEntry{ {Key: "go-mod", Hash: []string{"go.sum", "go.mod"}, Paths: []string{"/x"}}, })[0] if partial.Hash == "" { t.Fatal("partial hash is empty, want the present file hashed") } if partial.RestoreKey != "" { t.Fatalf("partial restore = %q, want a miss", partial.RestoreKey) } none := resolveCachesIdentity(ctx, discardLogger, d, "did:plc:repo", "microvm", repoPath, rev, "wf", "refs/heads/main", []models.CacheEntry{ {Key: "go-mod", Hash: []string{"nope.lock"}, Paths: []string{"/x"}}, })[0] if none.Hash != "" || none.RestoreKey != "" { t.Fatalf("all-missing = hash %q, restore %q", none.Hash, none.RestoreKey) } }