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:
274
cmd/mcrctl/purge.go
Normal file
274
cmd/mcrctl/purge.go
Normal 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 = <
|
||||
} 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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user