mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
6.9 KiB
Go
251 lines
6.9 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// seedCascadeFixture inserts a user and a single manifest. Returns the
|
|
// manifest's row id so callers can attach references (for the multi-arch case).
|
|
func seedCascadeFixture(t *testing.T, db *sql.DB, didStr, repo, digest string) string {
|
|
t.Helper()
|
|
|
|
user := &User{
|
|
DID: didStr,
|
|
Handle: "tester.example.com",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("UpsertUser: %v", err)
|
|
}
|
|
|
|
id, err := InsertManifest(db, &Manifest{
|
|
DID: didStr,
|
|
Repository: repo,
|
|
Digest: digest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestGetTagDigest_ReturnsDigestForKnownTag(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:tagdigest"
|
|
const repo = "myapp"
|
|
const digest = "sha256:aaa"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "latest",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
got, err := GetTagDigest(db, did, repo, "latest")
|
|
if err != nil {
|
|
t.Fatalf("GetTagDigest: %v", err)
|
|
}
|
|
if got != digest {
|
|
t.Errorf("digest mismatch: got %q want %q", got, digest)
|
|
}
|
|
}
|
|
|
|
func TestGetTagDigest_UnknownTagReturnsErrNoRows(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
seedCascadeFixture(t, db, "did:plc:tagdigest2", "myapp", "sha256:bbb")
|
|
|
|
_, err = GetTagDigest(db, "did:plc:tagdigest2", "myapp", "does-not-exist")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Errorf("expected sql.ErrNoRows for unknown tag, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_LastTagAndNoParent: the common case —
|
|
// digest has no remaining tags and is not referenced by any manifest list.
|
|
// Cascade should fire.
|
|
func TestShouldCascadeDeleteManifest_LastTagAndNoParent(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade1"
|
|
const repo = "myapp"
|
|
const digest = "sha256:lonely"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
// No tags pointing to this digest, no manifest_references entries.
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("expected cascade=true when manifest is untagged and unreferenced")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_RemainingTagBlocks: another tag still
|
|
// points to this digest → keep the manifest alive.
|
|
func TestShouldCascadeDeleteManifest_RemainingTagBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade2"
|
|
const repo = "myapp"
|
|
const digest = "sha256:shared"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "v1",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when another tag still points to the digest")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks: the same digest is
|
|
// tagged in a *different* repository of the same user. The io.atcr.manifest
|
|
// record is keyed by digest alone, so one record backs both repos — cascading
|
|
// here would delete the record out from under the other repo and purge the
|
|
// shared layers on the hold, breaking an image the user never touched.
|
|
func TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade4"
|
|
const deletingRepo = "myapp"
|
|
const otherRepo = "myapp-mirror"
|
|
const digest = "sha256:sharedacrossrepos"
|
|
|
|
// Same content pushed to two repositories.
|
|
seedCascadeFixture(t, db, did, deletingRepo, digest)
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: did,
|
|
Repository: otherRepo,
|
|
Digest: digest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("InsertManifest(otherRepo): %v", err)
|
|
}
|
|
|
|
// Only the other repo still carries a tag. The deleting repo has none.
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: otherRepo,
|
|
Tag: "v1",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when the digest is still tagged in another repository")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_MultiArchChildBlocks: the digest is a child
|
|
// of a manifest list (multi-arch parent). Even with no tags, deleting it
|
|
// would orphan the parent's reference, so we must NOT cascade.
|
|
func TestShouldCascadeDeleteManifest_MultiArchChildBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade3"
|
|
const repo = "myapp"
|
|
const childDigest = "sha256:amd64child"
|
|
const parentDigest = "sha256:multiarchparent"
|
|
|
|
// Insert the child manifest fixture.
|
|
seedCascadeFixture(t, db, did, repo, childDigest)
|
|
|
|
// Insert a separate parent (manifest list) and attach a manifest_reference
|
|
// from parent → child.
|
|
parentID, err := InsertManifest(db, &Manifest{
|
|
DID: did,
|
|
Repository: repo,
|
|
Digest: parentDigest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest(parent): %v", err)
|
|
}
|
|
|
|
if err := InsertManifestReference(db, &ManifestReference{
|
|
ManifestKey: parentID,
|
|
Digest: childDigest,
|
|
Size: 1234,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformArchitecture: "amd64",
|
|
PlatformOS: "linux",
|
|
ReferenceIndex: 0,
|
|
}); err != nil {
|
|
t.Fatalf("InsertManifestReference: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, childDigest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when digest is a child of a manifest list")
|
|
}
|
|
}
|