Add mcrctl purge command for tag retention

Client-side purge that keeps the last N tags per repository (excluding
latest) and deletes older manifests. Uses existing MCR APIs — no new
server RPCs needed.

Server-side: added updated_at to TagInfo struct and GetRepositoryDetail
query so tags can be sorted by recency.

Usage: mcrctl purge --keep 3 --dry-run --gc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:42:28 -07:00
parent 078dd39052
commit 296bbc5357
7 changed files with 1156 additions and 221 deletions

274
cmd/mcrctl/purge.go Normal file
View File

@@ -0,0 +1,274 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"sort"
"strings"
"github.com/spf13/cobra"
mcrv1 "git.wntrmute.dev/kyle/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 = &lt
} 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] + "..."
}