Skip to content

Commit 6699d39

Browse files
committed
feat: add inventory data view command
Add `datumctl inventory`, a read view over the Datum Cloud physical inventory (providers, regions, sites, clusters, nodes) loaded into the inventory.miloapis.com CRDs. Generic `get` already lists these via the CRDs' printer columns; this adds the topology navigation and rollups that `get` cannot express. Key features: - List subcommands per kind (providers, regions, sites, clusters, nodes) with curated default columns and -o table/json/yaml - Filter flags --region/--site/--cluster resolve server-side via the topology.inventory.miloapis.com/* labels; --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 - Defaults to --platform-wide (inventory lives on the platform root); --organization/--project override - Reads via the dynamic client over unstructured objects, so datumctl takes no dependency on the inventory Go types Closes #211
1 parent 59e4d21 commit 6699d39

8 files changed

Lines changed: 882 additions & 0 deletions

File tree

docs/inventory.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: "Inventory"
3+
sidebar:
4+
order: 4
5+
---
6+
7+
`datumctl inventory` is a read view over the Datum Cloud physical inventory —
8+
the providers, regions, sites, clusters, and nodes that make up the real
9+
infrastructure Datum Cloud runs on. Use it to answer questions like "which sites
10+
are in this region?", "which provider owns this site?", and "which nodes are
11+
assigned to this cluster?" without writing label selectors by hand.
12+
13+
Inventory records live on the platform root, so every `inventory` subcommand
14+
defaults to `--platform-wide`. Pass `--organization` or `--project` to override
15+
the scope.
16+
17+
## Listing resources
18+
19+
Each kind has its own list subcommand:
20+
21+
```
22+
datumctl inventory providers
23+
datumctl inventory regions
24+
datumctl inventory sites
25+
datumctl inventory clusters
26+
datumctl inventory nodes
27+
```
28+
29+
By default each prints a table with the most useful columns. Use `-o json` or
30+
`-o yaml` for the full objects (handy for scripting):
31+
32+
```
33+
datumctl inventory sites -o json
34+
```
35+
36+
## Filtering
37+
38+
List subcommands accept filter flags that narrow the results by topology:
39+
40+
```
41+
# Sites in one region
42+
datumctl inventory sites --region us-central-2
43+
44+
# Sites from one provider
45+
datumctl inventory sites --provider netactuate
46+
47+
# Nodes at a site, or assigned to a cluster
48+
datumctl inventory nodes --site us-central-2a
49+
datumctl inventory nodes --cluster my-edge-cluster
50+
51+
# Clusters in a region
52+
datumctl inventory clusters --region us-central-2
53+
```
54+
55+
Region, site, and cluster filters are resolved server-side using the
56+
`topology.inventory.miloapis.com/*` labels that the platform propagates onto
57+
inventory objects. The provider filter matches on the site's `providerRef`.
58+
59+
## Topology tree
60+
61+
`datumctl inventory tree` prints the region → site → node hierarchy, with the
62+
clusters anchored in each region listed alongside:
63+
64+
```
65+
datumctl inventory tree
66+
datumctl inventory tree --region us-central-2
67+
```
68+
69+
## Summary
70+
71+
`datumctl inventory summary` prints fleet-wide counts: totals per kind, sites
72+
and nodes per region, and sites per provider.
73+
74+
```
75+
datumctl inventory summary
76+
```

