Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 91 additions & 11 deletions cmd/y-cluster/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -31,24 +32,48 @@ func imagesCmd() *cobra.Command {
}

func imagesListCmd() *cobra.Command {
var contextName string
var format string
var sortKey string

cmd := &cobra.Command{
Use: "list <yaml-file|->",
Short: "Print every container image referenced by a Kubernetes YAML stream",
Long: `Read a YAML stream and print every image reference found in any
PodSpec (Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet,
Pod). Output is sorted, deduplicated, one ref per line — suitable for
piping to xargs or a downstream tool.
Use: "list [<yaml-file>|-]",
Short: "Print images referenced by a YAML stream or stored in a cluster",
Long: `Two input modes; mutually exclusive.

Input source is a positional argument:
YAML mode (positional argument):
<path> read the file at path
- read stdin
Prints every image reference found in any PodSpec
(Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet,
Pod). Output is sorted, deduplicated, one ref per line —
suitable for piping to xargs or a downstream tool.
Pipe a kustomize build through it:
kubectl kustomize ./base | y-cluster images list -

To extract images from a kustomize tree, pipe the build through:
kubectl kustomize ./base | y-cluster images list -
Cluster mode (--context=<ctx>):
Queries the cluster's containerd k8s.io namespace and prints
one row per stored manifest, sorted by descending compressed
size by default. Digest-aliases of the same manifest are
collapsed (no double-count). Use this to answer "what's
taking the space in this appliance qcow2".
Default output is a SIZE/IMAGE table; --format=json emits
[{ref, digest, size_bytes, size_human}]. --sort=name switches
to alphabetical.

Exit codes: 0 on success, 1 on YAML parse / I/O error, 2 on usage.`,
Args: cobra.ExactArgs(1),
Exit codes: 0 on success, 1 on YAML parse / I/O / cluster
error, 2 on usage.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 && contextName != "" {
return fmt.Errorf("--context is mutually exclusive with positional input")
}
if len(args) == 0 && contextName == "" {
return fmt.Errorf("specify a positional input (<path>|-) or --context=<ctx>")
}
if contextName != "" {
return runListFromCluster(cmd, contextName, format, sortKey)
}
r, closer, err := openYAMLInput(args[0], cmd.InOrStdin())
if err != nil {
return err
Expand All @@ -65,9 +90,64 @@ Exit codes: 0 on success, 1 on YAML parse / I/O error, 2 on usage.`,
return nil
},
}
cmd.Flags().StringVar(&contextName, "context", "", "kubeconfig context — query the cluster's containerd (mutex with positional input)")
cmd.Flags().StringVar(&format, "format", "table", "cluster mode output format: table|json")
cmd.Flags().StringVar(&sortKey, "sort", "size", "cluster mode sort key: size (desc) | name (asc)")
return cmd
}

func runListFromCluster(cmd *cobra.Command, contextName, format, sortKey string) error {
ctx := cmd.Context()
lr, err := cluster.Lookup(ctx, "", contextName)
if err != nil {
return err
}
rows, err := images.ListFromCluster(ctx, lr)
if err != nil {
return err
}
switch sortKey {
case "", "size":
images.SortClusterImagesBySizeDesc(rows)
case "name":
images.SortClusterImagesByName(rows)
default:
return fmt.Errorf("--sort: unknown value %q (size|name)", sortKey)
}
out := cmd.OutOrStdout()
switch format {
case "", "table":
return writeClusterImagesTable(out, rows)
case "json":
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(rows)
default:
return fmt.Errorf("--format: unknown value %q (table|json)", format)
}
}

// writeClusterImagesTable renders one row per stored manifest
// as "SIZE IMAGE", with the SIZE column padded to the widest
// value so refs line up. Matches the spec's example output.
func writeClusterImagesTable(w io.Writer, rows []images.ClusterImage) error {
sizeWidth := len("SIZE")
for _, r := range rows {
if l := len(r.SizeHuman); l > sizeWidth {
sizeWidth = l
}
}
if _, err := fmt.Fprintf(w, "%-*s %s\n", sizeWidth, "SIZE", "IMAGE"); err != nil {
return err
}
for _, r := range rows {
if _, err := fmt.Fprintf(w, "%-*s %s\n", sizeWidth, r.SizeHuman, r.Ref); err != nil {
return err
}
}
return nil
}

