···11+// usage-report queries a hold service and generates a storage usage report
22+// grouped by user, with unique layers and totals.
33+//
44+// Usage:
55+//
66+// go run ./cmd/usage-report --hold https://hold01.atcr.io
77+// go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests
88+package main
99+1010+import (
1111+ "encoding/json"
1212+ "flag"
1313+ "fmt"
1414+ "io"
1515+ "net/http"
1616+ "net/url"
1717+ "os"
1818+ "sort"
1919+ "strings"
2020+ "time"
2121+)
2222+2323+// LayerRecord matches the io.atcr.hold.layer record structure
2424+type LayerRecord struct {
2525+ Type string `json:"$type"`
2626+ Digest string `json:"digest"`
2727+ Size int64 `json:"size"`
2828+ MediaType string `json:"mediaType"`
2929+ Manifest string `json:"manifest"`
3030+ UserDID string `json:"userDid"`
3131+ CreatedAt string `json:"createdAt"`
3232+}
3333+3434+// ManifestRecord matches the io.atcr.manifest record structure
3535+type ManifestRecord struct {
3636+ Type string `json:"$type"`
3737+ Repository string `json:"repository"`
3838+ Digest string `json:"digest"`
3939+ HoldDID string `json:"holdDid"`
4040+ Config *struct {
4141+ Digest string `json:"digest"`
4242+ Size int64 `json:"size"`
4343+ } `json:"config"`
4444+ Layers []struct {
4545+ Digest string `json:"digest"`
4646+ Size int64 `json:"size"`
4747+ MediaType string `json:"mediaType"`
4848+ } `json:"layers"`
4949+ Manifests []struct {
5050+ Digest string `json:"digest"`
5151+ Size int64 `json:"size"`
5252+ } `json:"manifests"`
5353+ CreatedAt string `json:"createdAt"`
5454+}
5555+5656+// CrewRecord matches the io.atcr.hold.crew record structure
5757+type CrewRecord struct {
5858+ Member string `json:"member"`
5959+ Role string `json:"role"`
6060+ Permissions []string `json:"permissions"`
6161+ AddedAt string `json:"addedAt"`
6262+}
6363+6464+// ListRecordsResponse is the response from com.atproto.repo.listRecords
6565+type ListRecordsResponse struct {
6666+ Records []struct {
6767+ URI string `json:"uri"`
6868+ CID string `json:"cid"`
6969+ Value json.RawMessage `json:"value"`
7070+ } `json:"records"`
7171+ Cursor string `json:"cursor,omitempty"`
7272+}
7373+7474+// UserUsage tracks storage for a single user
7575+type UserUsage struct {
7676+ DID string
7777+ Handle string
7878+ UniqueLayers map[string]int64 // digest -> size
7979+ TotalSize int64
8080+ LayerCount int
8181+ Repositories map[string]bool // unique repos
8282+}
8383+8484+var client = &http.Client{Timeout: 30 * time.Second}
8585+8686+func main() {
8787+ holdURL := flag.String("hold", "https://hold01.atcr.io", "Hold service URL")
8888+ fromManifests := flag.Bool("from-manifests", false, "Calculate usage from user manifests instead of hold layer records (more accurate but slower)")
8989+ flag.Parse()
9090+9191+ // Normalize URL
9292+ baseURL := strings.TrimSuffix(*holdURL, "/")
9393+9494+ fmt.Printf("Querying %s...\n\n", baseURL)
9595+9696+ // First, get the hold's DID
9797+ holdDID, err := getHoldDID(baseURL)
9898+ if err != nil {
9999+ fmt.Fprintf(os.Stderr, "Failed to get hold DID: %v\n", err)
100100+ os.Exit(1)
101101+ }
102102+ fmt.Printf("Hold DID: %s\n\n", holdDID)
103103+104104+ var userUsage map[string]*UserUsage
105105+106106+ if *fromManifests {
107107+ fmt.Println("=== Calculating from user manifests (bypasses layer record bug) ===")
108108+ userUsage, err = calculateFromManifests(baseURL, holdDID)
109109+ } else {
110110+ fmt.Println("=== Calculating from hold layer records ===")
111111+ fmt.Println("NOTE: May undercount app-password users due to layer record bug")
112112+ fmt.Println(" Use --from-manifests for more accurate results")
113113+114114+ userUsage, err = calculateFromLayerRecords(baseURL, holdDID)
115115+ }
116116+117117+ if err != nil {
118118+ fmt.Fprintf(os.Stderr, "Failed to calculate usage: %v\n", err)
119119+ os.Exit(1)
120120+ }
121121+122122+ // Resolve DIDs to handles
123123+ fmt.Println("\n\nResolving DIDs to handles...")
124124+ for _, usage := range userUsage {
125125+ handle, err := resolveDIDToHandle(usage.DID)
126126+ if err != nil {
127127+ usage.Handle = usage.DID
128128+ } else {
129129+ usage.Handle = handle
130130+ }
131131+ }
132132+133133+ // Convert to slice and sort by total size (descending)
134134+ var sorted []*UserUsage
135135+ for _, u := range userUsage {
136136+ sorted = append(sorted, u)
137137+ }
138138+ sort.Slice(sorted, func(i, j int) bool {
139139+ return sorted[i].TotalSize > sorted[j].TotalSize
140140+ })
141141+142142+ // Print report
143143+ fmt.Println("\n========================================")
144144+ fmt.Println("STORAGE USAGE REPORT")
145145+ fmt.Println("========================================")
146146+147147+ var grandTotal int64
148148+ var grandLayers int
149149+ for _, u := range sorted {
150150+ grandTotal += u.TotalSize
151151+ grandLayers += u.LayerCount
152152+ }
153153+154154+ fmt.Printf("\nTotal Users: %d\n", len(sorted))
155155+ fmt.Printf("Total Unique Layers: %d\n", grandLayers)
156156+ fmt.Printf("Total Storage: %s\n\n", humanSize(grandTotal))
157157+158158+ fmt.Println("BY USER (sorted by storage):")
159159+ fmt.Println("----------------------------------------")
160160+ for i, u := range sorted {
161161+ fmt.Printf("%3d. %s\n", i+1, u.Handle)
162162+ fmt.Printf(" DID: %s\n", u.DID)
163163+ fmt.Printf(" Unique Layers: %d\n", u.LayerCount)
164164+ fmt.Printf(" Total Size: %s\n", humanSize(u.TotalSize))
165165+ if len(u.Repositories) > 0 {
166166+ var repos []string
167167+ for r := range u.Repositories {
168168+ repos = append(repos, r)
169169+ }
170170+ sort.Strings(repos)
171171+ fmt.Printf(" Repositories: %s\n", strings.Join(repos, ", "))
172172+ }
173173+ pct := float64(0)
174174+ if grandTotal > 0 {
175175+ pct = float64(u.TotalSize) / float64(grandTotal) * 100
176176+ }
177177+ fmt.Printf(" Share: %.1f%%\n\n", pct)
178178+ }
179179+180180+ // Output CSV format for easy analysis
181181+ fmt.Println("\n========================================")
182182+ fmt.Println("CSV FORMAT")
183183+ fmt.Println("========================================")
184184+ fmt.Println("handle,did,unique_layers,total_bytes,total_human,repositories")
185185+ for _, u := range sorted {
186186+ var repos []string
187187+ for r := range u.Repositories {
188188+ repos = append(repos, r)
189189+ }
190190+ sort.Strings(repos)
191191+ fmt.Printf("%s,%s,%d,%d,%s,\"%s\"\n", u.Handle, u.DID, u.LayerCount, u.TotalSize, humanSize(u.TotalSize), strings.Join(repos, ";"))
192192+ }
193193+}
194194+195195+// calculateFromLayerRecords uses the hold's layer records (original method)
196196+func calculateFromLayerRecords(baseURL, holdDID string) (map[string]*UserUsage, error) {
197197+ layers, err := fetchAllLayerRecords(baseURL, holdDID)
198198+ if err != nil {
199199+ return nil, err
200200+ }
201201+202202+ fmt.Printf("Fetched %d layer records\n", len(layers))
203203+204204+ userUsage := make(map[string]*UserUsage)
205205+ for _, layer := range layers {
206206+ if layer.UserDID == "" {
207207+ continue
208208+ }
209209+210210+ usage, exists := userUsage[layer.UserDID]
211211+ if !exists {
212212+ usage = &UserUsage{
213213+ DID: layer.UserDID,
214214+ UniqueLayers: make(map[string]int64),
215215+ Repositories: make(map[string]bool),
216216+ }
217217+ userUsage[layer.UserDID] = usage
218218+ }
219219+220220+ if _, seen := usage.UniqueLayers[layer.Digest]; !seen {
221221+ usage.UniqueLayers[layer.Digest] = layer.Size
222222+ usage.TotalSize += layer.Size
223223+ usage.LayerCount++
224224+ }
225225+ }
226226+227227+ return userUsage, nil
228228+}
229229+230230+// calculateFromManifests queries crew members and fetches their manifests from their PDSes
231231+func calculateFromManifests(baseURL, holdDID string) (map[string]*UserUsage, error) {
232232+ // Get all crew members
233233+ crewDIDs, err := fetchCrewMembers(baseURL, holdDID)
234234+ if err != nil {
235235+ return nil, fmt.Errorf("failed to fetch crew: %w", err)
236236+ }
237237+238238+ // Also get captain
239239+ captainDID, err := fetchCaptain(baseURL, holdDID)
240240+ if err == nil && captainDID != "" {
241241+ // Add captain to list if not already there
242242+ found := false
243243+ for _, d := range crewDIDs {
244244+ if d == captainDID {
245245+ found = true
246246+ break
247247+ }
248248+ }
249249+ if !found {
250250+ crewDIDs = append(crewDIDs, captainDID)
251251+ }
252252+ }
253253+254254+ fmt.Printf("Found %d users (crew + captain)\n", len(crewDIDs))
255255+256256+ userUsage := make(map[string]*UserUsage)
257257+258258+ for _, did := range crewDIDs {
259259+ fmt.Printf(" Checking manifests for %s...", did)
260260+261261+ // Resolve DID to PDS
262262+ pdsEndpoint, err := resolveDIDToPDS(did)
263263+ if err != nil {
264264+ fmt.Printf(" (failed to resolve PDS: %v)\n", err)
265265+ continue
266266+ }
267267+268268+ // Fetch manifests that use this hold
269269+ manifests, err := fetchUserManifestsForHold(pdsEndpoint, did, holdDID)
270270+ if err != nil {
271271+ fmt.Printf(" (failed to fetch manifests: %v)\n", err)
272272+ continue
273273+ }
274274+275275+ if len(manifests) == 0 {
276276+ fmt.Printf(" 0 manifests\n")
277277+ continue
278278+ }
279279+280280+ // Calculate unique layers across all manifests
281281+ usage := &UserUsage{
282282+ DID: did,
283283+ UniqueLayers: make(map[string]int64),
284284+ Repositories: make(map[string]bool),
285285+ }
286286+287287+ for _, m := range manifests {
288288+ usage.Repositories[m.Repository] = true
289289+290290+ // Add config blob
291291+ if m.Config != nil {
292292+ if _, seen := usage.UniqueLayers[m.Config.Digest]; !seen {
293293+ usage.UniqueLayers[m.Config.Digest] = m.Config.Size
294294+ usage.TotalSize += m.Config.Size
295295+ usage.LayerCount++
296296+ }
297297+ }
298298+299299+ // Add layers
300300+ for _, layer := range m.Layers {
301301+ if _, seen := usage.UniqueLayers[layer.Digest]; !seen {
302302+ usage.UniqueLayers[layer.Digest] = layer.Size
303303+ usage.TotalSize += layer.Size
304304+ usage.LayerCount++
305305+ }
306306+ }
307307+ }
308308+309309+ fmt.Printf(" %d manifests, %d unique layers, %s\n", len(manifests), usage.LayerCount, humanSize(usage.TotalSize))
310310+311311+ if usage.LayerCount > 0 {
312312+ userUsage[did] = usage
313313+ }
314314+ }
315315+316316+ return userUsage, nil
317317+}
318318+319319+// fetchCrewMembers gets all crew member DIDs from the hold
320320+func fetchCrewMembers(baseURL, holdDID string) ([]string, error) {
321321+ var dids []string
322322+ seen := make(map[string]bool)
323323+324324+ cursor := ""
325325+ for {
326326+ u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords", baseURL)
327327+ params := url.Values{}
328328+ params.Set("repo", holdDID)
329329+ params.Set("collection", "io.atcr.hold.crew")
330330+ params.Set("limit", "100")
331331+ if cursor != "" {
332332+ params.Set("cursor", cursor)
333333+ }
334334+335335+ resp, err := client.Get(u + "?" + params.Encode())
336336+ if err != nil {
337337+ return nil, err
338338+ }
339339+340340+ var listResp ListRecordsResponse
341341+ if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
342342+ resp.Body.Close()
343343+ return nil, err
344344+ }
345345+ resp.Body.Close()
346346+347347+ for _, rec := range listResp.Records {
348348+ var crew CrewRecord
349349+ if err := json.Unmarshal(rec.Value, &crew); err != nil {
350350+ continue
351351+ }
352352+ if crew.Member != "" && !seen[crew.Member] {
353353+ seen[crew.Member] = true
354354+ dids = append(dids, crew.Member)
355355+ }
356356+ }
357357+358358+ if listResp.Cursor == "" || len(listResp.Records) < 100 {
359359+ break
360360+ }
361361+ cursor = listResp.Cursor
362362+ }
363363+364364+ return dids, nil
365365+}
366366+367367+// fetchCaptain gets the captain DID from the hold
368368+func fetchCaptain(baseURL, holdDID string) (string, error) {
369369+ u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=io.atcr.hold.captain&rkey=self",
370370+ baseURL, url.QueryEscape(holdDID))
371371+372372+ resp, err := client.Get(u)
373373+ if err != nil {
374374+ return "", err
375375+ }
376376+ defer resp.Body.Close()
377377+378378+ if resp.StatusCode != http.StatusOK {
379379+ return "", fmt.Errorf("status %d", resp.StatusCode)
380380+ }
381381+382382+ var result struct {
383383+ Value struct {
384384+ Owner string `json:"owner"`
385385+ } `json:"value"`
386386+ }
387387+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
388388+ return "", err
389389+ }
390390+391391+ return result.Value.Owner, nil
392392+}
393393+394394+// fetchUserManifestsForHold fetches all manifests from a user's PDS that use the specified hold
395395+func fetchUserManifestsForHold(pdsEndpoint, userDID, holdDID string) ([]ManifestRecord, error) {
396396+ var manifests []ManifestRecord
397397+ cursor := ""
398398+399399+ for {
400400+ u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords", pdsEndpoint)
401401+ params := url.Values{}
402402+ params.Set("repo", userDID)
403403+ params.Set("collection", "io.atcr.manifest")
404404+ params.Set("limit", "100")
405405+ if cursor != "" {
406406+ params.Set("cursor", cursor)
407407+ }
408408+409409+ resp, err := client.Get(u + "?" + params.Encode())
410410+ if err != nil {
411411+ return nil, err
412412+ }
413413+414414+ if resp.StatusCode != http.StatusOK {
415415+ resp.Body.Close()
416416+ return nil, fmt.Errorf("status %d", resp.StatusCode)
417417+ }
418418+419419+ var listResp ListRecordsResponse
420420+ if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
421421+ resp.Body.Close()
422422+ return nil, err
423423+ }
424424+ resp.Body.Close()
425425+426426+ for _, rec := range listResp.Records {
427427+ var m ManifestRecord
428428+ if err := json.Unmarshal(rec.Value, &m); err != nil {
429429+ continue
430430+ }
431431+ // Only include manifests for this hold
432432+ if m.HoldDID == holdDID {
433433+ manifests = append(manifests, m)
434434+ }
435435+ }
436436+437437+ if listResp.Cursor == "" || len(listResp.Records) < 100 {
438438+ break
439439+ }
440440+ cursor = listResp.Cursor
441441+ }
442442+443443+ return manifests, nil
444444+}
445445+446446+// getHoldDID fetches the hold's DID from /.well-known/atproto-did
447447+func getHoldDID(baseURL string) (string, error) {
448448+ resp, err := http.Get(baseURL + "/.well-known/atproto-did")
449449+ if err != nil {
450450+ return "", err
451451+ }
452452+ defer resp.Body.Close()
453453+454454+ if resp.StatusCode != http.StatusOK {
455455+ return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
456456+ }
457457+458458+ body, err := io.ReadAll(resp.Body)
459459+ if err != nil {
460460+ return "", err
461461+ }
462462+463463+ return strings.TrimSpace(string(body)), nil
464464+}
465465+466466+// fetchAllLayerRecords fetches all layer records with pagination
467467+func fetchAllLayerRecords(baseURL, holdDID string) ([]LayerRecord, error) {
468468+ var allLayers []LayerRecord
469469+ cursor := ""
470470+ limit := 100
471471+472472+ for {
473473+ u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords", baseURL)
474474+ params := url.Values{}
475475+ params.Set("repo", holdDID)
476476+ params.Set("collection", "io.atcr.hold.layer")
477477+ params.Set("limit", fmt.Sprintf("%d", limit))
478478+ if cursor != "" {
479479+ params.Set("cursor", cursor)
480480+ }
481481+482482+ fullURL := u + "?" + params.Encode()
483483+ fmt.Printf(" Fetching: %s\n", fullURL)
484484+485485+ resp, err := client.Get(fullURL)
486486+ if err != nil {
487487+ return nil, fmt.Errorf("request failed: %w", err)
488488+ }
489489+490490+ if resp.StatusCode != http.StatusOK {
491491+ body, _ := io.ReadAll(resp.Body)
492492+ resp.Body.Close()
493493+ return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
494494+ }
495495+496496+ var listResp ListRecordsResponse
497497+ if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
498498+ resp.Body.Close()
499499+ return nil, fmt.Errorf("decode failed: %w", err)
500500+ }
501501+ resp.Body.Close()
502502+503503+ for _, rec := range listResp.Records {
504504+ var layer LayerRecord
505505+ if err := json.Unmarshal(rec.Value, &layer); err != nil {
506506+ fmt.Fprintf(os.Stderr, "Warning: failed to parse layer record: %v\n", err)
507507+ continue
508508+ }
509509+ allLayers = append(allLayers, layer)
510510+ }
511511+512512+ fmt.Printf(" Got %d records (total: %d)\n", len(listResp.Records), len(allLayers))
513513+514514+ if listResp.Cursor == "" || len(listResp.Records) < limit {
515515+ break
516516+ }
517517+ cursor = listResp.Cursor
518518+ }
519519+520520+ return allLayers, nil
521521+}
522522+523523+// resolveDIDToHandle resolves a DID to a handle using the PLC directory or did:web
524524+func resolveDIDToHandle(did string) (string, error) {
525525+ if strings.HasPrefix(did, "did:web:") {
526526+ return strings.TrimPrefix(did, "did:web:"), nil
527527+ }
528528+529529+ if strings.HasPrefix(did, "did:plc:") {
530530+ plcURL := "https://plc.directory/" + did
531531+ resp, err := client.Get(plcURL)
532532+ if err != nil {
533533+ return "", fmt.Errorf("PLC query failed: %w", err)
534534+ }
535535+ defer resp.Body.Close()
536536+537537+ if resp.StatusCode != http.StatusOK {
538538+ return "", fmt.Errorf("PLC returned status %d", resp.StatusCode)
539539+ }
540540+541541+ var plcDoc struct {
542542+ AlsoKnownAs []string `json:"alsoKnownAs"`
543543+ }
544544+ if err := json.NewDecoder(resp.Body).Decode(&plcDoc); err != nil {
545545+ return "", fmt.Errorf("failed to parse PLC response: %w", err)
546546+ }
547547+548548+ for _, aka := range plcDoc.AlsoKnownAs {
549549+ if strings.HasPrefix(aka, "at://") {
550550+ return strings.TrimPrefix(aka, "at://"), nil
551551+ }
552552+ }
553553+554554+ return did, nil
555555+ }
556556+557557+ return did, nil
558558+}
559559+560560+// resolveDIDToPDS resolves a DID to its PDS endpoint
561561+func resolveDIDToPDS(did string) (string, error) {
562562+ if strings.HasPrefix(did, "did:web:") {
563563+ // did:web:example.com -> https://example.com
564564+ domain := strings.TrimPrefix(did, "did:web:")
565565+ return "https://" + domain, nil
566566+ }
567567+568568+ if strings.HasPrefix(did, "did:plc:") {
569569+ plcURL := "https://plc.directory/" + did
570570+ resp, err := client.Get(plcURL)
571571+ if err != nil {
572572+ return "", fmt.Errorf("PLC query failed: %w", err)
573573+ }
574574+ defer resp.Body.Close()
575575+576576+ if resp.StatusCode != http.StatusOK {
577577+ return "", fmt.Errorf("PLC returned status %d", resp.StatusCode)
578578+ }
579579+580580+ var plcDoc struct {
581581+ Service []struct {
582582+ ID string `json:"id"`
583583+ Type string `json:"type"`
584584+ ServiceEndpoint string `json:"serviceEndpoint"`
585585+ } `json:"service"`
586586+ }
587587+ if err := json.NewDecoder(resp.Body).Decode(&plcDoc); err != nil {
588588+ return "", fmt.Errorf("failed to parse PLC response: %w", err)
589589+ }
590590+591591+ for _, svc := range plcDoc.Service {
592592+ if svc.Type == "AtprotoPersonalDataServer" {
593593+ return svc.ServiceEndpoint, nil
594594+ }
595595+ }
596596+597597+ return "", fmt.Errorf("no PDS found in DID document")
598598+ }
599599+600600+ return "", fmt.Errorf("unsupported DID method")
601601+}
602602+603603+// humanSize converts bytes to human-readable format
604604+func humanSize(bytes int64) string {
605605+ const (
606606+ KB = 1024
607607+ MB = 1024 * KB
608608+ GB = 1024 * MB
609609+ TB = 1024 * GB
610610+ )
611611+612612+ switch {
613613+ case bytes >= TB:
614614+ return fmt.Sprintf("%.2f TB", float64(bytes)/TB)
615615+ case bytes >= GB:
616616+ return fmt.Sprintf("%.2f GB", float64(bytes)/GB)
617617+ case bytes >= MB:
618618+ return fmt.Sprintf("%.2f MB", float64(bytes)/MB)
619619+ case bytes >= KB:
620620+ return fmt.Sprintf("%.2f KB", float64(bytes)/KB)
621621+ default:
622622+ return fmt.Sprintf("%d B", bytes)
623623+ }
624624+}
+3-1
pkg/appview/storage/manifest_store.go
···224224225225 // Notify hold about manifest push (for layer tracking, Bluesky posts, and stats)
226226 // Do this asynchronously to avoid blocking the push
227227- if tag != "" && s.ctx.ServiceToken != "" && s.ctx.Handle != "" {
227227+ // Note: We notify even for tagless pushes (e.g., buildx platform manifests) to create layer records
228228+ // Bluesky posts are only created for tagged pushes (handled by hold service)
229229+ if s.ctx.ServiceToken != "" && s.ctx.Handle != "" {
228230 go func() {
229231 defer func() {
230232 if r := recover(); r != nil {
+3-2
pkg/hold/oci/xrpc.go
···350350 }
351351 }
352352353353- // Create Bluesky post if enabled
354354- if postsEnabled {
353353+ // Create Bluesky post if enabled and tag is present
354354+ // Skip posts for tagless pushes (e.g., buildx platform manifests pushed by digest)
355355+ if postsEnabled && req.Tag != "" {
355356 // Resolve handle from DID (cached, 24-hour TTL)
356357 _, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID)
357358 if resolveErr != nil {