mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
hold/gc: stop an unreachable hold's manifests from being adopted
95d4f7cmade the predecessor check fail open so a five-second blip against a live predecessor could not drop its blobs out of the referenced set. That was right, but the boolean it flipped does two jobs: manifestBelongsToHold decides both "keep these blobs referenced" and "this manifest is ours", and for an unreachable hold those want different answers. The consequence showed up on hold01 the moment it started running this code. Five stale dev manifests pointing at did:web:localhost%3A8080 and did:web:172.28.0.3:8080 were adopted into knownManifests, and since hold01 had never stored them, every one of their ten layers was reported as a missing layer record. Worse than the noise: reconcileMissingRecords acts on exactly that list, so a Reconcile would have written io.atcr.hold.layer records asserting hold01 stores blobs for a localhost hold. These DIDs are loopback and RFC1918, so they can never resolve from a server. This is not a transient outage that clears itself on the next run. manifestBelongsToHold and isPredecessorHold now return (value, definitive), matching the idiom checkPredecessor already uses. An indefinite answer still carries the manifest so its blobs stay referenced, but marks it ProtectOnly, and analyzeRecords protects its digests without adding it to knownManifests — the same shape the in-grace takedown branch above it already uses. Left alone deliberately: the legacy holdEndpoint path still treats a resolve failure as a definitive "not ours". That predates95d4f7cand fails closed rather than open, so it is a different bug with a different blast radius. The regression test was verified to fail without the fix, reporting the adoption rather than a build error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWoKzpgtBJ33sCyGxJGR7x
This commit is contained in:
co-authored by
Claude Opus 5
parent
fd8e4b0bde
commit
264d332bbd
+50
-17
@@ -284,6 +284,10 @@ type manifestInfo struct {
|
|||||||
URI string // AT-URI of the manifest
|
URI string // AT-URI of the manifest
|
||||||
UserDID string // DID of the user who owns it
|
UserDID string // DID of the user who owns it
|
||||||
Record *atproto.ManifestRecord // Parsed manifest data
|
Record *atproto.ManifestRecord // Parsed manifest data
|
||||||
|
// ProtectOnly marks a manifest whose owning hold could not be reached, so
|
||||||
|
// whether it is ours is unknown. Its blobs stay referenced, but it is not
|
||||||
|
// adopted into knownManifests. See the ProtectOnly branch in analyzeRecords.
|
||||||
|
ProtectOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// analysisResult holds intermediate data from record analysis, shared between Run and Preview
|
// analysisResult holds intermediate data from record analysis, shared between Run and Preview
|
||||||
@@ -850,6 +854,23 @@ func (gc *GarbageCollector) analyzeRecords(ctx context.Context) (*analysisResult
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership was indefinite: the manifest names a hold we could not
|
||||||
|
// reach, so we cannot say it is ours. Protect its blobs exactly as an
|
||||||
|
// in-grace takedown does, but do not adopt it. Adopting would report
|
||||||
|
// every layer as a missing layer record and let reconcileMissingRecords
|
||||||
|
// write io.atcr.hold.layer records claiming another hold's content.
|
||||||
|
if m.ProtectOnly {
|
||||||
|
for _, layer := range m.Record.Layers {
|
||||||
|
result.referenced[layer.Digest] = true
|
||||||
|
}
|
||||||
|
if m.Record.Config != nil && m.Record.Config.Digest != "" {
|
||||||
|
result.referenced[m.Record.Config.Digest] = true
|
||||||
|
}
|
||||||
|
gc.logger.Debug("Manifest hold unreachable: blobs protected, ownership not claimed",
|
||||||
|
"manifest", m.URI, "holdDid", m.Record.HoldDID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
knownManifests[m.URI] = m
|
knownManifests[m.URI] = m
|
||||||
if d := extractDigestFromManifestURI(m.URI); d != "" {
|
if d := extractDigestFromManifestURI(m.URI); d != "" {
|
||||||
knownDigests[d] = true
|
knownDigests[d] = true
|
||||||
@@ -1762,11 +1783,12 @@ func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if gc.manifestBelongsToHold(ctx, &manifest, holdDID) {
|
if belongs, definitive := gc.manifestBelongsToHold(ctx, &manifest, holdDID); belongs {
|
||||||
manifests = append(manifests, &manifestInfo{
|
manifests = append(manifests, &manifestInfo{
|
||||||
URI: rec.URI,
|
URI: rec.URI,
|
||||||
UserDID: userDID,
|
UserDID: userDID,
|
||||||
Record: &manifest,
|
Record: &manifest,
|
||||||
|
ProtectOnly: !definitive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1782,12 +1804,23 @@ func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context,
|
|||||||
|
|
||||||
// manifestBelongsToHold checks if a manifest references this hold via HoldDID,
|
// manifestBelongsToHold checks if a manifest references this hold via HoldDID,
|
||||||
// legacy HoldEndpoint, or a predecessor hold that has been migrated.
|
// legacy HoldEndpoint, or a predecessor hold that has been migrated.
|
||||||
func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) bool {
|
//
|
||||||
|
// The second return value reports whether that answer is definitive. It is false
|
||||||
|
// only when the manifest's hold could not be reached, and "unreachable" is not the
|
||||||
|
// same claim as "ours": the blobs must stay referenced, because an outage must
|
||||||
|
// never make content deletable, but the manifest must not be adopted. Adopting it
|
||||||
|
// reports every one of its layers as a missing layer record and lets Reconcile
|
||||||
|
// write ownership records for another hold's content. A dev push to a hold that
|
||||||
|
// can never resolve — did:web:localhost:8080, an RFC1918 address — is permanently
|
||||||
|
// unreachable rather than briefly down, so this is not a transient state that
|
||||||
|
// clears itself. Callers must not read an indefinite answer as ownership; see the
|
||||||
|
// ProtectOnly branch in analyzeRecords.
|
||||||
|
func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) (belongs, definitive bool) {
|
||||||
manifestHoldDID := manifest.HoldDID
|
manifestHoldDID := manifest.HoldDID
|
||||||
|
|
||||||
// Direct match
|
// Direct match
|
||||||
if manifestHoldDID == holdDID {
|
if manifestHoldDID == holdDID {
|
||||||
return true
|
return true, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: resolve holdEndpoint to DID
|
// Legacy: resolve holdEndpoint to DID
|
||||||
@@ -1795,16 +1828,16 @@ func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest
|
|||||||
resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint)
|
resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err)
|
gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err)
|
||||||
return false
|
return false, true
|
||||||
}
|
}
|
||||||
manifestHoldDID = resolved
|
manifestHoldDID = resolved
|
||||||
if manifestHoldDID == holdDID {
|
if manifestHoldDID == holdDID {
|
||||||
return true
|
return true, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if manifestHoldDID == "" {
|
if manifestHoldDID == "" {
|
||||||
return false
|
return false, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the manifest's hold is a predecessor (has a successor label set)
|
// Check if the manifest's hold is a predecessor (has a successor label set)
|
||||||
@@ -1817,34 +1850,34 @@ func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest
|
|||||||
// reports true — the manifest is kept referenced — matching how this package
|
// reports true — the manifest is kept referenced — matching how this package
|
||||||
// already treats a user whose PDS cannot be reached: an outage must never be
|
// already treats a user whose PDS cannot be reached: an outage must never be
|
||||||
// the reason content becomes eligible for deletion.
|
// the reason content becomes eligible for deletion.
|
||||||
func (gc *GarbageCollector) isPredecessorHold(ctx context.Context, holdDID string) bool {
|
func (gc *GarbageCollector) isPredecessorHold(ctx context.Context, holdDID string) (isPredecessor, definitive bool) {
|
||||||
if gc.predecessorCache == nil {
|
if gc.predecessorCache == nil {
|
||||||
gc.predecessorCache = make(map[string]bool)
|
gc.predecessorCache = make(map[string]bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isPredecessor, cached := gc.predecessorCache[holdDID]; cached {
|
if cachedPredecessor, cached := gc.predecessorCache[holdDID]; cached {
|
||||||
return isPredecessor
|
return cachedPredecessor, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already inconclusive earlier in this run. Answer the same way without
|
// Already inconclusive earlier in this run. Answer the same way without
|
||||||
// paying another timeout; the next run starts fresh and re-checks.
|
// paying another timeout; the next run starts fresh and re-checks.
|
||||||
if gc.predecessorUnresolved[holdDID] {
|
if gc.predecessorUnresolved[holdDID] {
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
isPredecessor, definitive := gc.checkPredecessor(ctx, holdDID)
|
isPredecessor, definitive = gc.checkPredecessor(ctx, holdDID)
|
||||||
if !definitive {
|
if !definitive {
|
||||||
if gc.predecessorUnresolved == nil {
|
if gc.predecessorUnresolved == nil {
|
||||||
gc.predecessorUnresolved = make(map[string]bool)
|
gc.predecessorUnresolved = make(map[string]bool)
|
||||||
}
|
}
|
||||||
gc.predecessorUnresolved[holdDID] = true
|
gc.predecessorUnresolved[holdDID] = true
|
||||||
gc.logger.Warn("GC: predecessor status unresolved, treating hold's manifests as referenced",
|
gc.logger.Warn("GC: predecessor status unresolved, keeping hold's blobs referenced but not adopting its manifests",
|
||||||
"holdDID", holdDID)
|
"holdDID", holdDID)
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
gc.predecessorCache[holdDID] = isPredecessor
|
gc.predecessorCache[holdDID] = isPredecessor
|
||||||
return isPredecessor
|
return isPredecessor, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPredecessor fetches a hold's captain record to check if it has a successor label
|
// checkPredecessor fetches a hold's captain record to check if it has a successor label
|
||||||
|
|||||||
+109
-3
@@ -263,10 +263,13 @@ func TestManifestBelongsToHold(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := gc.manifestBelongsToHold(context.Background(), tt.manifest, holdDID)
|
got, definitive := gc.manifestBelongsToHold(context.Background(), tt.manifest, holdDID)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("manifestBelongsToHold() = %v, want %v", got, tt.want)
|
t.Errorf("manifestBelongsToHold() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
if !definitive {
|
||||||
|
t.Errorf("manifestBelongsToHold() definitive = false, want true: no hold lookup is involved in this case")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,10 +288,19 @@ func TestPredecessorUnresolvedStaysReferenced(t *testing.T) {
|
|||||||
const unreachable = "did:web:unreachable.invalid"
|
const unreachable = "did:web:unreachable.invalid"
|
||||||
manifest := &atproto.ManifestRecord{HoldDID: unreachable}
|
manifest := &atproto.ManifestRecord{HoldDID: unreachable}
|
||||||
|
|
||||||
if !gc.manifestBelongsToHold(context.Background(), manifest, holdDID) {
|
belongs, definitive := gc.manifestBelongsToHold(context.Background(), manifest, holdDID)
|
||||||
|
if !belongs {
|
||||||
t.Fatal("unreachable hold treated as not-a-predecessor: its blobs would be deleted")
|
t.Fatal("unreachable hold treated as not-a-predecessor: its blobs would be deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blobs stay referenced, but the answer must not masquerade as ownership:
|
||||||
|
// analyzeRecords keys its ProtectOnly branch off this, and adopting an
|
||||||
|
// unreachable hold's manifest reports phantom missing layer records and lets
|
||||||
|
// Reconcile write ownership records for another hold's content.
|
||||||
|
if definitive {
|
||||||
|
t.Error("unreachable hold reported as a definitive answer: its manifests would be adopted into knownManifests")
|
||||||
|
}
|
||||||
|
|
||||||
if _, cached := gc.predecessorCache[unreachable]; cached {
|
if _, cached := gc.predecessorCache[unreachable]; cached {
|
||||||
t.Error("inconclusive check cached in predecessorCache: the outage would outlive itself")
|
t.Error("inconclusive check cached in predecessorCache: the outage would outlive itself")
|
||||||
}
|
}
|
||||||
@@ -298,9 +310,13 @@ func TestPredecessorUnresolvedStaysReferenced(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second call must be answered from predecessorUnresolved, not re-dialled.
|
// Second call must be answered from predecessorUnresolved, not re-dialled.
|
||||||
if !gc.manifestBelongsToHold(context.Background(), manifest, holdDID) {
|
belongs, definitive = gc.manifestBelongsToHold(context.Background(), manifest, holdDID)
|
||||||
|
if !belongs {
|
||||||
t.Error("second call disagreed with the first")
|
t.Error("second call disagreed with the first")
|
||||||
}
|
}
|
||||||
|
if definitive {
|
||||||
|
t.Error("cached inconclusive answer reported as definitive")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A hold that answers and declares no successor is the one negative worth caching.
|
// A hold that answers and declares no successor is the one negative worth caching.
|
||||||
@@ -440,6 +456,96 @@ func TestFetchUserManifests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFetchUserManifests_UnreachableHoldIsProtectOnly pins the split between
|
||||||
|
// "keep these blobs referenced" and "this manifest is ours". A hold that cannot
|
||||||
|
// be reached must not be written off, or its blobs are deleted. But it must not
|
||||||
|
// be adopted either: adopting reports every layer of a foreign manifest as a
|
||||||
|
// missing layer record, and reconcileMissingRecords acts on exactly that list,
|
||||||
|
// writing io.atcr.hold.layer records that claim another hold's content.
|
||||||
|
//
|
||||||
|
// This is not hypothetical. A dev push to did:web:localhost:8080 leaves a
|
||||||
|
// manifest whose hold can never resolve from a server, so it is permanently
|
||||||
|
// indefinite rather than briefly down, and it surfaced on hold01 as ten missing
|
||||||
|
// layer records for manifests it had never stored.
|
||||||
|
func TestFetchUserManifests_UnreachableHoldIsProtectOnly(t *testing.T) {
|
||||||
|
holdDID := "did:web:hold.example.com"
|
||||||
|
// .invalid is reserved by RFC 2606 and never resolves, standing in for the
|
||||||
|
// localhost and RFC1918 hold DIDs that dev pushes leave behind.
|
||||||
|
const unreachable = "did:web:dev-push-stand-in.invalid"
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
response := map[string]any{
|
||||||
|
"records": []map[string]any{
|
||||||
|
{
|
||||||
|
"uri": "at://did:plc:user1/io.atcr.manifest/ours",
|
||||||
|
"cid": "bafyrei1",
|
||||||
|
"value": map[string]any{
|
||||||
|
"$type": "io.atcr.manifest",
|
||||||
|
"repository": "ours",
|
||||||
|
"digest": "sha256:ours",
|
||||||
|
"holdDid": holdDID,
|
||||||
|
"layers": []map[string]any{
|
||||||
|
{"digest": "sha256:ourlayer", "size": 1000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "at://did:plc:user1/io.atcr.manifest/devpush",
|
||||||
|
"cid": "bafyrei2",
|
||||||
|
"value": map[string]any{
|
||||||
|
"$type": "io.atcr.manifest",
|
||||||
|
"repository": "valtest",
|
||||||
|
"digest": "sha256:devpush",
|
||||||
|
"holdDid": unreachable,
|
||||||
|
"layers": []map[string]any{
|
||||||
|
{"digest": "sha256:devlayer", "size": 170},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
gc := &GarbageCollector{logger: newTestLogger()}
|
||||||
|
|
||||||
|
manifests, err := gc.fetchUserManifestsFromEndpoint(
|
||||||
|
t.Context(), "did:plc:user1", server.URL, holdDID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchUserManifestsFromEndpoint() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both survive: the unreachable one is still carried so its blobs stay
|
||||||
|
// referenced. Dropping it here is the failure 95d4f7c fixed.
|
||||||
|
if len(manifests) != 2 {
|
||||||
|
t.Fatalf("expected 2 manifests (ours, plus the unreachable one kept for blob protection), got %d", len(manifests))
|
||||||
|
}
|
||||||
|
|
||||||
|
byURI := make(map[string]*manifestInfo, len(manifests))
|
||||||
|
for _, m := range manifests {
|
||||||
|
byURI[m.URI] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
ours := byURI["at://did:plc:user1/io.atcr.manifest/ours"]
|
||||||
|
if ours == nil {
|
||||||
|
t.Fatal("our own manifest was dropped")
|
||||||
|
}
|
||||||
|
if ours.ProtectOnly {
|
||||||
|
t.Error("a directly-matching manifest was marked ProtectOnly: it would never be adopted, " +
|
||||||
|
"so its genuinely missing layer records would go unreported and unreconciled")
|
||||||
|
}
|
||||||
|
|
||||||
|
dev := byURI["at://did:plc:user1/io.atcr.manifest/devpush"]
|
||||||
|
if dev == nil {
|
||||||
|
t.Fatal("unreachable hold's manifest was dropped: its blobs fall out of the referenced set and are deleted")
|
||||||
|
}
|
||||||
|
if !dev.ProtectOnly {
|
||||||
|
t.Error("unreachable hold's manifest was adopted as ours: analyzeRecords would report its " +
|
||||||
|
"layers as missing layer records and Reconcile would write ownership records for another hold's content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchUserManifests_Pagination(t *testing.T) {
|
func TestFetchUserManifests_Pagination(t *testing.T) {
|
||||||
holdDID := "did:web:hold.example.com"
|
holdDID := "did:web:hold.example.com"
|
||||||
callCount := 0
|
callCount := 0
|
||||||
|
|||||||
Reference in New Issue
Block a user