func imagesCacheCmd() *cobra.Command {
var cacheDir string

Expand Down
54 changes: 54 additions & 0 deletions cmd/y-cluster/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,60 @@ func TestImagesListCmd_FileNotFound(t *testing.T) {
}
}

// TestImagesListCmd_PositionalAndContextMutex pins the mutex
// rule: a positional input and --context can't both be set,
// because they pick incompatible input sources (YAML stream vs
// containerd ground truth).
func TestImagesListCmd_PositionalAndContextMutex(t *testing.T) {
cmd := rootCmd()
cmd.SetArgs([]string{"images", "list", "--context=local", "/some/path.yaml"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for positional + --context combination")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error should mention mutex: %v", err)
}
}

// TestImagesListCmd_ContextUnknownPropagates: a --context that
// the kubeconfig doesn't know about should surface the cluster
// lookup error rather than swallowing it.
func TestImagesListCmd_ContextUnknownPropagates(t *testing.T) {
cmd := rootCmd()
cmd.SetArgs([]string{"images", "list", "--context=does-not-exist"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected cluster-lookup error for unknown --context")
}
}

// TestImagesListCmd_BadFormat / _BadSort pin the validation
// of the cluster-mode formatting knobs. Errors should fire on
// the flag value, NOT on the unreachable cluster -- but a
// non-existent context happens to error first; we assert that
// the flag values themselves are at least accepted without a
// flag-parse error (cobra would error before our RunE runs).
func TestImagesListCmd_FlagsAccepted(t *testing.T) {
for _, args := range [][]string{
{"images", "list", "--context=does-not-exist", "--format=table"},
{"images", "list", "--context=does-not-exist", "--format=json"},
{"images", "list", "--context=does-not-exist", "--sort=size"},
{"images", "list", "--context=does-not-exist", "--sort=name"},
} {
cmd := rootCmd()
cmd.SetArgs(args)
// We expect a cluster-lookup error, not a flag-parse error.
err := cmd.Execute()
if err == nil {
t.Errorf("%v: expected cluster-lookup error", args)
continue
}
if strings.Contains(err.Error(), "unknown flag") {
t.Errorf("%v: cobra rejected a flag we own: %v", args, err)
}
}
}

func TestImagesCacheCmd_RequiresRef(t *testing.T) {
cmd := rootCmd()
cmd.SetArgs([]string{"images", "cache"})
Expand Down
194 changes: 194 additions & 0 deletions pkg/images/list_cluster.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package images

import (
"bytes"
"context"
"fmt"
"sort"
"strconv"
"strings"

"github.com/Yolean/y-cluster/pkg/cluster"
)

// ClusterImage is one stored manifest in a cluster's containerd
// k8s.io namespace, after digest-alias collapse. Multiple
// (ref, digest) rows in `ctr image list` that share the same
// manifest digest fold into one ClusterImage with the most
// informative ref form selected.
type ClusterImage struct {
Ref string `json:"ref"`
Digest string `json:"digest"`
SizeBytes int64 `json:"size_bytes"`
SizeHuman string `json:"size_human"`
}

// ListFromCluster queries the cluster's k8s.io containerd
// namespace and returns one row per stored manifest. ctr's
// tabular output is used over `--format json` because it's
// stable across the containerd versions y-cluster targets;
// the size column is parsed back from its IEC-formatted form.
func ListFromCluster(ctx context.Context, lr *cluster.LookupResult) ([]ClusterImage, error) {
var stdout, stderr bytes.Buffer
if err := cluster.RunCtr(ctx, lr, []string{"-n", "k8s.io", "image", "list"}, nil, &stdout, &stderr); err != nil {
return nil, fmt.Errorf("ctr image list: %s: %w", stderr.String(), err)
}
return parseClusterImageList(stdout.String()), nil
}

// parseClusterImageList is the pure parser exposed for tests.
// Skips header, blank lines, and the bare `sha256:<hex>`
// config-digest rows ctr writes alongside the canonical rows.
// Collapses by digest: many refs sharing the same manifest
// digest fold into one ClusterImage, with the most informative
// ref form winning (preferred order in betterRef).
func parseClusterImageList(output string) []ClusterImage {
byDigest := map[string]*ClusterImage{}
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] == "REF" {
continue
}
ref := fields[0]
if strings.HasPrefix(ref, "sha256:") {
continue
}
// Locate the digest by the sha256: prefix; the SIZE
// column lives two whitespace tokens after it (number,
// unit). Anchoring on the digest is robust to ctr column
// reorderings.
var digest string
sizeNumIdx := -1
for i, f := range fields[1:] {
if strings.HasPrefix(f, "sha256:") && len(f) == 7+64 {
digest = f
sizeNumIdx = i + 1 + 1
break
}
}
if digest == "" {
continue
}
var size int64
if sizeNumIdx >= 0 && sizeNumIdx+1 < len(fields) {
size = parseHumanSize(fields[sizeNumIdx], fields[sizeNumIdx+1])
}
if existing, ok := byDigest[digest]; ok {
if refRank(ref) > refRank(existing.Ref) {
existing.Ref = ref
}
if size > existing.SizeBytes {
existing.SizeBytes = size
existing.SizeHuman = formatHumanSize(size)
}
continue
}
byDigest[digest] = &ClusterImage{
Ref: ref,
Digest: digest,
SizeBytes: size,
SizeHuman: formatHumanSize(size),
}
}
out := make([]ClusterImage, 0, len(byDigest))
for _, v := range byDigest {
out = append(out, *v)
}
return out
}

