diff --git a/docs/inventory.md b/docs/inventory.md new file mode 100644 index 0000000..069d718 --- /dev/null +++ b/docs/inventory.md @@ -0,0 +1,76 @@ +--- +title: "Inventory" +sidebar: + order: 4 +--- + +`datumctl inventory` is a read view over the Datum Cloud physical inventory — +the providers, regions, sites, clusters, and nodes that make up the real +infrastructure Datum Cloud runs on. Use it to answer questions like "which sites +are in this region?", "which provider owns this site?", and "which nodes are +assigned to this cluster?" without writing label selectors by hand. + +Inventory records live on the platform root, so every `inventory` subcommand +defaults to `--platform-wide`. Pass `--organization` or `--project` to override +the scope. + +## Listing resources + +Each kind has its own list subcommand: + +``` +datumctl inventory providers +datumctl inventory regions +datumctl inventory sites +datumctl inventory clusters +datumctl inventory nodes +``` + +By default each prints a table with the most useful columns. Use `-o json` or +`-o yaml` for the full objects (handy for scripting): + +``` +datumctl inventory sites -o json +``` + +## Filtering + +List subcommands accept filter flags that narrow the results by topology: + +``` +# Sites in one region +datumctl inventory sites --region us-central-2 + +# Sites from one provider +datumctl inventory sites --provider netactuate + +# Nodes at a site, or assigned to a cluster +datumctl inventory nodes --site us-central-2a +datumctl inventory nodes --cluster my-edge-cluster + +# Clusters in a region +datumctl inventory clusters --region us-central-2 +``` + +Region, site, and cluster filters are resolved server-side using the +`topology.inventory.miloapis.com/*` labels that the platform propagates onto +inventory objects. The provider filter matches on the site's `providerRef`. + +## Topology tree + +`datumctl inventory tree` prints the region → site → node hierarchy, with the +clusters anchored in each region listed alongside: + +``` +datumctl inventory tree +datumctl inventory tree --region us-central-2 +``` + +## Summary + +`datumctl inventory summary` prints fleet-wide counts: totals per kind, sites +and nodes per region, and sites per provider. + +``` +datumctl inventory summary +``` diff --git a/internal/cmd/inventory/fields.go b/internal/cmd/inventory/fields.go new file mode 100644 index 0000000..bdda5d4 --- /dev/null +++ b/internal/cmd/inventory/fields.go @@ -0,0 +1,48 @@ +package inventory + +import ( + "strconv" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const none = "" + +// str reads a nested string field, returning "" when absent or empty. +func str(u unstructured.Unstructured, fields ...string) string { + v, found, err := unstructured.NestedString(u.Object, fields...) + if err != nil || !found || v == "" { + return none + } + return v +} + +// intStr reads a nested integer field as a string, "" when absent. +func intStr(u unstructured.Unstructured, fields ...string) string { + v, found, err := unstructured.NestedInt64(u.Object, fields...) + if err != nil || !found { + return none + } + return strconv.FormatInt(v, 10) +} + +// ready returns the status of the "Ready" condition ("True"/"False"), or +// "" when the object carries no such condition yet. +func ready(u unstructured.Unstructured) string { + conds, found, err := unstructured.NestedSlice(u.Object, "status", "conditions") + if err != nil || !found { + return none + } + for _, c := range conds { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + if m["type"] == "Ready" { + if s, ok := m["status"].(string); ok && s != "" { + return s + } + } + } + return none +} diff --git a/internal/cmd/inventory/inventory.go b/internal/cmd/inventory/inventory.go new file mode 100644 index 0000000..5909b96 --- /dev/null +++ b/internal/cmd/inventory/inventory.go @@ -0,0 +1,62 @@ +// Package inventory defines the `datumctl inventory` command tree — a +// purpose-built read view over the Datum Cloud physical inventory +// (providers, regions, sites, clusters, nodes). +package inventory + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "go.datum.net/datumctl/internal/client" +) + +// Command returns the `datumctl inventory` parent command. +func Command(factory *client.DatumCloudFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "inventory", + Short: "Browse the Datum Cloud physical inventory", + Long: templates.LongDesc(` + 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 platform root, so these commands default to + --platform-wide. Pass --organization or --project to override.`), + Example: templates.Examples(` + # List every region + datumctl inventory regions + + # Sites in one region, 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 + datumctl inventory tree + + # Fleet-wide counts + datumctl inventory summary`), + } + + cmd.PersistentFlags().StringP("output", "o", "table", "Output format. One of: table, json, yaml.") + + cmd.AddCommand( + newListCmd(factory, providersView), + newListCmd(factory, regionsView), + newListCmd(factory, sitesView), + newListCmd(factory, clustersView), + newListCmd(factory, nodesView), + newTreeCmd(factory), + newSummaryCmd(factory), + ) + + return cmd +} diff --git a/internal/cmd/inventory/inventory_test.go b/internal/cmd/inventory/inventory_test.go new file mode 100644 index 0000000..4b2d784 --- /dev/null +++ b/internal/cmd/inventory/inventory_test.go @@ -0,0 +1,191 @@ +package inventory + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func obj(name string, labels map[string]string, spec, status map[string]interface{}) unstructured.Unstructured { + o := map[string]interface{}{"metadata": map[string]interface{}{"name": name}} + if labels != nil { + l := map[string]interface{}{} + for k, v := range labels { + l[k] = v + } + o["metadata"].(map[string]interface{})["labels"] = l + } + if spec != nil { + o["spec"] = spec + } + if status != nil { + o["status"] = status + } + return unstructured.Unstructured{Object: o} +} + +func readyCond(s string) map[string]interface{} { + return map[string]interface{}{ + "conditions": []interface{}{map[string]interface{}{"type": "Ready", "status": s}}, + } +} + +func TestStrAndIntStr(t *testing.T) { + u := obj("n", nil, map[string]interface{}{ + "hardware": map[string]interface{}{"cpuArchitecture": "arm64", "cpuCores": int64(96)}, + }, nil) + if got := str(u, "spec", "hardware", "cpuArchitecture"); got != "arm64" { + t.Errorf("str arch = %q, want arm64", got) + } + if got := intStr(u, "spec", "hardware", "cpuCores"); got != "96" { + t.Errorf("intStr cpu = %q, want 96", got) + } + if got := str(u, "spec", "missing"); got != none { + t.Errorf("str missing = %q, want %s", got, none) + } + if got := intStr(u, "spec", "missing"); got != none { + t.Errorf("intStr missing = %q, want %s", got, none) + } +} + +func TestReady(t *testing.T) { + cases := map[string]struct { + status map[string]interface{} + want string + }{ + "true": {readyCond("True"), "True"}, + "false": {readyCond("False"), "False"}, + "none": {nil, none}, + "noReady": {map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"type": "Accepted", "status": "True"}}}, none}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := ready(obj("x", nil, nil, tc.status)); got != tc.want { + t.Errorf("ready = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSitesViewRow(t *testing.T) { + u := obj("us-central-2a", map[string]string{labelRegion: "us-central-2"}, map[string]interface{}{ + "regionRef": map[string]interface{}{"name": "us-central-2"}, + "providerRef": map[string]interface{}{"name": "netactuate"}, + "type": "Edge", + }, readyCond("True")) + got := sitesView.row(u) + want := []any{"us-central-2a", "us-central-2", "netactuate", "Edge", "True"} + if len(got) != len(want) { + t.Fatalf("row len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("col %d = %v, want %v", i, got[i], want[i]) + } + } +} + +func TestFilterItemsPredicate(t *testing.T) { + list := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{ + obj("a", nil, map[string]interface{}{"providerRef": map[string]interface{}{"name": "vultr"}}, nil), + obj("b", nil, map[string]interface{}{"providerRef": map[string]interface{}{"name": "netactuate"}}, nil), + }} + filterItems(list, []func(u unstructured.Unstructured) bool{ + func(u unstructured.Unstructured) bool { return str(u, "spec", "providerRef", "name") == "netactuate" }, + }) + if len(list.Items) != 1 || list.Items[0].GetName() != "b" { + t.Fatalf("filterItems kept %v, want [b]", names(list)) + } +} + +func TestFilterItemsNoPredicateKeepsAll(t *testing.T) { + list := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{obj("a", nil, nil, nil), obj("b", nil, nil, nil)}} + filterItems(list, nil) + if len(list.Items) != 2 { + t.Fatalf("filterItems dropped items without predicate: %v", names(list)) + } +} + +func TestPrintTree(t *testing.T) { + regions := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{ + obj("us-central-2", nil, nil, nil), + obj("eu-west-1", nil, nil, nil), + }} + sites := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{ + obj("us-central-2a", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "us-central-2"}}, nil), + }} + clusters := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{ + obj("edge-1", map[string]string{labelRegion: "us-central-2"}, nil, nil), + }} + nodes := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{ + obj("node-1", nil, map[string]interface{}{"siteRef": map[string]interface{}{"name": "us-central-2a"}}, nil), + }} + + 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 output 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 other region:\n%s", buf.String()) + } +} + +func TestTallyAndUnion(t *testing.T) { + items := []unstructured.Unstructured{ + obj("a", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r1"}}, nil), + obj("b", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r1"}}, nil), + obj("c", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r2"}}, nil), + } + got := tally(items, func(u unstructured.Unstructured) string { return str(u, "spec", "regionRef", "name") }) + if got["r1"] != 2 || got["r2"] != 1 { + t.Errorf("tally = %v, want r1:2 r2:1", got) + } + u := union(map[string]int{"r1": 1}, map[string]int{"r2": 1, "r1": 3}) + if strings.Join(u, ",") != "r1,r2" { + t.Errorf("union = %v, want [r1 r2] sorted", u) + } +} + +func TestRenderJSONYAMLUnstructured(t *testing.T) { + list := &unstructured.UnstructuredList{} + list.SetAPIVersion("inventory.miloapis.com/v1alpha1") + list.SetKind("SiteList") + list.Items = []unstructured.Unstructured{obj("s1", nil, map[string]interface{}{"type": "Edge"}, nil)} + for _, f := range []string{"json", "yaml"} { + var buf bytes.Buffer + c := &cobra.Command{} + c.SetOut(&buf) + if err := render(c, f, list, sitesView.headers, sitesView.row); err != nil { + t.Fatalf("%s render err: %v", f, err) + } + if !strings.Contains(buf.String(), "s1") { + t.Errorf("%s output missing name s1:\n%s", f, buf.String()) + } + } +} + +func TestRenderInvalidFormat(t *testing.T) { + c := &cobra.Command{} + c.SetOut(&bytes.Buffer{}) + if err := render(c, "xml", &unstructured.UnstructuredList{}, nil, nil); err == nil { + t.Fatal("render with invalid format should error") + } +} + +func names(list *unstructured.UnstructuredList) []string { + var out []string + for _, i := range list.Items { + out = append(out, i.GetName()) + } + return out +} diff --git a/internal/cmd/inventory/list.go b/internal/cmd/inventory/list.go new file mode 100644 index 0000000..825c750 --- /dev/null +++ b/internal/cmd/inventory/list.go @@ -0,0 +1,261 @@ +package inventory + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kubectl/pkg/util/templates" + + "go.datum.net/datumctl/internal/client" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/output" +) + +const ( + apiGroup = "inventory.miloapis.com" + apiVersion = "v1alpha1" + + labelRegion = "topology.inventory.miloapis.com/region" + labelSite = "topology.inventory.miloapis.com/site" + labelCluster = "topology.inventory.miloapis.com/cluster" +) + +func gvr(resource string) schema.GroupVersionResource { + return schema.GroupVersionResource{Group: apiGroup, Version: apiVersion, Resource: resource} +} + +// filterFlag declares one --flag on a list subcommand. A flag either narrows +// the server-side label selector (labelKey set) or filters client-side on a +// spec field (predicate set); never both. +type filterFlag struct { + name string + usage string + labelKey string + predicate func(u unstructured.Unstructured, value string) bool +} + +// resourceView describes how to list and render one inventory kind. +type resourceView struct { + resource string + use string + short string + headers []any + row func(u unstructured.Unstructured) []any + filters []filterFlag +} + +var providersView = resourceView{ + resource: "providers", + use: "providers", + short: "List inventory providers", + headers: []any{"NAME", "DISPLAY", "TYPE", "READY"}, + row: func(u unstructured.Unstructured) []any { + return []any{u.GetName(), str(u, "spec", "displayName"), str(u, "spec", "type"), ready(u)} + }, +} + +var regionsView = resourceView{ + resource: "regions", + use: "regions", + short: "List inventory regions", + headers: []any{"NAME", "DISPLAY", "READY"}, + row: func(u unstructured.Unstructured) []any { + return []any{u.GetName(), str(u, "spec", "displayName"), ready(u)} + }, +} + +var sitesView = resourceView{ + resource: "sites", + use: "sites", + short: "List inventory sites", + headers: []any{"NAME", "REGION", "PROVIDER", "TYPE", "READY"}, + row: func(u unstructured.Unstructured) []any { + return []any{ + u.GetName(), + str(u, "spec", "regionRef", "name"), + str(u, "spec", "providerRef", "name"), + str(u, "spec", "type"), + ready(u), + } + }, + filters: []filterFlag{ + {name: "region", usage: "Filter by region name", labelKey: labelRegion}, + {name: "provider", usage: "Filter by provider name", predicate: func(u unstructured.Unstructured, v string) bool { + return str(u, "spec", "providerRef", "name") == v + }}, + }, +} + +var clustersView = resourceView{ + resource: "clusters", + use: "clusters", + short: "List inventory clusters", + headers: []any{"NAME", "REGION", "CP-SITE", "ROLE", "PROVIDER", "READY"}, + row: func(u unstructured.Unstructured) []any { + return []any{ + u.GetName(), + u.GetLabels()[labelRegion], + str(u, "spec", "controlPlaneSiteRef", "name"), + str(u, "spec", "role"), + str(u, "spec", "provider"), + ready(u), + } + }, + filters: []filterFlag{ + {name: "region", usage: "Filter by region name", labelKey: labelRegion}, + {name: "site", usage: "Filter by control-plane site name", predicate: func(u unstructured.Unstructured, v string) bool { + return str(u, "spec", "controlPlaneSiteRef", "name") == v + }}, + }, +} + +var nodesView = resourceView{ + resource: "nodes", + use: "nodes", + short: "List inventory nodes", + headers: []any{"NAME", "SITE", "CLUSTER", "ROLE", "ARCH", "CPU", "PHASE", "READY"}, + row: func(u unstructured.Unstructured) []any { + return []any{ + u.GetName(), + str(u, "spec", "siteRef", "name"), + str(u, "spec", "assignment", "clusterRef", "name"), + str(u, "spec", "assignment", "role"), + str(u, "spec", "hardware", "cpuArchitecture"), + intStr(u, "spec", "hardware", "cpuCores"), + str(u, "status", "phase"), + ready(u), + } + }, + filters: []filterFlag{ + {name: "region", usage: "Filter by region name", labelKey: labelRegion}, + {name: "site", usage: "Filter by site name", labelKey: labelSite}, + {name: "cluster", usage: "Filter by cluster name", labelKey: labelCluster}, + }, +} + +func newListCmd(factory *client.DatumCloudFactory, view resourceView) *cobra.Command { + values := make(map[string]*string, len(view.filters)) + cmd := &cobra.Command{ + Use: view.use, + Short: view.short, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + format, _ := cmd.Flags().GetString("output") + applyInventoryScope(cmd, factory) + + selector := labels.Set{} + var predicates []func(u unstructured.Unstructured) bool + for _, f := range view.filters { + v := *values[f.name] + if v == "" { + continue + } + switch { + case f.labelKey != "": + selector[f.labelKey] = v + case f.predicate != nil: + fn, val := f.predicate, v + predicates = append(predicates, func(u unstructured.Unstructured) bool { return fn(u, val) }) + } + } + + list, err := listResources(cmd.Context(), factory, view.resource, selector.String()) + if err != nil { + return err + } + filterItems(list, predicates) + return render(cmd, format, list, view.headers, view.row) + }, + } + for i := range view.filters { + f := view.filters[i] + values[f.name] = cmd.Flags().String(f.name, "", f.usage) + } + cmd.Example = listExample(view) + return cmd +} + +func listExample(view resourceView) string { + lines := []string{fmt.Sprintf("# List all %s", view.resource), "datumctl inventory " + view.use} + for _, f := range view.filters { + lines = append(lines, + "", + fmt.Sprintf("# Filter by %s", f.name), + fmt.Sprintf("datumctl inventory %s --%s <%s>", view.use, f.name, f.name)) + } + return templates.Examples(strings.Join(lines, "\n")) +} + +// applyInventoryScope defaults to the platform root, where inventory lives, +// unless the user explicitly selected an organization or project scope. +func applyInventoryScope(cmd *cobra.Command, factory *client.DatumCloudFactory) { + if cmd.Flags().Changed("platform-wide") || + cmd.Flags().Changed("organization") || + cmd.Flags().Changed("project") { + return + } + *factory.ConfigFlags.PlatformWide = true +} + +func listResources(ctx context.Context, factory *client.DatumCloudFactory, resource, selector string) (*unstructured.UnstructuredList, error) { + dc, err := factory.DynamicClient() + if err != nil { + return nil, customerrors.NewUserError(fmt.Sprintf("could not reach Datum Cloud: %v", err)) + } + list, err := dc.Resource(gvr(resource)).List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return nil, customerrors.NewUserError(fmt.Sprintf("could not list %s: %v", resource, err)) + } + sort.Slice(list.Items, func(i, j int) bool { return list.Items[i].GetName() < list.Items[j].GetName() }) + return list, nil +} + +func filterItems(list *unstructured.UnstructuredList, predicates []func(u unstructured.Unstructured) bool) { + if len(predicates) == 0 { + return + } + kept := list.Items[:0] + for _, item := range list.Items { + match := true + for _, p := range predicates { + if !p(item) { + match = false + break + } + } + if match { + kept = append(kept, item) + } + } + list.Items = kept +} + +func render(cmd *cobra.Command, format string, list *unstructured.UnstructuredList, headers []any, row func(u unstructured.Unstructured) []any) error { + switch format { + case "json", "yaml": + return output.CLIPrint(cmd.OutOrStdout(), format, list, nil) + case "", "table": + rows := make([][]any, 0, len(list.Items)) + for _, item := range list.Items { + rows = append(rows, row(item)) + } + if len(rows) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No matching inventory found.") + return nil + } + return output.CLIPrint(cmd.OutOrStdout(), "table", list, func() (output.ColumnFormatter, output.RowFormatterFunc) { + return output.ColumnFormatter(headers), func() output.RowFormatter { return rows } + }) + default: + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("invalid value %q for --output", format), + "Allowed values: table, json, yaml.") + } +} diff --git a/internal/cmd/inventory/summary.go b/internal/cmd/inventory/summary.go new file mode 100644 index 0000000..763c92b --- /dev/null +++ b/internal/cmd/inventory/summary.go @@ -0,0 +1,114 @@ +package inventory + +import ( + "fmt" + "io" + "sort" + + "github.com/rodaine/table" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/util/templates" + + "go.datum.net/datumctl/internal/client" +) + +func newSummaryCmd(factory *client.DatumCloudFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "summary", + Short: "Show fleet-wide inventory counts", + Long: templates.LongDesc(` + Print fleet-wide counts: totals per kind, sites and nodes per region, + and nodes per provider.`), + Example: templates.Examples(` + # Fleet-wide counts + datumctl inventory summary`), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + applyInventoryScope(cmd, factory) + ctx := cmd.Context() + + counts := map[string]int{} + lists := map[string]*unstructured.UnstructuredList{} + for _, kind := range []string{"providers", "regions", "sites", "clusters", "nodes"} { + l, err := listResources(ctx, factory, kind, "") + if err != nil { + return err + } + lists[kind] = l + counts[kind] = len(l.Items) + } + + printSummary(cmd.OutOrStdout(), counts, lists) + return nil + }, + } + return cmd +} + +func printSummary(w io.Writer, counts map[string]int, lists map[string]*unstructured.UnstructuredList) { + fmt.Fprintln(w, "Totals") + totals := table.New("KIND", "COUNT") + totals.WithWriter(w) + for _, kind := range []string{"providers", "regions", "sites", "clusters", "nodes"} { + totals.AddRow(kind, counts[kind]) + } + totals.Print() + + sitesPerRegion := tally(lists["sites"].Items, func(u unstructured.Unstructured) string { return str(u, "spec", "regionRef", "name") }) + nodesPerRegion := tally(lists["nodes"].Items, func(u unstructured.Unstructured) string { + if r := u.GetLabels()[labelRegion]; r != "" { + return r + } + return none + }) + fmt.Fprintln(w, "\nPer region") + perRegion := table.New("REGION", "SITES", "NODES") + perRegion.WithWriter(w) + for _, region := range union(sitesPerRegion, nodesPerRegion) { + perRegion.AddRow(region, sitesPerRegion[region], nodesPerRegion[region]) + } + perRegion.Print() + + sitesPerProvider := tally(lists["sites"].Items, func(u unstructured.Unstructured) string { return str(u, "spec", "providerRef", "name") }) + fmt.Fprintln(w, "\nSites per provider") + perProvider := table.New("PROVIDER", "SITES") + perProvider.WithWriter(w) + for _, provider := range sortedKeys(sitesPerProvider) { + perProvider.AddRow(provider, sitesPerProvider[provider]) + } + perProvider.Print() +} + +func tally(items []unstructured.Unstructured, key func(u unstructured.Unstructured) string) map[string]int { + out := map[string]int{} + for _, item := range items { + out[key(item)]++ + } + return out +} + +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 union(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/internal/cmd/inventory/tree.go b/internal/cmd/inventory/tree.go new file mode 100644 index 0000000..9523392 --- /dev/null +++ b/internal/cmd/inventory/tree.go @@ -0,0 +1,125 @@ +package inventory + +import ( + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/util/templates" + + "go.datum.net/datumctl/internal/client" +) + +func newTreeCmd(factory *client.DatumCloudFactory) *cobra.Command { + var regionFilter string + cmd := &cobra.Command{ + Use: "tree", + Short: "Show the region -> site -> node hierarchy", + Long: templates.LongDesc(` + 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: templates.Examples(` + # Full topology tree + datumctl inventory tree + + # Just one region + datumctl inventory tree --region us-central-2`), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + applyInventoryScope(cmd, factory) + ctx := cmd.Context() + + regions, err := listResources(ctx, factory, "regions", "") + if err != nil { + return err + } + sites, err := listResources(ctx, factory, "sites", "") + if err != nil { + return err + } + clusters, err := listResources(ctx, factory, "clusters", "") + if err != nil { + return err + } + nodes, err := listResources(ctx, factory, "nodes", "") + if err != nil { + return err + } + + printTree(cmd.OutOrStdout(), regionFilter, regions, sites, clusters, nodes) + return nil + }, + } + cmd.Flags().StringVar(®ionFilter, "region", "", "Limit the tree to a single region") + return cmd +} + +func printTree(w io.Writer, regionFilter string, regions, sites, clusters, nodes *unstructured.UnstructuredList) { + sitesByRegion := groupBy(sites.Items, func(u unstructured.Unstructured) string { return str(u, "spec", "regionRef", "name") }) + nodesBySite := groupBy(nodes.Items, func(u unstructured.Unstructured) string { return str(u, "spec", "siteRef", "name") }) + clustersByRegion := groupBy(clusters.Items, func(u unstructured.Unstructured) string { + if r := u.GetLabels()[labelRegion]; r != "" { + return r + } + return none + }) + + names := make([]string, 0, len(regions.Items)) + for _, r := range regions.Items { + names = append(names, r.GetName()) + } + sort.Strings(names) + + printed := 0 + for _, region := range names { + if regionFilter != "" && region != regionFilter { + continue + } + printed++ + fmt.Fprintf(w, "%s\n", region) + + if cls := clustersByRegion[region]; len(cls) > 0 { + sort.Strings(cls) + fmt.Fprintf(w, " clusters: %s\n", join(cls)) + } + + regionSites := sitesByRegion[region] + sort.Strings(regionSites) + for _, site := range regionSites { + fmt.Fprintf(w, " %s\n", site) + siteNodes := nodesBySite[site] + sort.Strings(siteNodes) + for _, n := range siteNodes { + fmt.Fprintf(w, " %s\n", n) + } + } + } + + if printed == 0 { + fmt.Fprintln(w, "No matching inventory found.") + } +} + +func groupBy(items []unstructured.Unstructured, key func(u unstructured.Unstructured) string) map[string][]string { + out := map[string][]string{} + for _, item := range items { + k := key(item) + out[k] = append(out[k], item.GetName()) + } + return out +} + +func join(items []string) string { + out := "" + for i, s := range items { + if i > 0 { + out += ", " + } + out += s + } + return out +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bcc6635..6573a51 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -38,6 +38,7 @@ import ( "go.datum.net/datumctl/internal/cmd/create" datumctx "go.datum.net/datumctl/internal/cmd/ctx" "go.datum.net/datumctl/internal/cmd/docs" + "go.datum.net/datumctl/internal/cmd/inventory" "go.datum.net/datumctl/internal/cmd/login" "go.datum.net/datumctl/internal/cmd/logout" plugincmd "go.datum.net/datumctl/internal/cmd/plugin" @@ -283,6 +284,10 @@ Get started: autoUpdateCmd.GroupID = "other" rootCmd.AddCommand(autoUpdateCmd) + inventoryCmd := inventory.Command(factory) + inventoryCmd.GroupID = "resource" + rootCmd.AddCommand(inventoryCmd) + authCommand := auth.Command() whoami := kubeauth.NewCmdWhoAmI(factory, ioStreams) whoami.Short = "Show your identity on a Datum Cloud control plane (kubectl users only)"