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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/inventory.md
Original file line number Diff line number Diff line change
@@ -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
```
48 changes: 48 additions & 0 deletions internal/cmd/inventory/fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package inventory

import (
"strconv"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

const none = "<none>"

// str reads a nested string field, returning "<none>" 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, "<none>" 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
// "<none>" 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
}
62 changes: 62 additions & 0 deletions internal/cmd/inventory/inventory.go
Original file line number Diff line number Diff line change
@@ -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
}
191 changes: 191 additions & 0 deletions internal/cmd/inventory/inventory_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading