mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
cleanup relay-compare script
This commit is contained in:
+131
-40
@@ -8,8 +8,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -17,7 +19,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
)
|
||||
|
||||
// ANSI color codes (disabled via --no-color or NO_COLOR env)
|
||||
@@ -37,16 +41,16 @@ func disableColors() {
|
||||
|
||||
// All io.atcr.* collections to compare
|
||||
var allCollections = []string{
|
||||
atproto.ManifestCollection, // io.atcr.manifest
|
||||
atproto.TagCollection, // io.atcr.tag
|
||||
atproto.SailorProfileCollection, // io.atcr.sailor.profile
|
||||
atproto.StarCollection, // io.atcr.sailor.star
|
||||
atproto.RepoPageCollection, // io.atcr.repo.page
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain
|
||||
atproto.CrewCollection, // io.atcr.hold.crew
|
||||
atproto.LayerCollection, // io.atcr.hold.layer
|
||||
atproto.StatsCollection, // io.atcr.hold.stats
|
||||
atproto.ScanCollection, // io.atcr.hold.scan
|
||||
"io.atcr.manifest",
|
||||
"io.atcr.tag",
|
||||
"io.atcr.sailor.profile",
|
||||
"io.atcr.sailor.star",
|
||||
"io.atcr.repo.page",
|
||||
"io.atcr.hold.captain",
|
||||
"io.atcr.hold.crew",
|
||||
"io.atcr.hold.layer",
|
||||
"io.atcr.hold.stats",
|
||||
"io.atcr.hold.scan",
|
||||
}
|
||||
|
||||
type summaryRow struct {
|
||||
@@ -76,9 +80,29 @@ type diffEntry struct {
|
||||
relayIdx int
|
||||
}
|
||||
|
||||
// XRPC response types for listReposByCollection
|
||||
type listReposByCollectionResult struct {
|
||||
Repos []repoRef `json:"repos"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
DID string `json:"did"`
|
||||
}
|
||||
|
||||
// XRPC response types for listRecords
|
||||
type listRecordsResult struct {
|
||||
Records []json.RawMessage `json:"records"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// Shared identity directory for DID resolution
|
||||
var dir identity.Directory
|
||||
|
||||
func main() {
|
||||
noColor := flag.Bool("no-color", false, "disable colored output")
|
||||
verify := flag.Bool("verify", false, "verify diffs against PDS to distinguish real gaps from ghost entries")
|
||||
hideGhosts := flag.Bool("hide-ghosts", false, "with --verify, hide ghost and deactivated entries from output")
|
||||
collection := flag.String("collection", "", "compare only this collection")
|
||||
timeout := flag.Duration("timeout", 2*time.Minute, "timeout for all relay queries")
|
||||
flag.Usage = func() {
|
||||
@@ -113,6 +137,8 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
dir = identity.DefaultDirectory()
|
||||
|
||||
// Short display names for each relay
|
||||
names := make([]string, len(relays))
|
||||
maxNameLen := 0
|
||||
@@ -253,6 +279,7 @@ func main() {
|
||||
fmt.Printf("\n %sMissing from %s (%d):%s\n", cRed, names[ri], len(missing), cReset)
|
||||
for _, did := range missing {
|
||||
suffix := ""
|
||||
skip := false
|
||||
if *verify {
|
||||
vr, ok := verified[key{col, did}]
|
||||
if !ok {
|
||||
@@ -263,6 +290,7 @@ func main() {
|
||||
suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset)
|
||||
row.deactivated++
|
||||
totalDeactivated++
|
||||
skip = *hideGhosts
|
||||
} else if vr.exists {
|
||||
suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset)
|
||||
row.realGaps++
|
||||
@@ -271,9 +299,12 @@ func main() {
|
||||
suffix = fmt.Sprintf(" %s← ghost (not on PDS)%s", cDim, cReset)
|
||||
row.ghosts++
|
||||
totalGhosts++
|
||||
skip = *hideGhosts
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s- %s%s%s\n", cRed, did, cReset, suffix)
|
||||
if !skip {
|
||||
fmt.Printf(" %s- %s%s%s\n", cRed, did, cReset, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +314,10 @@ func main() {
|
||||
}
|
||||
|
||||
if inSync {
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
notes := ""
|
||||
if !*hideGhosts {
|
||||
notes = formatSyncNotes(row.ghosts, row.deactivated)
|
||||
}
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
|
||||
} else {
|
||||
@@ -297,28 +331,38 @@ func main() {
|
||||
}
|
||||
|
||||
// Summary table
|
||||
printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated)
|
||||
printSummary(summary, names, maxNameLen, totalMissing, *verify, *hideGhosts, totalRealGaps, totalGhosts, totalDeactivated)
|
||||
}
|
||||
|
||||
func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) {
|
||||
func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify, hideGhosts bool, totalRealGaps, totalGhosts, totalDeactivated int) {
|
||||
fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset)
|
||||
|
||||
colW := 28
|
||||
relayW := maxNameLen + 2
|
||||
if relayW < 8 {
|
||||
relayW = 8
|
||||
// Build short labels (A, B, C, ...) for compact columns
|
||||
labels := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
labels[i] = string(rune('A' + i))
|
||||
fmt.Printf(" %s%s%s: %s\n", cBold, labels[i], cReset, name)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
colW := len("Collection")
|
||||
for _, row := range rows {
|
||||
if len(row.collection) > colW {
|
||||
colW = len(row.collection)
|
||||
}
|
||||
}
|
||||
relayW := 6
|
||||
|
||||
// Header
|
||||
fmt.Printf(" %-*s", colW, "Collection")
|
||||
for _, name := range names {
|
||||
fmt.Printf(" %*s", relayW, name)
|
||||
for _, label := range labels {
|
||||
fmt.Printf(" %*s", relayW, label)
|
||||
}
|
||||
fmt.Printf(" Status\n")
|
||||
|
||||
// Separator
|
||||
fmt.Printf(" %s", strings.Repeat("─", colW))
|
||||
for range names {
|
||||
for range labels {
|
||||
fmt.Printf(" %s", strings.Repeat("─", relayW))
|
||||
}
|
||||
fmt.Printf(" %s\n", strings.Repeat("─", 14))
|
||||
@@ -336,7 +380,10 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in
|
||||
}
|
||||
switch row.status {
|
||||
case "sync":
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
notes := ""
|
||||
if !hideGhosts {
|
||||
notes = formatSyncNotes(row.ghosts, row.deactivated)
|
||||
}
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset)
|
||||
} else {
|
||||
@@ -344,12 +391,16 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in
|
||||
}
|
||||
case "diff":
|
||||
if showVerify {
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
if notes != "" {
|
||||
notes = ", " + notes
|
||||
if hideGhosts {
|
||||
fmt.Printf(" %s≠ %d missing%s", cYellow, row.realGaps, cReset)
|
||||
} else {
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
if notes != "" {
|
||||
notes = ", " + notes
|
||||
}
|
||||
fmt.Printf(" %s≠ %d missing%s %s(%d real%s)%s",
|
||||
cYellow, row.realGaps, cReset, cDim, row.realGaps, notes, cReset)
|
||||
}
|
||||
fmt.Printf(" %s≠ %d missing%s %s(%s)%s",
|
||||
cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset)
|
||||
} else {
|
||||
fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset)
|
||||
}
|
||||
@@ -363,14 +414,20 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in
|
||||
fmt.Println()
|
||||
if totalMissing > 0 {
|
||||
if showVerify && totalRealGaps == 0 {
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
|
||||
if hideGhosts {
|
||||
fmt.Printf("%s✓ All relays in sync%s\n", cGreen, cReset)
|
||||
} else {
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
|
||||
}
|
||||
} else {
|
||||
if showVerify {
|
||||
fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset)
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s(%s)%s", cDim, notes, cReset)
|
||||
if !hideGhosts {
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s(%s)%s", cDim, notes, cReset)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
@@ -425,7 +482,7 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
pds, err := atproto.ResolveDIDToPDS(ctx, did)
|
||||
pds, err := resolveDIDToPDS(ctx, did)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
pdsErrors[did] = err
|
||||
@@ -466,8 +523,13 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
}
|
||||
|
||||
pds := pdsEndpoints[dc.did]
|
||||
client := atproto.NewClient(pds, "", "")
|
||||
records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "")
|
||||
client := &xrpc.Client{Host: pds, Client: http.DefaultClient}
|
||||
var listResult listRecordsResult
|
||||
err := client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", map[string]any{
|
||||
"repo": dc.did,
|
||||
"collection": dc.col,
|
||||
"limit": 1,
|
||||
}, nil, &listResult)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
@@ -480,7 +542,7 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
results[k] = verifyResult{err: err}
|
||||
}
|
||||
} else {
|
||||
results[k] = verifyResult{exists: len(records) > 0}
|
||||
results[k] = verifyResult{exists: len(listResult.Records) > 0}
|
||||
}
|
||||
mu.Unlock()
|
||||
}(dc)
|
||||
@@ -490,16 +552,45 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
return results
|
||||
}
|
||||
|
||||
// resolveDIDToPDS resolves a DID to its PDS endpoint using the shared identity directory.
|
||||
func resolveDIDToPDS(ctx context.Context, did string) (string, error) {
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid DID: %w", err)
|
||||
}
|
||||
|
||||
ident, err := dir.LookupDID(ctx, didParsed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve DID: %w", err)
|
||||
}
|
||||
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
if pdsEndpoint == "" {
|
||||
return "", fmt.Errorf("no PDS endpoint found for DID")
|
||||
}
|
||||
|
||||
return pdsEndpoint, nil
|
||||
}
|
||||
|
||||
// fetchAllDIDs paginates through listReposByCollection to collect all DIDs.
|
||||
func fetchAllDIDs(ctx context.Context, relay, collection string) (map[string]struct{}, error) {
|
||||
client := atproto.NewClient(relay, "", "")
|
||||
client := &xrpc.Client{Host: relay, Client: http.DefaultClient}
|
||||
dids := make(map[string]struct{})
|
||||
var cursor string
|
||||
|
||||
for {
|
||||
result, err := client.ListReposByCollection(ctx, collection, 1000, cursor)
|
||||
params := map[string]any{
|
||||
"collection": collection,
|
||||
"limit": 1000,
|
||||
}
|
||||
if cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
|
||||
var result listReposByCollectionResult
|
||||
err := client.LexDo(ctx, "GET", "", "com.atproto.sync.listReposByCollection", params, nil, &result)
|
||||
if err != nil {
|
||||
return dids, err
|
||||
return dids, fmt.Errorf("listReposByCollection failed: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range result.Repos {
|
||||
|
||||
Reference in New Issue
Block a user