package main import ( "context" "encoding/json" "fmt" "os" "path" "sort" "strings" "github.com/spf13/cobra" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" ) func purgeCmd() *cobra.Command { var ( keep int repo string dryRun bool trigGC bool ) cmd := &cobra.Command{ Use: "purge", Short: "Remove old image tags, keeping the last N per repository", Long: `Purge removes old image tags from repositories based on a retention policy. The 'latest' tag is always kept. For each repository, tags are sorted by updated_at (most recent first) and only the --keep most recent are retained. Remaining tags' manifests are deleted, cascading to their tag references. Run with --gc to trigger garbage collection afterward.`, RunE: func(_ *cobra.Command, _ []string) error { return runPurge(keep, repo, dryRun, trigGC) }, } cmd.Flags().IntVar(&keep, "keep", 3, "number of tags to keep per repo (excluding latest)") cmd.Flags().StringVar(&repo, "repo", "", "limit to repositories matching this glob pattern") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting") cmd.Flags().BoolVar(&trigGC, "gc", false, "trigger garbage collection after purging") return cmd } // tagWithTime is a tag with its timestamp for sorting. type tagWithTime struct { Name string Digest string UpdatedAt string } func runPurge(keep int, repoPattern string, dryRun, trigGC bool) error { if keep < 0 { return fmt.Errorf("--keep must be non-negative") } // List repositories. repos, err := listRepos() if err != nil { return err } // Filter by pattern if given. if repoPattern != "" { var filtered []string for _, r := range repos { matched, _ := path.Match(repoPattern, r) if matched { filtered = append(filtered, r) } } repos = filtered } if len(repos) == 0 { _, _ = fmt.Fprintln(os.Stdout, "No repositories matched.") return nil } totalDeleted := 0 for _, repoName := range repos { tags, err := listTagsWithTime(repoName) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", repoName, err) continue } // Separate latest from versioned tags. var latest *tagWithTime var versioned []tagWithTime for _, t := range tags { if t.Name == "latest" { lt := t latest = < } else { versioned = append(versioned, t) } } // Sort by updated_at descending (most recent first). sort.Slice(versioned, func(i, j int) bool { return versioned[i].UpdatedAt > versioned[j].UpdatedAt }) // Determine which tags to delete. if len(versioned) <= keep { continue // nothing to purge } toDelete := versioned[keep:] // Build set of digests that are still referenced by kept tags. keptDigests := make(map[string]bool) if latest != nil { keptDigests[latest.Digest] = true } for _, t := range versioned[:keep] { keptDigests[t.Digest] = true } for _, t := range toDelete { if keptDigests[t.Digest] { // Manifest is shared with a kept tag — skip deletion. if dryRun { _, _ = fmt.Fprintf(os.Stdout, "[skip] %s:%s (manifest shared with kept tag)\n", repoName, t.Name) } continue } if dryRun { _, _ = fmt.Fprintf(os.Stdout, "[delete] %s:%s (digest: %s, updated: %s)\n", repoName, t.Name, t.Digest[:12], t.UpdatedAt) } else { if err := deleteManifest(repoName, t.Digest); err != nil { _, _ = fmt.Fprintf(os.Stderr, "warning: failed to delete %s:%s: %v\n", repoName, t.Name, err) continue } _, _ = fmt.Fprintf(os.Stdout, "deleted %s:%s\n", repoName, t.Name) } totalDeleted++ } } if dryRun { _, _ = fmt.Fprintf(os.Stdout, "\nDry run: %d manifests would be deleted.\n", totalDeleted) } else { _, _ = fmt.Fprintf(os.Stdout, "\nPurged %d manifests.\n", totalDeleted) } // Trigger GC if requested and not a dry run. if trigGC && !dryRun && totalDeleted > 0 { _, _ = fmt.Fprintln(os.Stdout, "Triggering garbage collection...") if err := triggerGC(); err != nil { return fmt.Errorf("gc: %w", err) } _, _ = fmt.Fprintln(os.Stdout, "Garbage collection started.") } return nil } // listRepos returns all repository names. func listRepos() ([]string, error) { if client.useGRPC() { resp, err := client.registry.ListRepositories(context.Background(), &mcrv1.ListRepositoriesRequest{}) if err != nil { return nil, fmt.Errorf("list repositories: %w", err) } var names []string for _, r := range resp.GetRepositories() { names = append(names, r.GetName()) } return names, nil } data, err := client.restDo("GET", "/v1/repositories", nil) if err != nil { return nil, fmt.Errorf("list repositories: %w", err) } var repos []struct { Name string `json:"name"` } if err := json.Unmarshal(data, &repos); err != nil { return nil, err } var names []string for _, r := range repos { names = append(names, r.Name) } return names, nil } // listTagsWithTime returns tags with timestamps for a repository. func listTagsWithTime(repoName string) ([]tagWithTime, error) { if client.useGRPC() { resp, err := client.registry.GetRepository(context.Background(), &mcrv1.GetRepositoryRequest{Name: repoName}) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } var tags []tagWithTime for _, t := range resp.GetTags() { tags = append(tags, tagWithTime{ Name: t.GetName(), Digest: t.GetDigest(), UpdatedAt: t.GetUpdatedAt(), }) } return tags, nil } data, err := client.restDo("GET", "/v1/repositories/"+repoName, nil) if err != nil { return nil, fmt.Errorf("get repository: %w", err) } var detail struct { Tags []struct { Name string `json:"name"` Digest string `json:"digest"` UpdatedAt string `json:"updated_at"` } `json:"tags"` } if err := json.Unmarshal(data, &detail); err != nil { return nil, err } var tags []tagWithTime for _, t := range detail.Tags { tags = append(tags, tagWithTime{ Name: t.Name, Digest: t.Digest, UpdatedAt: t.UpdatedAt, }) } return tags, nil } // deleteManifest deletes a manifest by digest via the OCI API. func deleteManifest(repoName, digest string) error { if client.useGRPC() { // No gRPC RPC for OCI manifest delete — fall back to REST. // The OCI Distribution spec uses DELETE /v2/{name}/manifests/{reference}. } // URL-encode the repo name for path segments with slashes. _, err := client.restDo("DELETE", "/v2/"+repoName+"/manifests/"+digest, nil) return err } // triggerGC starts garbage collection. func triggerGC() error { if client.useGRPC() { _, err := client.registry.GarbageCollect(context.Background(), &mcrv1.GarbageCollectRequest{}) return err } _, err := client.restDo("POST", "/v1/gc", nil) return err } // formatRepoName truncates long repo names for display. func formatRepoName(name string, maxLen int) string { if len(name) <= maxLen { return name } parts := strings.Split(name, "/") if len(parts) > 1 { return "..." + parts[len(parts)-1] } return name[:maxLen-3] + "..." }