internal/cmd/inventory/fields.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package inventory
2+
3+
import (
4+
"strconv"
5+
6+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
7+
)
8+
9+
const none = "<none>"
10+
11+
// str reads a nested string field, returning "<none>" when absent or empty.
12+
func str(u unstructured.Unstructured, fields ...string) string {
13+
v, found, err := unstructured.NestedString(u.Object, fields...)
14+
if err != nil || !found || v == "" {
15+
return none
16+
}
17+
return v
18+
}
19+
20+
// intStr reads a nested integer field as a string, "<none>" when absent.
21+
func intStr(u unstructured.Unstructured, fields ...string) string {
22+
v, found, err := unstructured.NestedInt64(u.Object, fields...)
23+
if err != nil || !found {
24+
return none
25+
}
26+
return strconv.FormatInt(v, 10)
27+
}
28+
29+
// ready returns the status of the "Ready" condition ("True"/"False"), or
30+
// "<none>" when the object carries no such condition yet.
31+
func ready(u unstructured.Unstructured) string {
32+
conds, found, err := unstructured.NestedSlice(u.Object, "status", "conditions")
33+
if err != nil || !found {
34+
return none
35+
}
36+
for _, c := range conds {
37+
m, ok := c.(map[string]interface{})
38+
if !ok {
39+
continue
40+
}
41+
if m["type"] == "Ready" {
42+
if s, ok := m["status"].(string); ok && s != "" {
43+
return s
44+
}
45+
}
46+
}
47+
return none
48+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Package inventory defines the `datumctl inventory` command tree — a
2+
// purpose-built read view over the Datum Cloud physical inventory
3+
// (providers, regions, sites, clusters, nodes).
4+
package inventory
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
"k8s.io/kubectl/pkg/util/templates"
9+
10+
"go.datum.net/datumctl/internal/client"
11+
)
12+
13+
// Command returns the `datumctl inventory` parent command.
14+
func Command(factory *client.DatumCloudFactory) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "inventory",
17+
Short: "Browse the Datum Cloud physical inventory",
18+
Long: templates.LongDesc(`
19+
Browse the Datum Cloud physical inventory: providers, regions, sites,
20+
clusters, and nodes.
21+
22+
These records describe the real infrastructure Datum Cloud runs on —
23+
which provider owns a site, which region a site sits in, and which
24+
nodes are assigned to which cluster. Use the list subcommands to query
25+
one kind at a time, 'inventory tree' to see the region/site/node
26+
hierarchy, and 'inventory summary' for fleet-wide counts.
27+
28+
Inventory lives on the platform root, so these commands default to
29+
--platform-wide. Pass --organization or --project to override.`),
30+
Example: templates.Examples(`
31+
# List every region
32+
datumctl inventory regions
33+
34+
# Sites in one region, by provider
35+
datumctl inventory sites --region us-central-2
36+
datumctl inventory sites --provider netactuate
37+
38+
# Nodes at a site or in a cluster
39+
datumctl inventory nodes --site us-central-2a
40+
datumctl inventory nodes --cluster my-edge-cluster
41+
42+
# Region -> site -> node hierarchy
43+
datumctl inventory tree
44+
45+
# Fleet-wide counts
46+
datumctl inventory summary`),
47+
}
48+
49+
cmd.PersistentFlags().StringP("output", "o", "table", "Output format. One of: table, json, yaml.")
50+
51+
cmd.AddCommand(
52+
newListCmd(factory, providersView),
53+
newListCmd(factory, regionsView),
54+
newListCmd(factory, sitesView),
55+
newListCmd(factory, clustersView),
56+
newListCmd(factory, nodesView),
57+
newTreeCmd(factory),
58+
newSummaryCmd(factory),
59+
)
60+
61+
return cmd
62+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package inventory
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/spf13/cobra"
9+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
10+
)
11+
12+
func obj(name string, labels map[string]string, spec, status map[string]interface{}) unstructured.Unstructured {
13+
o := map[string]interface{}{"metadata": map[string]interface{}{"name": name}}
14+
if labels != nil {
15+
l := map[string]interface{}{}
16+
for k, v := range labels {
17+
l[k] = v
18+
}
19+
o["metadata"].(map[string]interface{})["labels"] = l
20+
}
21+
if spec != nil {
22+
o["spec"] = spec
23+
}
24+
if status != nil {
25+
o["status"] = status
26+
}
27+
return unstructured.Unstructured{Object: o}
28+
}
29+
30+
func readyCond(s string) map[string]interface{} {
31+
return map[string]interface{}{
32+
"conditions": []interface{}{map[string]interface{}{"type": "Ready", "status": s}},
33+
}
34+
}
35+
36+
func TestStrAndIntStr(t *testing.T) {
37+
u := obj("n", nil, map[string]interface{}{
38+
"hardware": map[string]interface{}{"cpuArchitecture": "arm64", "cpuCores": int64(96)},
39+
}, nil)
40+
if got := str(u, "spec", "hardware", "cpuArchitecture"); got != "arm64" {
41+
t.Errorf("str arch = %q, want arm64", got)
42+
}
43+
if got := intStr(u, "spec", "hardware", "cpuCores"); got != "96" {
44+
t.Errorf("intStr cpu = %q, want 96", got)
45+
}
46+
if got := str(u, "spec", "missing"); got != none {
47+
t.Errorf("str missing = %q, want %s", got, none)
48+
}
49+
if got := intStr(u, "spec", "missing"); got != none {
50+
t.Errorf("intStr missing = %q, want %s", got, none)
51+
}
52+
}
53+
54+
func TestReady(t *testing.T) {
55+
cases := map[string]struct {
56+
status map[string]interface{}
57+
want string
58+
}{
59+
"true": {readyCond("True"), "True"},
60+
"false": {readyCond("False"), "False"},
61+
"none": {nil, none},
62+
"noReady": {map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"type": "Accepted", "status": "True"}}}, none},
63+
}
64+
for name, tc := range cases {
65+
t.Run(name, func(t *testing.T) {
66+
if got := ready(obj("x", nil, nil, tc.status)); got != tc.want {
67+
t.Errorf("ready = %q, want %q", got, tc.want)
68+
}
69+
})
70+
}
71+
}
72+
73+
func TestSitesViewRow(t *testing.T) {
74+
u := obj("us-central-2a", map[string]string{labelRegion: "us-central-2"}, map[string]interface{}{
75+
"regionRef": map[string]interface{}{"name": "us-central-2"},
76+
"providerRef": map[string]interface{}{"name": "netactuate"},
77+
"type": "Edge",
78+
}, readyCond("True"))
79+
got := sitesView.row(u)
80+
want := []any{"us-central-2a", "us-central-2", "netactuate", "Edge", "True"}
81+
if len(got) != len(want) {
82+
t.Fatalf("row len = %d, want %d", len(got), len(want))
83+
}
84+
for i := range want {
85+
if got[i] != want[i] {
86+
t.Errorf("col %d = %v, want %v", i, got[i], want[i])
87+
}
88+
}
89+
}
90+
91+
func TestFilterItemsPredicate(t *testing.T) {
92+
list := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{
93+
obj("a", nil, map[string]interface{}{"providerRef": map[string]interface{}{"name": "vultr"}}, nil),
94+
obj("b", nil, map[string]interface{}{"providerRef": map[string]interface{}{"name": "netactuate"}}, nil),
95+
}}
96+
filterItems(list, []func(u unstructured.Unstructured) bool{
97+
func(u unstructured.Unstructured) bool { return str(u, "spec", "providerRef", "name") == "netactuate" },
98+
})
99+
if len(list.Items) != 1 || list.Items[0].GetName() != "b" {
100+
t.Fatalf("filterItems kept %v, want [b]", names(list))
101+
}
102+
}
103+
104+
func TestFilterItemsNoPredicateKeepsAll(t *testing.T) {
105+
list := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{obj("a", nil, nil, nil), obj("b", nil, nil, nil)}}
106+
filterItems(list, nil)
107+
if len(list.Items) != 2 {
108+
t.Fatalf("filterItems dropped items without predicate: %v", names(list))
109+
}
110+
}
111+
112+
func TestPrintTree(t *testing.T) {
113+
regions := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{
114+
obj("us-central-2", nil, nil, nil),
115+
obj("eu-west-1", nil, nil, nil),
116+
}}
117+
sites := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{
118+
obj("us-central-2a", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "us-central-2"}}, nil),
119+
}}
120+
clusters := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{
121+
obj("edge-1", map[string]string{labelRegion: "us-central-2"}, nil, nil),
122+
}}
123+
nodes := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{
124+
obj("node-1", nil, map[string]interface{}{"siteRef": map[string]interface{}{"name": "us-central-2a"}}, nil),
125+
}}
126+
127+
var buf bytes.Buffer
128+
printTree(&buf, "", regions, sites, clusters, nodes)
129+
out := buf.String()
130+
for _, want := range []string{"us-central-2", "eu-west-1", "clusters: edge-1", " us-central-2a", " node-1"} {
131+
if !strings.Contains(out, want) {
132+
t.Errorf("tree output missing %q\n%s", want, out)
133+
}
134+
}
135+
136+
buf.Reset()
137+
printTree(&buf, "us-central-2", regions, sites, clusters, nodes)
138+
if strings.Contains(buf.String(), "eu-west-1") {
139+
t.Errorf("--region filter leaked other region:\n%s", buf.String())
140+
}
141+
}
142+
143+
func TestTallyAndUnion(t *testing.T) {
144+
items := []unstructured.Unstructured{
145+
obj("a", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r1"}}, nil),
146+
obj("b", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r1"}}, nil),
147+
obj("c", nil, map[string]interface{}{"regionRef": map[string]interface{}{"name": "r2"}}, nil),
148+
}
149+
got := tally(items, func(u unstructured.Unstructured) string { return str(u, "spec", "regionRef", "name") })
150+
if got["r1"] != 2 || got["r2"] != 1 {
151+
t.Errorf("tally = %v, want r1:2 r2:1", got)
152+
}
153+
u := union(map[string]int{"r1": 1}, map[string]int{"r2": 1, "r1": 3})
154+
if strings.Join(u, ",") != "r1,r2" {
155+
t.Errorf("union = %v, want [r1 r2] sorted", u)
156+
}
157+
}
158+
159+
func TestRenderJSONYAMLUnstructured(t *testing.T) {
160+
list := &unstructured.UnstructuredList{}
161+
list.SetAPIVersion("inventory.miloapis.com/v1alpha1")
162+
list.SetKind("SiteList")
163+
list.Items = []unstructured.Unstructured{obj("s1", nil, map[string]interface{}{"type": "Edge"}, nil)}
164+
for _, f := range []string{"json", "yaml"} {
165+
var buf bytes.Buffer
166+
c := &cobra.Command{}
167+
c.SetOut(&buf)
168+
if err := render(c, f, list, sitesView.headers, sitesView.row); err != nil {
169+
t.Fatalf("%s render err: %v", f, err)
170+
}
171+
if !strings.Contains(buf.String(), "s1") {
172+
t.Errorf("%s output missing name s1:\n%s", f, buf.String())
173+
}
174+
}
175+
}
176+
177+
func TestRenderInvalidFormat(t *testing.T) {
178+
c := &cobra.Command{}
179+
c.SetOut(&bytes.Buffer{})
180+
if err := render(c, "xml", &unstructured.UnstructuredList{}, nil, nil); err == nil {
181+
t.Fatal("render with invalid format should error")
182+
}
183+
}
184+
185+
func names(list *unstructured.UnstructuredList) []string {
186+
var out []string
187+
for _, i := range list.Items {
188+
out = append(out, i.GetName())
189+
}
190+
return out
191+
}

0 commit comments

Comments
 (0)