// refRank prefers refs that name more identifying detail:
// name:tag@digest > name@digest > name:tag > anything else.
// Used to pick the canonical ref when many rows fold into one.
func refRank(ref string) int {
hasAt := strings.Contains(ref, "@")
tail := ref
if slash := strings.LastIndex(ref, "/"); slash >= 0 {
tail = ref[slash+1:]
}
if at := strings.Index(tail, "@"); at >= 0 {
tail = tail[:at]
}
hasTag := strings.Contains(tail, ":")
rank := 0
if hasAt {
rank += 2
}
if hasTag {
rank++
}
return rank
}

// parseHumanSize converts ctr's IEC-formatted size column
// ("263.7 MiB", "1.5 GiB") to bytes. Tolerates "-" / "0" for
// unknown size and a small set of metric units in case
// containerd ever switches.
func parseHumanSize(num, unit string) int64 {
if num == "" || num == "-" {
return 0
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0
}
var mul float64
switch unit {
case "B", "":
mul = 1
case "KiB":
mul = 1024
case "MiB":
mul = 1024 * 1024
case "GiB":
mul = 1024 * 1024 * 1024
case "TiB":
mul = 1024 * 1024 * 1024 * 1024
case "kB":
mul = 1000
case "MB":
mul = 1000 * 1000
case "GB":
mul = 1000 * 1000 * 1000
case "TB":
mul = 1000 * 1000 * 1000 * 1000
default:
return 0
}
return int64(f * mul)
}

// formatHumanSize renders a byte count in the same IEC shape
// ctr uses ("263.7 MiB"). Bytes pass through as "<n> B".
func formatHumanSize(b int64) string {
if b < 1024 {
return fmt.Sprintf("%d B", b)
}
f := float64(b)
for _, u := range []string{"KiB", "MiB", "GiB", "TiB"} {
f /= 1024
if f < 1024 {
return fmt.Sprintf("%.1f %s", f, u)
}
}
return fmt.Sprintf("%.1f PiB", f/1024)
}

// SortClusterImagesBySizeDesc sorts in place by SizeBytes
// descending, ties broken by Ref ascending so the output is
// deterministic across runs of an unchanged cluster.
func SortClusterImagesBySizeDesc(rows []ClusterImage) {
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].SizeBytes != rows[j].SizeBytes {
return rows[i].SizeBytes > rows[j].SizeBytes
}
return rows[i].Ref < rows[j].Ref
})
}

// SortClusterImagesByName sorts in place by Ref ascending.
func SortClusterImagesByName(rows []ClusterImage) {
sort.SliceStable(rows, func(i, j int) bool {
return rows[i].Ref < rows[j].Ref
})
}
Loading