diff --git a/cmd/bbox/cache.go b/cmd/bbox/cache.go new file mode 100644 index 0000000..f9da291 --- /dev/null +++ b/cmd/bbox/cache.go @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/stacklok/go-microvm/image" +) + +func cacheCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cache", + Short: "Manage the OCI image cache", + } + cmd.AddCommand(cacheListCmd()) + cmd.AddCommand(cacheGCCmd()) + cmd.AddCommand(cachePurgeCmd()) + return cmd +} + +func cacheListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "Show cached images", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + dir, err := imageCacheDir() + if err != nil { + return fmt.Errorf("resolving image cache directory: %w", err) + } + + cache := image.NewCache(dir) + entries, err := cache.List() + if err != nil { + return fmt.Errorf("listing cache: %w", err) + } + + if len(entries) == 0 { + fmt.Println("No cached images") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "DIGEST\tSIZE\tLAST USED\tIMAGE") + + var totalRootfs int64 + orphans := 0 + for _, e := range entries { + digest := shortDigest(e.Digest) + size := humanSize(e.Size) + age := timeAgo(e.ModTime) + ref := "(orphan)" + if len(e.Refs) > 0 { + ref = e.Refs[0] + if len(e.Refs) > 1 { + ref += fmt.Sprintf(" (+%d more)", len(e.Refs)-1) + } + } else { + orphans++ + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", digest, size, age, ref) + totalRootfs += e.Size + } + _ = w.Flush() + + // Summary line. + layerSize, _ := cache.LayerCache().Size() + total := totalRootfs + layerSize + + fmt.Println() + summary := fmt.Sprintf("Entries: %d", len(entries)) + if orphans > 0 { + summary += fmt.Sprintf(" (%d orphan)", orphans) + } + summary += fmt.Sprintf(" | Rootfs: %s", humanSize(totalRootfs)) + if layerSize > 0 { + summary += fmt.Sprintf(" | Layers: %s", humanSize(layerSize)) + } + summary += fmt.Sprintf(" | Total: %s", humanSize(total)) + fmt.Println(summary) + + return nil + }, + } +} + +func cacheGCCmd() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "gc", + Short: "Remove unreferenced cache entries", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + dir, err := imageCacheDir() + if err != nil { + return fmt.Errorf("resolving image cache directory: %w", err) + } + + cache := image.NewCache(dir) + + if dryRun { + entries, err := cache.List() + if err != nil { + return fmt.Errorf("listing cache: %w", err) + } + var count int + var totalSize int64 + for _, e := range entries { + if len(e.Refs) == 0 { + fmt.Printf("would remove %s (%s)\n", shortDigest(e.Digest), humanSize(e.Size)) + count++ + totalSize += e.Size + } + } + if count == 0 { + fmt.Println("No unreferenced entries to remove") + } else { + fmt.Printf("\n%d entries, %s would be freed\n", count, humanSize(totalSize)) + } + return nil + } + + removed, err := cache.GC() + if err != nil { + return fmt.Errorf("cache gc: %w", err) + } + if removed == 0 { + fmt.Println("No unreferenced entries to remove") + } else { + fmt.Printf("Removed %d unreferenced cache entries\n", removed) + } + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be removed without removing it") + return cmd +} + +func cachePurgeCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "purge", + Short: "Remove all cached images", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + dir, err := imageCacheDir() + if err != nil { + return fmt.Errorf("resolving image cache directory: %w", err) + } + + cache := image.NewCache(dir) + + if !force { + fmt.Fprintf(os.Stderr, "This will remove all cached images at %s\n", dir) + fmt.Fprint(os.Stderr, "Continue? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + if answer != "y" && answer != "Y" { + fmt.Println("Aborted") + return nil + } + } + + if err := cache.Purge(); err != nil { + return fmt.Errorf("purging cache: %w", err) + } + fmt.Println("Cache purged") + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + return cmd +} + +// shortDigest truncates a digest to a readable length. +// "sha256:abc123def456..." → "sha256:abc123def456" +func shortDigest(digest string) string { + const maxHexLen = 12 + parts := splitDigest(digest) + if parts[1] != "" && len(parts[1]) > maxHexLen { + return parts[0] + ":" + parts[1][:maxHexLen] + } + return digest +} + +// splitDigest splits "sha256:hex" into ["sha256", "hex"]. +func splitDigest(digest string) [2]string { + for i, c := range digest { + if c == ':' { + return [2]string{digest[:i], digest[i+1:]} + } + } + return [2]string{digest, ""} +} + +// humanSize formats a byte count as a human-readable string. +func humanSize(b int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case b >= gb: + return fmt.Sprintf("%.1f GB", float64(b)/float64(gb)) + case b >= mb: + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + case b >= kb: + return fmt.Sprintf("%.1f KB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } +} + +// timeAgo formats a time.Time as a relative duration string. +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} diff --git a/cmd/bbox/cache_test.go b/cmd/bbox/cache_test.go new file mode 100644 index 0000000..ca25245 --- /dev/null +++ b/cmd/bbox/cache_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestHumanSize(t *testing.T) { + t.Parallel() + + tests := []struct { + bytes int64 + expected string + }{ + {0, "0 B"}, + {100, "100 B"}, + {1023, "1023 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {1024 * 1024 * 500, "500.0 MB"}, + {1024 * 1024 * 1024, "1.0 GB"}, + {int64(1024) * 1024 * 1024 * 3, "3.0 GB"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, humanSize(tt.bytes), "humanSize(%d)", tt.bytes) + } +} + +func TestTimeAgo(t *testing.T) { + t.Parallel() + + tests := []struct { + offset time.Duration + expected string + }{ + {5 * time.Second, "just now"}, + {1 * time.Minute, "1 minute ago"}, + {30 * time.Minute, "30 minutes ago"}, + {1 * time.Hour, "1 hour ago"}, + {5 * time.Hour, "5 hours ago"}, + {24 * time.Hour, "1 day ago"}, + {72 * time.Hour, "3 days ago"}, + } + + for _, tt := range tests { + result := timeAgo(time.Now().Add(-tt.offset)) + assert.Equal(t, tt.expected, result, "timeAgo(-%v)", tt.offset) + } +} + +func TestShortDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"sha256:abcdef123456abcdef", "sha256:abcdef123456"}, + {"sha256:short", "sha256:short"}, + {"sha256:exactlytwelv", "sha256:exactlytwelv"}, + {"no-colon", "no-colon"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, shortDigest(tt.input), "shortDigest(%q)", tt.input) + } +} + +func TestCacheCmd_SubcommandsExist(t *testing.T) { + t.Parallel() + + cmd := cacheCmd() + assert.NotNil(t, cmd) + + // Verify subcommands are registered. + subCmds := make(map[string]bool) + for _, sub := range cmd.Commands() { + subCmds[sub.Name()] = true + } + + assert.True(t, subCmds["list"], "missing 'list' subcommand") + assert.True(t, subCmds["gc"], "missing 'gc' subcommand") + assert.True(t, subCmds["purge"], "missing 'purge' subcommand") +} + +func TestCacheGCCmd_DryRunFlagRegistered(t *testing.T) { + t.Parallel() + + cmd := cacheGCCmd() + flag := cmd.Flags().Lookup("dry-run") + assert.NotNil(t, flag, "missing --dry-run flag") + assert.Equal(t, "false", flag.DefValue) +} + +func TestCachePurgeCmd_ForceFlagRegistered(t *testing.T) { + t.Parallel() + + cmd := cachePurgeCmd() + flag := cmd.Flags().Lookup("force") + assert.NotNil(t, flag, "missing --force flag") + assert.Equal(t, "false", flag.DefValue) +} diff --git a/cmd/bbox/main.go b/cmd/bbox/main.go index 1a49bf9..d1945be 100644 --- a/cmd/bbox/main.go +++ b/cmd/bbox/main.go @@ -198,6 +198,7 @@ Example: cmd.AddCommand(listCmd()) cmd.AddCommand(authCmd()) cmd.AddCommand(configCmd()) + cmd.AddCommand(cacheCmd()) return cmd } diff --git a/go.mod b/go.mod index 199c7ff..d016ab1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sergi/go-diff v1.4.0 github.com/spf13/cobra v1.10.2 - github.com/stacklok/go-microvm v0.0.24 + github.com/stacklok/go-microvm v0.0.26 github.com/stacklok/toolhive v0.12.5 github.com/stacklok/toolhive-core v0.0.12 github.com/stretchr/testify v1.11.1 @@ -143,7 +143,7 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index fe69048..117dd7b 100644 --- a/go.sum +++ b/go.sum @@ -77,12 +77,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/cedar-policy/cedar-go v1.5.2 h1:J8z9AHaZd9CNBOTAruy/EgU4Zw5+TQSWR04T3wLFMzE= -github.com/cedar-policy/cedar-go v1.5.2/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cedar-policy/cedar-go v1.6.0 h1:5dYWkrQjza+GzdJxnzmus7Ag/2pHv4bYWe460/kDlAM= github.com/cedar-policy/cedar-go v1.6.0/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -318,8 +314,6 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -396,8 +390,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= @@ -636,10 +630,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/go-microvm v0.0.24 h1:5XPIeomBFbO6zHyWystGfO+TwRytKebLATpJWjPJJo8= -github.com/stacklok/go-microvm v0.0.24/go.mod h1:bejubmGh8UMoT6vZE4O2alLm2Y5on2zl4LNsCmOLYGg= -github.com/stacklok/toolhive v0.12.4 h1:nyoUIQKFofMH5/ja0RCecUmgCRv5CCPyyaUZswzHij8= -github.com/stacklok/toolhive v0.12.4/go.mod h1:xNwAJLrjzwEPxs5GnP7SrITxw3Q3bQhC7tSKA8Qwmlk= +github.com/stacklok/go-microvm v0.0.26 h1:0vNzWhJcPKHZF5FlH4c/zcs1fOr8HrFVgE7R7YKLqO0= +github.com/stacklok/go-microvm v0.0.26/go.mod h1:Nhy/grJzoVNsa/f3YzUd6vmQ3MlNPGBxMuzRertUPCQ= github.com/stacklok/toolhive v0.12.5 h1:aeowKor50w+ISxXWEZSe+WzyR+J/cH4RnGvIwQqpVRY= github.com/stacklok/toolhive v0.12.5/go.mod h1:bLepWuZmK6RuNDicKujRwrGyVWKqoA25IWEBgQC8COY= github.com/stacklok/toolhive-core v0.0.12 h1:jKrgbXSXTkLkLMrcv3u/ozGeFhM0vd0jamVnQX9trEw= @@ -746,16 +738,10 @@ go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/exporters/prometheus v0.63.0 h1:OLo1FNb0pBZykLqbKRZolKtGZd0Waqlr240YdMEnhhg= diff --git a/pkg/runtime/factory.go b/pkg/runtime/factory.go index 9fe6eeb..14aa52d 100644 --- a/pkg/runtime/factory.go +++ b/pkg/runtime/factory.go @@ -69,7 +69,6 @@ type DefaultSandboxDepsOpts struct { // Defaults to ~/.cache/broodbox/snapshots/ (XDG_CACHE_HOME), falling // back to os.TempDir() if XDG resolution fails. SnapshotDir string - } // NewDefaultSandboxDeps wires Brood Box's standard infrastructure dependencies.