All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
6.9 KiB
Go
275 lines
6.9 KiB
Go
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] + "..."
|
|
}
|