From 4d04f46138cc1af8509449e6b688f325f35bc975 Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Thu, 11 Jun 2026 11:51:34 -0400 Subject: [PATCH 1/3] feat(cli): add datumctl-inventory plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a datumctl plugin that provides a read view over the inventory served by this operator. Invoked as `datumctl inventory ...` once installed. Replaces the approach of baking the command into datumctl core (datum-cloud/datumctl#212) — per review, the CLI lives with the service that owns the API. Key features: - List subcommands per kind (providers, regions, sites, clusters, nodes) with curated columns and -o table/json/yaml - Filter flags --region/--site/--cluster resolve server-side via the topology.inventory.miloapis.com/* labels the operator propagates; --provider matches the site providerRef client-side - `inventory tree` prints the region -> site -> node hierarchy with per-region clusters; `inventory summary` prints fleet-wide counts - Uses this repo's typed api/v1alpha1 client against the platform root, with credentials fetched on demand via the datumctl SDK helper - Implements the datumctl plugin contract (--plugin-manifest, DATUM_* context, go.datum.net/datumctl/plugin SDK) --- .gitignore | 4 + cmd/datumctl-inventory/README.md | 55 +++++++ cmd/datumctl-inventory/client.go | 45 ++++++ cmd/datumctl-inventory/list.go | 210 ++++++++++++++++++++++++++ cmd/datumctl-inventory/main.go | 72 +++++++++ cmd/datumctl-inventory/plugin_test.go | 116 ++++++++++++++ cmd/datumctl-inventory/render.go | 76 ++++++++++ cmd/datumctl-inventory/summary.go | 127 ++++++++++++++++ cmd/datumctl-inventory/tree.go | 116 ++++++++++++++ go.mod | 65 ++++---- go.sum | 133 +++++++++------- 11 files changed, 937 insertions(+), 82 deletions(-) create mode 100644 cmd/datumctl-inventory/README.md create mode 100644 cmd/datumctl-inventory/client.go create mode 100644 cmd/datumctl-inventory/list.go create mode 100644 cmd/datumctl-inventory/main.go create mode 100644 cmd/datumctl-inventory/plugin_test.go create mode 100644 cmd/datumctl-inventory/render.go create mode 100644 cmd/datumctl-inventory/summary.go create mode 100644 cmd/datumctl-inventory/tree.go diff --git a/.gitignore b/.gitignore index 23ab49d..e03fb79 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ kind # compiled manager binary from `go build ./cmd/inventory` /inventory + +# datumctl-inventory plugin build artifact +/datumctl-inventory +cmd/datumctl-inventory/datumctl-inventory diff --git a/cmd/datumctl-inventory/README.md b/cmd/datumctl-inventory/README.md new file mode 100644 index 0000000..cfc83b7 --- /dev/null +++ b/cmd/datumctl-inventory/README.md @@ -0,0 +1,55 @@ +# datumctl-inventory + +A [datumctl](https://github.com/datum-cloud/datumctl) plugin that provides a +read view over the Datum Cloud physical inventory served by this operator +(`inventory.miloapis.com/v1alpha1`). Once installed it is invoked as +`datumctl inventory ...`. + +## Commands + +| Command | Description | +|---|---| +| `datumctl inventory providers` | List providers | +| `datumctl inventory regions` | List regions | +| `datumctl inventory sites [--region R] [--provider P]` | List sites | +| `datumctl inventory clusters [--region R] [--site S]` | List clusters | +| `datumctl inventory nodes [--region R] [--site S] [--cluster C]` | List nodes | +| `datumctl inventory tree [--region R]` | region → site → node hierarchy | +| `datumctl inventory summary` | Fleet-wide counts | + +All subcommands accept `-o table|json|yaml` (default `table`). + +`--region`, `--site`, and `--cluster` filter server-side using the +`topology.inventory.miloapis.com/*` labels the operator propagates onto +inventory objects. `--provider` filters on the site's `providerRef`. + +Inventory objects are cluster-scoped on the Datum Cloud platform root, so the +plugin talks to the platform API directly and takes no organization or project +scope. + +## How it works + +datumctl injects context via environment variables and execs the plugin. The +plugin reads `DATUM_API_HOST`, fetches a short-lived token through the +credentials helper (`plugin.Token()`), and builds a controller-runtime client +against the platform root using this repo's own typed API +(`go.miloapis.com/inventory/api/v1alpha1`). See the +[datumctl plugin docs](https://github.com/datum-cloud/datumctl/blob/main/docs/developer/plugins.md). + +## Build + +```sh +go build -o datumctl-inventory ./cmd/datumctl-inventory +``` + +The version reported in `--plugin-manifest` is set via +`-ldflags "-X main.version="` at release time. + +## Local use + +Build the binary onto your `PATH` named `datumctl-inventory`, then: + +```sh +datumctl plugin trust inventory # unmanaged plugins must be trusted once +datumctl inventory summary +``` diff --git a/cmd/datumctl-inventory/client.go b/cmd/datumctl-inventory/client.go new file mode 100644 index 0000000..dd05a49 --- /dev/null +++ b/cmd/datumctl-inventory/client.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.datum.net/datumctl/plugin" + inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" +) + +// newClient builds a controller-runtime client against the Datum Cloud platform +// root, where inventory objects live. It reads the API host from the context +// datumctl injects and fetches a fresh token via the credentials helper. +func newClient() (client.Client, error) { + ctx := plugin.Context() + if ctx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; run this via 'datumctl inventory ...' (not the bare binary)") + } + token, err := plugin.Token() + if err != nil { + return nil, fmt.Errorf("get credentials: %w", err) + } + + host := ctx.APIHost + if !strings.Contains(host, "://") { + host = "https://" + host + } + + scheme := runtime.NewScheme() + if err := inventoryv1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("build scheme: %w", err) + } + + c, err := client.New(&rest.Config{Host: host, BearerToken: token}, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("build API client: %w", err) + } + return c, nil +} diff --git a/cmd/datumctl-inventory/list.go b/cmd/datumctl-inventory/list.go new file mode 100644 index 0000000..0f2bc8b --- /dev/null +++ b/cmd/datumctl-inventory/list.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "fmt" + "sort" + "strconv" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" +) + +// resourceView bundles a list subcommand's identity with a binder that +// registers its filter flags and returns the closure that lists, filters, +// sorts, and renders one inventory kind. +type resourceView struct { + use string + short string + bind func(cmd *cobra.Command) runFunc +} + +type runFunc func(ctx context.Context, c client.Client) (list runtime.Object, headers []string, rows [][]string, err error) + +func newListCmd(v resourceView) *cobra.Command { + cmd := &cobra.Command{Use: v.use, Short: v.short, Args: cobra.NoArgs, SilenceUsage: true} + run := v.bind(cmd) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + c, err := newClient() + if err != nil { + return err + } + list, headers, rows, err := run(cmd.Context(), c) + if err != nil { + return err + } + return emit(cmd, list, headers, rows) + } + return cmd +} + +func listErr(resource string, err error) error { + return fmt.Errorf("could not list %s: %w", resource, err) +} + +func regionLabelOpt(region string) []client.ListOption { + if region == "" { + return nil + } + return []client.ListOption{client.MatchingLabels{inventoryv1alpha1.TopologyRegionLabel: region}} +} + +var providersView = resourceView{ + use: "providers", + short: "List inventory providers", + bind: func(_ *cobra.Command) runFunc { + return func(ctx context.Context, c client.Client) (runtime.Object, []string, [][]string, error) { + var list inventoryv1alpha1.ProviderList + if err := c.List(ctx, &list); err != nil { + return nil, nil, nil, listErr("providers", err) + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name }) + rows := make([][]string, 0, len(list.Items)) + for _, p := range list.Items { + rows = append(rows, []string{p.Name, orNone(p.Spec.DisplayName), string(p.Spec.Type), ready(p.Status.Conditions)}) + } + return &list, []string{"NAME", "DISPLAY", "TYPE", "READY"}, rows, nil + } + }, +} + +var regionsView = resourceView{ + use: "regions", + short: "List inventory regions", + bind: func(_ *cobra.Command) runFunc { + return func(ctx context.Context, c client.Client) (runtime.Object, []string, [][]string, error) { + var list inventoryv1alpha1.RegionList + if err := c.List(ctx, &list); err != nil { + return nil, nil, nil, listErr("regions", err) + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name }) + rows := make([][]string, 0, len(list.Items)) + for _, r := range list.Items { + rows = append(rows, []string{r.Name, orNone(r.Spec.DisplayName), ready(r.Status.Conditions)}) + } + return &list, []string{"NAME", "DISPLAY", "READY"}, rows, nil + } + }, +} + +var sitesView = resourceView{ + use: "sites", + short: "List inventory sites", + bind: func(cmd *cobra.Command) runFunc { + region := cmd.Flags().String("region", "", "Filter by region name") + provider := cmd.Flags().String("provider", "", "Filter by provider name") + return func(ctx context.Context, c client.Client) (runtime.Object, []string, [][]string, error) { + var list inventoryv1alpha1.SiteList + if err := c.List(ctx, &list, regionLabelOpt(*region)...); err != nil { + return nil, nil, nil, listErr("sites", err) + } + if *provider != "" { + kept := list.Items[:0] + for _, s := range list.Items { + if s.Spec.ProviderRef != nil && s.Spec.ProviderRef.Name == *provider { + kept = append(kept, s) + } + } + list.Items = kept + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name }) + rows := make([][]string, 0, len(list.Items)) + for _, s := range list.Items { + prov := none + if s.Spec.ProviderRef != nil { + prov = orNone(s.Spec.ProviderRef.Name) + } + rows = append(rows, []string{s.Name, orNone(s.Spec.RegionRef.Name), prov, string(s.Spec.Type), ready(s.Status.Conditions)}) + } + return &list, []string{"NAME", "REGION", "PROVIDER", "TYPE", "READY"}, rows, nil + } + }, +} + +var clustersView = resourceView{ + use: "clusters", + short: "List inventory clusters", + bind: func(cmd *cobra.Command) runFunc { + region := cmd.Flags().String("region", "", "Filter by region name") + site := cmd.Flags().String("site", "", "Filter by control-plane site name") + return func(ctx context.Context, c client.Client) (runtime.Object, []string, [][]string, error) { + var list inventoryv1alpha1.ClusterList + if err := c.List(ctx, &list, regionLabelOpt(*region)...); err != nil { + return nil, nil, nil, listErr("clusters", err) + } + if *site != "" { + kept := list.Items[:0] + for _, cl := range list.Items { + if cl.Spec.ControlPlaneSiteRef.Name == *site { + kept = append(kept, cl) + } + } + list.Items = kept + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name }) + rows := make([][]string, 0, len(list.Items)) + for _, cl := range list.Items { + rows = append(rows, []string{ + cl.Name, + orNone(cl.Labels[inventoryv1alpha1.TopologyRegionLabel]), + orNone(cl.Spec.ControlPlaneSiteRef.Name), + string(cl.Spec.Role), + orNone(cl.Spec.Provider), + ready(cl.Status.Conditions), + }) + } + return &list, []string{"NAME", "REGION", "CP-SITE", "ROLE", "PROVIDER", "READY"}, rows, nil + } + }, +} + +var nodesView = resourceView{ + use: "nodes", + short: "List inventory nodes", + bind: func(cmd *cobra.Command) runFunc { + region := cmd.Flags().String("region", "", "Filter by region name") + site := cmd.Flags().String("site", "", "Filter by site name") + cluster := cmd.Flags().String("cluster", "", "Filter by cluster name") + return func(ctx context.Context, c client.Client) (runtime.Object, []string, [][]string, error) { + sel := client.MatchingLabels{} + if *region != "" { + sel[inventoryv1alpha1.TopologyRegionLabel] = *region + } + if *site != "" { + sel[inventoryv1alpha1.TopologySiteLabel] = *site + } + if *cluster != "" { + sel[inventoryv1alpha1.TopologyClusterLabel] = *cluster + } + var list inventoryv1alpha1.NodeList + if err := c.List(ctx, &list, client.MatchingLabels(sel)); err != nil { + return nil, nil, nil, listErr("nodes", err) + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].Name < list.Items[j].Name }) + rows := make([][]string, 0, len(list.Items)) + for _, n := range list.Items { + clusterName, role := none, none + if n.Spec.Assignment != nil { + clusterName = orNone(n.Spec.Assignment.ClusterRef.Name) + role = string(n.Spec.Assignment.Role) + } + rows = append(rows, []string{ + n.Name, + orNone(n.Spec.SiteRef.Name), + clusterName, + role, + string(n.Spec.Hardware.CPUArchitecture), + strconv.Itoa(int(n.Spec.Hardware.CPUCores)), + orNone(string(n.Status.Phase)), + ready(n.Status.Conditions), + }) + } + return &list, []string{"NAME", "SITE", "CLUSTER", "ROLE", "ARCH", "CPU", "PHASE", "READY"}, rows, nil + } + }, +} diff --git a/cmd/datumctl-inventory/main.go b/cmd/datumctl-inventory/main.go new file mode 100644 index 0000000..4d6702d --- /dev/null +++ b/cmd/datumctl-inventory/main.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Command datumctl-inventory is a datumctl plugin that provides a read view +// over the Datum Cloud physical inventory (providers, regions, sites, +// clusters, nodes). Invoked as `datumctl inventory ...` once installed. +package main + +import ( + "os" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" +) + +// version is set via -ldflags at build time; it feeds the plugin manifest. +var version = "dev" + +func main() { + plugin.ServeManifest(plugin.Manifest{ + Name: "inventory", + Version: version, + Description: "Browse the Datum Cloud physical inventory (providers, regions, sites, clusters, nodes)", + APIVersion: 1, + MinAPIVersion: 1, + }) + + root := &cobra.Command{ + Use: "inventory", + Short: "Browse the Datum Cloud physical inventory", + Long: `Browse the Datum Cloud physical inventory: providers, regions, sites, +clusters, and nodes. + +These records describe the real infrastructure Datum Cloud runs on — which +provider owns a site, which region a site sits in, and which nodes are assigned +to which cluster. Use the list subcommands to query one kind at a time, +'inventory tree' to see the region/site/node hierarchy, and 'inventory summary' +for fleet-wide counts. + +Inventory lives on the Datum Cloud platform root, so these commands talk to the +platform API directly; they do not take an organization or project scope.`, + Example: ` # List every region + datumctl inventory regions + + # Sites in one region, or by provider + datumctl inventory sites --region us-central-2 + datumctl inventory sites --provider netactuate + + # Nodes at a site or in a cluster + datumctl inventory nodes --site us-central-2a + datumctl inventory nodes --cluster my-edge-cluster + + # Region -> site -> node hierarchy, and fleet-wide counts + datumctl inventory tree + datumctl inventory summary`, + SilenceUsage: true, + } + root.PersistentFlags().StringP("output", "o", "table", "Output format. One of: table, json, yaml.") + + root.AddCommand( + newListCmd(providersView), + newListCmd(regionsView), + newListCmd(sitesView), + newListCmd(clustersView), + newListCmd(nodesView), + newTreeCmd(), + newSummaryCmd(), + ) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/datumctl-inventory/plugin_test.go b/cmd/datumctl-inventory/plugin_test.go new file mode 100644 index 0000000..a302f1f --- /dev/null +++ b/cmd/datumctl-inventory/plugin_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "bytes" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" +) + +func readyConds(status metav1.ConditionStatus) []metav1.Condition { + return []metav1.Condition{{Type: "Ready", Status: status}} +} + +func TestReady(t *testing.T) { + if got := ready(readyConds(metav1.ConditionTrue)); got != "True" { + t.Errorf("ready(True) = %q", got) + } + if got := ready(nil); got != none { + t.Errorf("ready(nil) = %q, want %s", got, none) + } + if got := ready([]metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue}}); got != none { + t.Errorf("ready(no Ready) = %q, want %s", got, none) + } +} + +func TestOrNone(t *testing.T) { + if orNone("") != none { + t.Error("orNone empty should be ") + } + if orNone("x") != "x" { + t.Error("orNone non-empty should pass through") + } +} + +func TestPrintTable(t *testing.T) { + var buf bytes.Buffer + if err := printTable(&buf, []string{"A", "B"}, [][]string{{"1", "2"}}); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "A") || !strings.Contains(out, "1") { + t.Errorf("table missing content:\n%s", out) + } + + buf.Reset() + _ = printTable(&buf, []string{"A"}, nil) + if !strings.Contains(buf.String(), "No matching inventory found.") { + t.Errorf("empty table should print no-match message, got:\n%s", buf.String()) + } +} + +func site(name, region, provider string) inventoryv1alpha1.Site { + s := inventoryv1alpha1.Site{} + s.Name = name + s.Spec.RegionRef = inventoryv1alpha1.LocalObjectReference{Name: region} + if provider != "" { + s.Spec.ProviderRef = &inventoryv1alpha1.LocalObjectReference{Name: provider} + } + return s +} + +func node(name, siteName string) inventoryv1alpha1.Node { + n := inventoryv1alpha1.Node{} + n.Name = name + n.Spec.SiteRef = inventoryv1alpha1.LocalObjectReference{Name: siteName} + return n +} + +func TestPrintTree(t *testing.T) { + regions := inventoryv1alpha1.RegionList{Items: []inventoryv1alpha1.Region{ + {ObjectMeta: metav1.ObjectMeta{Name: "us-central-2"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "eu-west-1"}}, + }} + sites := inventoryv1alpha1.SiteList{Items: []inventoryv1alpha1.Site{site("us-central-2a", "us-central-2", "")}} + cl := inventoryv1alpha1.Cluster{} + cl.Name = "edge-1" + cl.Labels = map[string]string{inventoryv1alpha1.TopologyRegionLabel: "us-central-2"} + clusters := inventoryv1alpha1.ClusterList{Items: []inventoryv1alpha1.Cluster{cl}} + nodes := inventoryv1alpha1.NodeList{Items: []inventoryv1alpha1.Node{node("node-1", "us-central-2a")}} + + var buf bytes.Buffer + printTree(&buf, "", regions, sites, clusters, nodes) + out := buf.String() + for _, want := range []string{"us-central-2", "eu-west-1", "clusters: edge-1", " us-central-2a", " node-1"} { + if !strings.Contains(out, want) { + t.Errorf("tree missing %q:\n%s", want, out) + } + } + + buf.Reset() + printTree(&buf, "us-central-2", regions, sites, clusters, nodes) + if strings.Contains(buf.String(), "eu-west-1") { + t.Errorf("--region filter leaked:\n%s", buf.String()) + } +} + +func TestPrintSummary(t *testing.T) { + sites := inventoryv1alpha1.SiteList{Items: []inventoryv1alpha1.Site{ + site("a", "r1", "netactuate"), + site("b", "r1", "netactuate"), + site("c", "r2", "vultr"), + }} + var buf bytes.Buffer + printSummary(&buf, inventoryv1alpha1.ProviderList{}, inventoryv1alpha1.RegionList{}, sites, inventoryv1alpha1.ClusterList{}, inventoryv1alpha1.NodeList{}) + out := buf.String() + for _, want := range []string{"Totals", "Per region", "Sites per provider", "netactuate", "r1"} { + if !strings.Contains(out, want) { + t.Errorf("summary missing %q:\n%s", want, out) + } + } +} diff --git a/cmd/datumctl-inventory/render.go b/cmd/datumctl-inventory/render.go new file mode 100644 index 0000000..c65be10 --- /dev/null +++ b/cmd/datumctl-inventory/render.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +const none = "" + +// emit renders a typed list in the format selected by the --output flag. For +// table output it prints headers + rows; for json/yaml it marshals the (already +// filtered) typed list so scripted callers get full objects. +func emit(cmd *cobra.Command, list runtime.Object, headers []string, rows [][]string) error { + format, _ := cmd.Flags().GetString("output") + out := cmd.OutOrStdout() + + switch format { + case "json": + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(b)) + return err + case "yaml": + b, err := yaml.Marshal(list) + if err != nil { + return err + } + _, err = fmt.Fprint(out, string(b)) + return err + case "", "table": + return printTable(out, headers, rows) + default: + return fmt.Errorf("invalid value %q for --output; allowed: table, json, yaml", format) + } +} + +func printTable(out io.Writer, headers []string, rows [][]string) error { + if len(rows) == 0 { + _, err := fmt.Fprintln(out, "No matching inventory found.") + return err + } + w := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, strings.Join(headers, "\t")) + for _, row := range rows { + fmt.Fprintln(w, strings.Join(row, "\t")) + } + return w.Flush() +} + +// ready returns the status of the "Ready" condition, or "" when absent. +func ready(conds []metav1.Condition) string { + if c := meta.FindStatusCondition(conds, "Ready"); c != nil && c.Status != "" { + return string(c.Status) + } + return none +} + +func orNone(s string) string { + if s == "" { + return none + } + return s +} diff --git a/cmd/datumctl-inventory/summary.go b/cmd/datumctl-inventory/summary.go new file mode 100644 index 0000000..529e7bf --- /dev/null +++ b/cmd/datumctl-inventory/summary.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "fmt" + "io" + "sort" + "strconv" + + "github.com/spf13/cobra" + + inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" +) + +func newSummaryCmd() *cobra.Command { + return &cobra.Command{ + Use: "summary", + Short: "Show fleet-wide inventory counts", + Long: `Print fleet-wide counts: totals per kind, sites and nodes per region, and +sites per provider.`, + Example: " datumctl inventory summary", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := newClient() + if err != nil { + return err + } + ctx := cmd.Context() + + var providers inventoryv1alpha1.ProviderList + var regions inventoryv1alpha1.RegionList + var sites inventoryv1alpha1.SiteList + var clusters inventoryv1alpha1.ClusterList + var nodes inventoryv1alpha1.NodeList + if err := c.List(ctx, &providers); err != nil { + return listErr("providers", err) + } + if err := c.List(ctx, ®ions); err != nil { + return listErr("regions", err) + } + if err := c.List(ctx, &sites); err != nil { + return listErr("sites", err) + } + if err := c.List(ctx, &clusters); err != nil { + return listErr("clusters", err) + } + if err := c.List(ctx, &nodes); err != nil { + return listErr("nodes", err) + } + + printSummary(cmd.OutOrStdout(), providers, regions, sites, clusters, nodes) + return nil + }, + } +} + +func printSummary(out io.Writer, providers inventoryv1alpha1.ProviderList, regions inventoryv1alpha1.RegionList, sites inventoryv1alpha1.SiteList, clusters inventoryv1alpha1.ClusterList, nodes inventoryv1alpha1.NodeList) { + fmt.Fprintln(out, "Totals") + _ = printTable(out, []string{"KIND", "COUNT"}, [][]string{ + {"providers", strconv.Itoa(len(providers.Items))}, + {"regions", strconv.Itoa(len(regions.Items))}, + {"sites", strconv.Itoa(len(sites.Items))}, + {"clusters", strconv.Itoa(len(clusters.Items))}, + {"nodes", strconv.Itoa(len(nodes.Items))}, + }) + + sitesPerRegion := map[string]int{} + for _, s := range sites.Items { + sitesPerRegion[s.Spec.RegionRef.Name]++ + } + nodesPerRegion := map[string]int{} + for _, n := range nodes.Items { + r := n.Labels[inventoryv1alpha1.TopologyRegionLabel] + if r == "" { + r = none + } + nodesPerRegion[r]++ + } + fmt.Fprintln(out, "\nPer region") + regionRows := make([][]string, 0) + for _, r := range sortedUnion(sitesPerRegion, nodesPerRegion) { + regionRows = append(regionRows, []string{r, strconv.Itoa(sitesPerRegion[r]), strconv.Itoa(nodesPerRegion[r])}) + } + _ = printTable(out, []string{"REGION", "SITES", "NODES"}, regionRows) + + sitesPerProvider := map[string]int{} + for _, s := range sites.Items { + p := none + if s.Spec.ProviderRef != nil { + p = s.Spec.ProviderRef.Name + } + sitesPerProvider[p]++ + } + fmt.Fprintln(out, "\nSites per provider") + providerRows := make([][]string, 0) + for _, p := range sortedKeys(sitesPerProvider) { + providerRows = append(providerRows, []string{p, strconv.Itoa(sitesPerProvider[p])}) + } + _ = printTable(out, []string{"PROVIDER", "SITES"}, providerRows) +} + +func sortedKeys(m map[string]int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedUnion(a, b map[string]int) []string { + seen := map[string]bool{} + for k := range a { + seen[k] = true + } + for k := range b { + seen[k] = true + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/datumctl-inventory/tree.go b/cmd/datumctl-inventory/tree.go new file mode 100644 index 0000000..4e9e6f1 --- /dev/null +++ b/cmd/datumctl-inventory/tree.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + + inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" +) + +func newTreeCmd() *cobra.Command { + var region string + cmd := &cobra.Command{ + Use: "tree", + Short: "Show the region -> site -> node hierarchy", + Long: `Print the inventory as a topology tree: each region, the sites within it, +the nodes at each site, and the clusters anchored in the region. + +Use --region to scope the tree to a single region.`, + Example: ` # Full topology tree + datumctl inventory tree + + # Just one region + datumctl inventory tree --region us-central-2`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := newClient() + if err != nil { + return err + } + ctx := cmd.Context() + + var regions inventoryv1alpha1.RegionList + if err := c.List(ctx, ®ions); err != nil { + return listErr("regions", err) + } + var sites inventoryv1alpha1.SiteList + if err := c.List(ctx, &sites); err != nil { + return listErr("sites", err) + } + var clusters inventoryv1alpha1.ClusterList + if err := c.List(ctx, &clusters); err != nil { + return listErr("clusters", err) + } + var nodes inventoryv1alpha1.NodeList + if err := c.List(ctx, &nodes); err != nil { + return listErr("nodes", err) + } + + printTree(cmd.OutOrStdout(), region, regions, sites, clusters, nodes) + return nil + }, + } + cmd.Flags().StringVar(®ion, "region", "", "Limit the tree to a single region") + return cmd +} + +func printTree(out io.Writer, regionFilter string, regions inventoryv1alpha1.RegionList, sites inventoryv1alpha1.SiteList, clusters inventoryv1alpha1.ClusterList, nodes inventoryv1alpha1.NodeList) { + sitesByRegion := map[string][]string{} + for _, s := range sites.Items { + sitesByRegion[s.Spec.RegionRef.Name] = append(sitesByRegion[s.Spec.RegionRef.Name], s.Name) + } + nodesBySite := map[string][]string{} + for _, n := range nodes.Items { + nodesBySite[n.Spec.SiteRef.Name] = append(nodesBySite[n.Spec.SiteRef.Name], n.Name) + } + clustersByRegion := map[string][]string{} + for _, cl := range clusters.Items { + r := cl.Labels[inventoryv1alpha1.TopologyRegionLabel] + if r == "" { + r = none + } + clustersByRegion[r] = append(clustersByRegion[r], cl.Name) + } + + names := make([]string, 0, len(regions.Items)) + for _, r := range regions.Items { + names = append(names, r.Name) + } + sort.Strings(names) + + printed := 0 + for _, region := range names { + if regionFilter != "" && region != regionFilter { + continue + } + printed++ + fmt.Fprintln(out, region) + + if cls := clustersByRegion[region]; len(cls) > 0 { + sort.Strings(cls) + fmt.Fprintf(out, " clusters: %s\n", strings.Join(cls, ", ")) + } + + regionSites := sitesByRegion[region] + sort.Strings(regionSites) + for _, site := range regionSites { + fmt.Fprintf(out, " %s\n", site) + siteNodes := nodesBySite[site] + sort.Strings(siteNodes) + for _, n := range siteNodes { + fmt.Fprintf(out, " %s\n", n) + } + } + } + + if printed == 0 { + fmt.Fprintln(out, "No matching inventory found.") + } +} diff --git a/go.mod b/go.mod index 9f3b19f..93e4d48 100644 --- a/go.mod +++ b/go.mod @@ -1,73 +1,84 @@ module go.miloapis.com/inventory -go 1.25.0 +go 1.25.8 require ( github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 + github.com/spf13/cobra v1.10.2 + go.datum.net/datumctl v0.15.0 + k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/yaml v1.6.0 ) require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.43.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.35.4 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index 1ab700c..eaac901 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,13 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -28,22 +29,48 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -53,25 +80,20 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -90,8 +112,9 @@ github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -102,17 +125,16 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -125,6 +147,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.datum.net/datumctl v0.15.0 h1:dOrnfwWyhQ0Yp42jTUiMu3w/ySkftz2CXF7hsuWp4gE= +go.datum.net/datumctl v0.15.0/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -135,28 +159,28 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -164,7 +188,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= @@ -177,17 +200,17 @@ k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 3617b68b83d19ac65d1e8fcd77669da88c79730b Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Thu, 11 Jun 2026 12:06:09 -0400 Subject: [PATCH 2/3] ci(cli): release wiring for datumctl-inventory plugin Add goreleaser config + workflow to publish datumctl-inventory release archives so it installs via `datumctl plugin install milo-os/datumctl-inventory`. Plugin releases are decoupled from the operator: they trigger on a `datumctl-inventory/vX.Y.Z` tag prefix and build only the plugin binary. The operator's publish.yaml is guarded so plugin tags and the plugin's GitHub release do not rebuild the operator image. Key changes: - .goreleaser.datumctl-inventory.yaml: builds ./cmd/datumctl-inventory for linux/darwin/windows x amd64/arm64; archives named datumctl-inventory_{OS}_{Arch}; checksums.txt; version via -X main.version - release-datumctl-inventory.yaml: triggers on datumctl-inventory/v* tags; GORELEASER_CURRENT_TAG strips the prefix so the plugin gets its own version - publish.yaml: tags-ignore datumctl-inventory/** on push, and skip the release-published jobs when the release tag carries the plugin prefix Closes #42 --- .github/workflows/publish.yaml | 9 +++ .../workflows/release-datumctl-inventory.yaml | 47 +++++++++++++++ .goreleaser.datumctl-inventory.yaml | 57 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 .github/workflows/release-datumctl-inventory.yaml create mode 100644 .goreleaser.datumctl-inventory.yaml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b481d95..4392ee7 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,6 +5,11 @@ on: paths-ignore: - 'docs/**' - 'README.md' + # datumctl-inventory plugin releases use their own tag prefix and are + # built by release-datumctl-inventory.yaml — they must not rebuild the + # operator image. + tags-ignore: + - 'datumctl-inventory/**' release: types: ['published'] @@ -17,6 +22,10 @@ jobs: uses: actions/checkout@v6 publish-container-image: + # Skip when the triggering release is a plugin release (see tags-ignore above + # for the push path). On push/branch events github.event.release is empty, so + # this evaluates true and the operator still publishes as before. + if: ${{ !startsWith(github.event.release.tag_name, 'datumctl-inventory/') }} permissions: id-token: write contents: read diff --git a/.github/workflows/release-datumctl-inventory.yaml b/.github/workflows/release-datumctl-inventory.yaml new file mode 100644 index 0000000..bf53605 --- /dev/null +++ b/.github/workflows/release-datumctl-inventory.yaml @@ -0,0 +1,47 @@ +name: Release datumctl-inventory plugin + +# Releases the datumctl-inventory CLI plugin only — not the operator image. +# Triggered by tags of the form datumctl-inventory/vX.Y.Z so plugin releases +# are decoupled from operator versions. The operator's publish.yaml ignores +# these tags and skips the matching release event. + +on: + push: + tags: + - 'datumctl-inventory/v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install Syft CLI + run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + # Strip the datumctl-inventory/ tag prefix so goreleaser derives the + # plugin's own version instead of the repo's latest (operator) tag. + - name: Resolve plugin version + id: ver + run: echo "version=${GITHUB_REF_NAME#datumctl-inventory/}" >> "$GITHUB_OUTPUT" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean -f .goreleaser.datumctl-inventory.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ steps.ver.outputs.version }} diff --git a/.goreleaser.datumctl-inventory.yaml b/.goreleaser.datumctl-inventory.yaml new file mode 100644 index 0000000..8e3f0a9 --- /dev/null +++ b/.goreleaser.datumctl-inventory.yaml @@ -0,0 +1,57 @@ +# GoReleaser config for the datumctl-inventory plugin. +# +# This repo's primary artifact is the inventory operator container image +# (see .github/workflows/publish.yaml). This config releases ONLY the +# datumctl-inventory CLI plugin, on its own tag prefix so plugin releases are +# decoupled from operator versions. +# +# Release a plugin version by pushing a tag: datumctl-inventory/vX.Y.Z +# +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: datumctl-inventory + +before: + hooks: + - go mod download + +builds: + - id: datumctl-inventory + main: ./cmd/datumctl-inventory + binary: datumctl-inventory + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - "-X main.version=v{{.Version}}" + +archives: + - id: datumctl-inventory + # name template makes OS/Arch match `uname` output, matching the convention + # datumctl's plugin installer expects (datumctl-inventory_Darwin_arm64, ...). + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +sboms: + - artifacts: archive + +changelog: + disable: true From 0962a763d6ad85cf86ba5b1fa8d8cc98bdf50041 Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Thu, 11 Jun 2026 12:27:21 -0400 Subject: [PATCH 3/3] ci(cli): release datumctl-inventory on the operator's tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review, the plugin shares the operator's version and tag instead of using a separate tag prefix. On `release: published`, goreleaser builds the plugin and appends datumctl-inventory_{OS}_{Arch} archives + checksums.txt to the same GitHub release as the operator image. Removes the tag-prefix trigger, the GORELEASER_CURRENT_TAG workaround, and the publish.yaml guards (no longer needed — both build on the same tag). --- .github/workflows/publish.yaml | 9 --------- .../workflows/release-datumctl-inventory.yaml | 20 ++++++------------- .goreleaser.datumctl-inventory.yaml | 13 ++++++++---- cmd/datumctl-inventory/README.md | 8 ++++++++ 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 4392ee7..b481d95 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,11 +5,6 @@ on: paths-ignore: - 'docs/**' - 'README.md' - # datumctl-inventory plugin releases use their own tag prefix and are - # built by release-datumctl-inventory.yaml — they must not rebuild the - # operator image. - tags-ignore: - - 'datumctl-inventory/**' release: types: ['published'] @@ -22,10 +17,6 @@ jobs: uses: actions/checkout@v6 publish-container-image: - # Skip when the triggering release is a plugin release (see tags-ignore above - # for the push path). On push/branch events github.event.release is empty, so - # this evaluates true and the operator still publishes as before. - if: ${{ !startsWith(github.event.release.tag_name, 'datumctl-inventory/') }} permissions: id-token: write contents: read diff --git a/.github/workflows/release-datumctl-inventory.yaml b/.github/workflows/release-datumctl-inventory.yaml index bf53605..05e84bf 100644 --- a/.github/workflows/release-datumctl-inventory.yaml +++ b/.github/workflows/release-datumctl-inventory.yaml @@ -1,14 +1,13 @@ name: Release datumctl-inventory plugin -# Releases the datumctl-inventory CLI plugin only — not the operator image. -# Triggered by tags of the form datumctl-inventory/vX.Y.Z so plugin releases -# are decoupled from operator versions. The operator's publish.yaml ignores -# these tags and skips the matching release event. +# Builds the datumctl-inventory CLI plugin and appends its archives to the +# GitHub release that triggered this run — the same release/tag the operator +# uses. Runs in parallel with publish.yaml (which builds the operator image) +# on the same `release: published` event. on: - push: - tags: - - 'datumctl-inventory/v*' + release: + types: ['published'] permissions: contents: write @@ -30,12 +29,6 @@ jobs: with: go-version-file: go.mod - # Strip the datumctl-inventory/ tag prefix so goreleaser derives the - # plugin's own version instead of the repo's latest (operator) tag. - - name: Resolve plugin version - id: ver - run: echo "version=${GITHUB_REF_NAME#datumctl-inventory/}" >> "$GITHUB_OUTPUT" - - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -44,4 +37,3 @@ jobs: args: release --clean -f .goreleaser.datumctl-inventory.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_CURRENT_TAG: ${{ steps.ver.outputs.version }} diff --git a/.goreleaser.datumctl-inventory.yaml b/.goreleaser.datumctl-inventory.yaml index 8e3f0a9..7b00c9d 100644 --- a/.goreleaser.datumctl-inventory.yaml +++ b/.goreleaser.datumctl-inventory.yaml @@ -1,11 +1,11 @@ # GoReleaser config for the datumctl-inventory plugin. # # This repo's primary artifact is the inventory operator container image -# (see .github/workflows/publish.yaml). This config releases ONLY the -# datumctl-inventory CLI plugin, on its own tag prefix so plugin releases are -# decoupled from operator versions. +# (see .github/workflows/publish.yaml). This config builds ONLY the +# datumctl-inventory CLI plugin and appends its archives to the SAME GitHub +# release as the operator — the plugin shares the operator's tag/version. # -# Release a plugin version by pushing a tag: datumctl-inventory/vX.Y.Z +# Triggered by release-datumctl-inventory.yaml on `release: published`. # # yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 @@ -53,5 +53,10 @@ checksum: sboms: - artifacts: archive +# Append plugin archives to the operator's existing GitHub release for this +# tag rather than creating a separate release. +release: + mode: append + changelog: disable: true diff --git a/cmd/datumctl-inventory/README.md b/cmd/datumctl-inventory/README.md index cfc83b7..161db53 100644 --- a/cmd/datumctl-inventory/README.md +++ b/cmd/datumctl-inventory/README.md @@ -45,6 +45,14 @@ go build -o datumctl-inventory ./cmd/datumctl-inventory The version reported in `--plugin-manifest` is set via `-ldflags "-X main.version="` at release time. +## Releases + +The plugin **shares the operator's version and tag**. When an `inventory` +release is published, `.github/workflows/release-datumctl-inventory.yaml` runs +goreleaser and *appends* `datumctl-inventory_{OS}_{Arch}` archives plus +`checksums.txt` to that same GitHub release (alongside the operator image +published by `publish.yaml`). So plugin `vX.Y.Z` == operator `vX.Y.Z`. + ## Local use Build the binary onto your `PATH` named `datumctl-inventory`, then: