From f2b0b65d1d3be0ab0756ec97648f706cab793bca Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Fri, 12 Jun 2026 14:46:03 -0400 Subject: [PATCH] feat: speak inventory v1alpha2 property-graph model milo-os/inventory v0.1.0 ships a v1alpha2 API that collapses the typed per-kind CRDs into a property graph: generic Node + Edge (each with an attribute bag) under group graph.inventory.miloapis.com, with NodeType / EdgeType as the schema registry. Rework the plugin to speak it, replacing the v1alpha1 typed commands. Implements datum-cloud/inventory#9. Key changes: - Bump go.miloapis.com/inventory to v0.1.0; client + apply use the v1alpha2 scheme (graph.inventory.miloapis.com) - get : list nodes of an asset class with columns derived from the matching NodeType's attribute schema (falls back to the union of attribute keys present when no NodeType is registered) - get edges [--type/--from/--to]: list relationships - types: browse the NodeType/EdgeType registry - neighbors NODE [--edge/--direction]: graph traversal over edges - tree [--edge/--root-type]: containment hierarchy rebuilt from edges (default located-in, rooted at Region), with cycle guard - summary: counts per node type and per edge type - apply: graph kinds (NodeType, EdgeType, Node, Edge) in dependency order (types, then nodes, then edges); unsupported kinds rejected up front - Remove the v1alpha1 typed list commands (providers/regions/sites/ clusters/nodes) --- README.md | 53 +++++++------ apply.go | 41 ++++++---- apply_test.go | 73 +++++++++-------- client.go | 4 +- get.go | 162 ++++++++++++++++++++++++++++++++++++++ go.mod | 3 +- go.sum | 14 +--- list.go | 210 ------------------------------------------------- main.go | 55 ++++++------- neighbors.go | 92 ++++++++++++++++++++++ plugin_test.go | 193 +++++++++++++++++++++++++++++---------------- summary.go | 111 +++++++------------------- tree.go | 132 ++++++++++++++----------------- types.go | 105 +++++++++++++++++++++++++ 14 files changed, 693 insertions(+), 555 deletions(-) create mode 100644 get.go delete mode 100644 list.go create mode 100644 neighbors.go create mode 100644 types.go diff --git a/README.md b/README.md index 74e9e98..6020cc1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # datumctl-inventory -A [datumctl](https://github.com/datum-cloud/datumctl) plugin that provides a -read view over the Datum Cloud physical inventory — providers, regions, sites, -clusters, and nodes — served by the [milo inventory -service](https://github.com/milo-os/inventory) (`inventory.miloapis.com/v1alpha1`). -Once installed it is invoked as `datumctl inventory ...`. +A [datumctl](https://github.com/datum-cloud/datumctl) plugin for the Datum Cloud +inventory, modeled as a **property graph**: typed `Node`s (Region, Site, +Cluster, Provider, Host, …) connected by typed `Edge`s (located-in, member-of, +provided-by, …), each carrying an attribute bag. The available types and their +attributes live in the `NodeType`/`EdgeType` schema registry. Served by the +[milo inventory service](https://github.com/milo-os/inventory) +(`graph.inventory.miloapis.com/v1alpha2`). Invoked as `datumctl inventory ...`. ## Install @@ -19,41 +21,40 @@ exposes it as `datumctl inventory`. | 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 | -| `datumctl inventory apply -f FILE [--dry-run=server]` | Create/update objects from a manifest | +| `datumctl inventory get ` | List nodes of an asset class; columns derived from the NodeType schema | +| `datumctl inventory get edges [--type T] [--from N] [--to N]` | List edges (relationships) | +| `datumctl inventory types` | List the NodeType/EdgeType schema registry | +| `datumctl inventory neighbors NODE [--edge T] [--direction out\|in\|both]` | Nodes adjacent to NODE | +| `datumctl inventory tree [--edge T] [--root-type T]` | Containment hierarchy from edges (default: `located-in`, rooted at `Region`) | +| `datumctl inventory summary` | Counts per node type and edge type | +| `datumctl inventory apply -f FILE [--dry-run=server]` | Create/update graph objects from a manifest | -The list subcommands accept `-o table|json|yaml` (default `table`). +The `get`, `types`, `summary` commands accept `-o table|json|yaml` (default `table`). -`--region`, `--site`, and `--cluster` filter server-side using the -`topology.inventory.miloapis.com/*` labels the inventory controllers propagate -onto objects. `--provider` filters on the site's `providerRef`. +Relationships that were typed fields in the old model (a site's region, a +node's cluster) are now **edges** — query them with `get edges`, `neighbors`, +or `tree` rather than as columns. ## Populating the inventory -`apply` is an idempotent, declarative upsert for inventory objects — for -loading the inventory from declared configuration, not fleet management: +`apply` is an idempotent, declarative upsert of graph objects — for loading the +inventory from declared configuration, not fleet management: ```sh -# Apply a manifest (objects land in dependency order: provider, region, -# site, cluster, node — regardless of order in the file) -datumctl inventory apply -f fleet.yaml +# Apply a manifest (objects land in dependency order: node/edge types first, +# then nodes, then edges — regardless of order in the file) +datumctl inventory apply -f graph.yaml # Pipe from a renderer render-fleet | datumctl inventory apply -f - # Validate against the server without persisting -datumctl inventory apply -f fleet.yaml --dry-run=server +datumctl inventory apply -f graph.yaml --dry-run=server ``` It uses server-side apply with field manager `datumctl-inventory`, so -re-applying the same manifest makes no changes. Only `Provider`, `Region`, -`Site`, `Cluster`, and `Node` are accepted. +re-applying the same manifest makes no changes. Only `NodeType`, `EdgeType`, +`Node`, and `Edge` are accepted. 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 @@ -65,7 +66,7 @@ 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 the milo inventory project's published typed -API (`go.miloapis.com/inventory/api/v1alpha1`). See the +API (`go.miloapis.com/inventory/api/v1alpha2`). See the [datumctl plugin docs](https://github.com/datum-cloud/datumctl/blob/main/docs/developer/plugins.md). This split keeps Datum's CLI surface in `datum-cloud/` while depending on the diff --git a/apply.go b/apply.go index 15ab3cc..168d144 100644 --- a/apply.go +++ b/apply.go @@ -4,6 +4,7 @@ package main import ( "context" + "encoding/json" "fmt" "io" "os" @@ -11,19 +12,20 @@ import ( "strings" "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" utilyaml "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/controller-runtime/pkg/client" - inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" ) const fieldManager = "datumctl-inventory" -// applyOrder lists the inventory kinds apply handles, in dependency order: -// parents are applied before the children that reference them. -var applyOrder = []string{"Provider", "Region", "Site", "Cluster", "Node"} +// applyOrder lists the graph kinds apply handles, in dependency order: the +// type registry first, then nodes, then the edges that reference nodes. +var applyOrder = []string{"NodeType", "EdgeType", "Node", "Edge"} func kindOrder(kind string) (int, bool) { for i, k := range applyOrder { @@ -39,14 +41,14 @@ func newApplyCmd() *cobra.Command { var dryRun string cmd := &cobra.Command{ Use: "apply -f FILE", - Short: "Create or update inventory objects from a manifest", - Long: `Create or update inventory objects (providers, regions, sites, clusters, -nodes) from a YAML or JSON manifest. + Short: "Create or update inventory graph objects from a manifest", + Long: `Create or update inventory graph objects (NodeType, EdgeType, Node, Edge) +from a YAML or JSON manifest. apply is an idempotent, declarative upsert: re-applying the same manifest makes -no changes. Objects are applied in dependency order (providers, then regions, -then sites, then clusters, then nodes) so a single mixed manifest lands cleanly. -It uses server-side apply with field manager "datumctl-inventory". +no changes. Objects are applied in dependency order (node/edge types first, then +nodes, then the edges that reference them) so a single mixed manifest lands +cleanly. It uses server-side apply with field manager "datumctl-inventory". This is for populating the inventory from declared configuration — not fleet management. Inventory lives on the Datum Cloud platform root, so apply takes no @@ -106,7 +108,7 @@ type applyObj struct { // kinds apply does not handle. func readManifests(stdin io.Reader, files []string) ([]applyObj, error) { scheme := runtime.NewScheme() - if err := inventoryv1alpha1.AddToScheme(scheme); err != nil { + if err := inventoryv1alpha2.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("build scheme: %w", err) } decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer() @@ -130,15 +132,22 @@ func readManifests(stdin io.Reader, files []string) ([]applyObj, error) { if len(raw.Raw) == 0 { continue } - obj, gvk, derr := decoder.Decode(raw.Raw, nil, nil) - if derr != nil { + // Check the kind before typed decode so an unsupported kind gets a + // helpful message rather than the scheme's "not registered" error. + var tm metav1.TypeMeta + if derr := json.Unmarshal(raw.Raw, &tm); derr != nil { closeFn() - return nil, fmt.Errorf("decode %s: %w", f, derr) + return nil, fmt.Errorf("parse %s: %w", f, derr) } - order, ok := kindOrder(gvk.Kind) + order, ok := kindOrder(tm.Kind) if !ok { closeFn() - return nil, fmt.Errorf("unsupported kind %q in %s (apply handles: %s)", gvk.Kind, f, strings.Join(applyOrder, ", ")) + return nil, fmt.Errorf("unsupported kind %q in %s (apply handles: %s)", tm.Kind, f, strings.Join(applyOrder, ", ")) + } + obj, gvk, derr := decoder.Decode(raw.Raw, nil, nil) + if derr != nil { + closeFn() + return nil, fmt.Errorf("decode %s: %w", f, derr) } co, ok := obj.(client.Object) if !ok { diff --git a/apply_test.go b/apply_test.go index 84be544..c38d5fe 100644 --- a/apply_test.go +++ b/apply_test.go @@ -9,35 +9,32 @@ import ( ) const sampleManifest = ` -apiVersion: inventory.miloapis.com/v1alpha1 -kind: Node +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Edge metadata: - name: node-a + name: site-dfw1-in-uc spec: - siteRef: - name: us-central-1a - hardware: - cpuCores: 8 - cpuArchitecture: amd64 - memoryBytes: 1073741824 + type: located-in + from: + name: site-dfw1 + to: + name: region-uc --- -apiVersion: inventory.miloapis.com/v1alpha1 -kind: Provider +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: NodeType metadata: - name: netactuate + name: Site spec: - displayName: NetActuate - type: Hosting + displayName: Site --- -apiVersion: inventory.miloapis.com/v1alpha1 -kind: Site +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Node metadata: - name: us-central-1a + name: site-dfw1 spec: - displayName: Dallas - type: AvailabilityZone - regionRef: - name: us-central-1 + type: Site + attributes: + displayName: Dallas ` func TestReadManifestsParsesAndOrders(t *testing.T) { @@ -49,14 +46,13 @@ func TestReadManifestsParsesAndOrders(t *testing.T) { t.Fatalf("got %d objects, want 3", len(objs)) } sort.SliceStable(objs, func(i, j int) bool { return objs[i].order < objs[j].order }) - gotKinds := []string{objs[0].kind, objs[1].kind, objs[2].kind} - want := []string{"Provider", "Site", "Node"} + got := []string{objs[0].kind, objs[1].kind, objs[2].kind} + want := []string{"NodeType", "Node", "Edge"} for i := range want { - if gotKinds[i] != want[i] { - t.Errorf("order[%d] = %s, want %s (full: %v)", i, gotKinds[i], want[i], gotKinds) + if got[i] != want[i] { + t.Errorf("order[%d] = %s, want %s (full: %v)", i, got[i], want[i], got) } } - // GVK must be set on each object so server-side apply has apiVersion/kind. for _, o := range objs { if o.obj.GetObjectKind().GroupVersionKind().Kind == "" { t.Errorf("%s/%s missing GVK", o.kind, o.obj.GetName()) @@ -66,10 +62,10 @@ func TestReadManifestsParsesAndOrders(t *testing.T) { func TestReadManifestsRejectsUnsupportedKind(t *testing.T) { const m = ` -apiVersion: inventory.miloapis.com/v1alpha1 -kind: Rack +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Widget metadata: - name: rack-a + name: w ` _, err := readManifests(strings.NewReader(m), []string{"-"}) if err == nil || !strings.Contains(err.Error(), "unsupported kind") { @@ -88,15 +84,18 @@ func TestReadManifestsEmpty(t *testing.T) { } func TestKindOrder(t *testing.T) { - if _, ok := kindOrder("Provider"); !ok { - t.Error("Provider should be ordered") + nt, ok := kindOrder("NodeType") + if !ok { + t.Fatal("NodeType should be ordered") + } + e, ok := kindOrder("Edge") + if !ok { + t.Fatal("Edge should be ordered") } - p, _ := kindOrder("Provider") - n, _ := kindOrder("Node") - if !(p < n) { - t.Errorf("Provider (%d) should sort before Node (%d)", p, n) + if !(nt < e) { + t.Errorf("NodeType (%d) should sort before Edge (%d)", nt, e) } - if _, ok := kindOrder("Rack"); ok { - t.Error("Rack should not be applyable") + if _, ok := kindOrder("Widget"); ok { + t.Error("Widget should not be applyable") } } diff --git a/client.go b/client.go index dd05a49..170ee1e 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "go.datum.net/datumctl/plugin" - inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" ) // newClient builds a controller-runtime client against the Datum Cloud platform @@ -33,7 +33,7 @@ func newClient() (client.Client, error) { } scheme := runtime.NewScheme() - if err := inventoryv1alpha1.AddToScheme(scheme); err != nil { + if err := inventoryv1alpha2.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("build scheme: %w", err) } diff --git a/get.go b/get.go new file mode 100644 index 0000000..edceb6e --- /dev/null +++ b/get.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" +) + +func listErr(what string, err error) error { + return fmt.Errorf("could not list %s: %w", what, err) +} + +func newGetCmd() *cobra.Command { + var edgeType, from, to string + cmd := &cobra.Command{ + Use: "get (TYPE | edges)", + Short: "List inventory nodes of a type, or edges", + Long: `List inventory objects from the property graph. + + datumctl inventory get list nodes of a node type (e.g. Site, Region, + Cluster) — columns are derived from the + matching NodeType's attribute schema. + datumctl inventory get edges list edges (relationships), optionally + filtered by --type/--from/--to. + +Run 'datumctl inventory types' to see the available node and edge types.`, + Example: ` # Nodes of a type, with attribute columns + datumctl inventory get Site + datumctl inventory get Region + + # Edges, filtered + datumctl inventory get edges + datumctl inventory get edges --type=located-in + datumctl inventory get edges --from=site-dfw1`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if args[0] == "edges" { + return getEdges(cmd.Context(), cmd, c, edgeType, from, to) + } + return getNodes(cmd.Context(), cmd, c, args[0]) + }, + } + cmd.Flags().StringVar(&edgeType, "type", "", "Filter edges by type (use with 'get edges')") + cmd.Flags().StringVar(&from, "from", "", "Filter edges by source node name (use with 'get edges')") + cmd.Flags().StringVar(&to, "to", "", "Filter edges by target node name (use with 'get edges')") + return cmd +} + +func getNodes(ctx context.Context, cmd *cobra.Command, c client.Client, nodeType string) error { + var nodes inventoryv1alpha2.NodeList + if err := c.List(ctx, &nodes); err != nil { + return listErr("nodes", err) + } + kept := nodes.Items[:0] + for _, n := range nodes.Items { + if n.Spec.Type == nodeType { + kept = append(kept, n) + } + } + nodes.Items = kept + sort.Slice(nodes.Items, func(i, j int) bool { return nodes.Items[i].Name < nodes.Items[j].Name }) + + keys := attributeColumns(ctx, c, nodeType, nodes.Items) + headers := append([]string{"NAME"}, upperAll(keys)...) + headers = append(headers, "READY") + + rows := make([][]string, 0, len(nodes.Items)) + for _, n := range nodes.Items { + row := make([]string, 0, len(headers)) + row = append(row, n.Name) + for _, k := range keys { + row = append(row, orNone(n.Spec.Attributes[k])) + } + row = append(row, ready(n.Status.Conditions)) + rows = append(rows, row) + } + return emit(cmd, &nodes, headers, rows) +} + +// attributeColumns returns the attribute keys to render as columns for a node +// type: the NodeType's declared attribute schema (authoritative order) when it +// exists, otherwise the union of keys actually present on the listed nodes. +func attributeColumns(ctx context.Context, c client.Client, nodeType string, nodes []inventoryv1alpha2.Node) []string { + var nt inventoryv1alpha2.NodeType + if err := c.Get(ctx, types.NamespacedName{Name: nodeType}, &nt); err == nil { + keys := make([]string, 0, len(nt.Spec.Attributes)) + for _, a := range nt.Spec.Attributes { + keys = append(keys, a.Key) + } + if len(keys) > 0 { + return keys + } + } else if !errors.IsNotFound(err) { + // A real error (not just "no such NodeType") — fall through to union; + // the node list already succeeded, so don't fail the command on it. + _ = err + } + + set := map[string]struct{}{} + for _, n := range nodes { + for k := range n.Spec.Attributes { + set[k] = struct{}{} + } + } + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func getEdges(ctx context.Context, cmd *cobra.Command, c client.Client, edgeType, from, to string) error { + var edges inventoryv1alpha2.EdgeList + if err := c.List(ctx, &edges); err != nil { + return listErr("edges", err) + } + kept := edges.Items[:0] + for _, e := range edges.Items { + if edgeType != "" && e.Spec.Type != edgeType { + continue + } + if from != "" && e.Spec.From.Name != from { + continue + } + if to != "" && e.Spec.To.Name != to { + continue + } + kept = append(kept, e) + } + edges.Items = kept + sort.Slice(edges.Items, func(i, j int) bool { return edges.Items[i].Name < edges.Items[j].Name }) + + headers := []string{"NAME", "TYPE", "FROM", "TO", "READY"} + rows := make([][]string, 0, len(edges.Items)) + for _, e := range edges.Items { + rows = append(rows, []string{e.Name, e.Spec.Type, e.Spec.From.Name, e.Spec.To.Name, ready(e.Status.Conditions)}) + } + return emit(cmd, &edges, headers, rows) +} + +func upperAll(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.ToUpper(s) + } + return out +} diff --git a/go.mod b/go.mod index 0bc476a..e585955 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/spf13/cobra v1.10.2 go.datum.net/datumctl v0.15.0 - go.miloapis.com/inventory v0.0.10 + go.miloapis.com/inventory v0.1.0 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 sigs.k8s.io/controller-runtime v0.23.3 @@ -52,6 +52,7 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/api v0.36.1 // indirect k8s.io/apiextensions-apiserver v0.35.3 // indirect diff --git a/go.sum b/go.sum index 515c06a..1aeb175 100644 --- a/go.sum +++ b/go.sum @@ -113,8 +113,8 @@ 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.miloapis.com/inventory v0.0.10 h1:UVAgoDH9loyxO6TfrIN2B3OF6hVGY8QITxKGzQi23JA= -go.miloapis.com/inventory v0.0.10/go.mod h1:fRWyulXCPGTnxEIbFRjtg29BO8TmwPEKXVZhxV1pNYU= +go.miloapis.com/inventory v0.1.0 h1:b+NZzBg3uXDCz/ngJ9c4HwDd365iWWo2I7s5pzTByaM= +go.miloapis.com/inventory v0.1.0/go.mod h1:fRWyulXCPGTnxEIbFRjtg29BO8TmwPEKXVZhxV1pNYU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -141,8 +141,6 @@ 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= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -154,22 +152,14 @@ 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.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= -k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= -k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= -k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= -k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= -k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= diff --git a/list.go b/list.go deleted file mode 100644 index 0f2bc8b..0000000 --- a/list.go +++ /dev/null @@ -1,210 +0,0 @@ -// 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/main.go b/main.go index d34d196..9c31e67 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,8 @@ // 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. +// Command datumctl-inventory is a datumctl plugin for the Datum Cloud inventory +// property graph (typed Nodes and Edges with attribute bags). Invoked as +// `datumctl inventory ...` once installed. package main import ( @@ -19,49 +19,46 @@ func main() { plugin.ServeManifest(plugin.Manifest{ Name: "inventory", Version: version, - Description: "Browse the Datum Cloud physical inventory (providers, regions, sites, clusters, nodes)", + Description: "Browse and populate the Datum Cloud inventory graph (typed nodes and edges)", 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. + Short: "Browse the Datum Cloud inventory graph", + Long: `Browse and populate the Datum Cloud inventory, modeled as a property graph: +typed Nodes (Region, Site, Cluster, Provider, Host, ...) connected by typed +Edges (located-in, member-of, provided-by, ...), each carrying an attribute +bag. The available types and their attributes live in the NodeType/EdgeType +schema registry. -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. +Use 'get ' to list nodes of an asset class, 'get edges' for +relationships, 'types' to browse the schema registry, 'tree' and 'neighbors' +to walk the graph, and 'apply' to populate it. 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 + Example: ` # Nodes of a type, and the schema registry + datumctl inventory get Site + datumctl inventory types - # 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 + # Relationships and traversal + datumctl inventory get edges --type=located-in + datumctl inventory neighbors site-dfw1 --edge=located-in datumctl inventory tree - datumctl inventory summary`, + + # Counts, and populating the graph + datumctl inventory summary + datumctl inventory apply -f graph.yaml`, 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), + newGetCmd(), + newTypesCmd(), + newNeighborsCmd(), newTreeCmd(), newSummaryCmd(), newApplyCmd(), diff --git a/neighbors.go b/neighbors.go new file mode 100644 index 0000000..7ad0fc6 --- /dev/null +++ b/neighbors.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "fmt" + "sort" + + "github.com/spf13/cobra" + "sigs.k8s.io/controller-runtime/pkg/client" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" +) + +func newNeighborsCmd() *cobra.Command { + var edgeType, direction string + cmd := &cobra.Command{ + Use: "neighbors NODE", + Short: "List nodes adjacent to NODE via edges", + Long: `List the nodes directly connected to NODE by an edge. + +By default both directions are shown. Use --edge to follow only one +relationship type (e.g. located-in, member-of) and --direction to restrict to +outgoing or incoming edges.`, + Example: ` # Everything adjacent to a node + datumctl inventory neighbors site-dfw1 + + # Only what site-dfw1 is located in (outgoing located-in edges) + datumctl inventory neighbors site-dfw1 --edge=located-in --direction=out + + # What is located in region us-central (incoming located-in edges) + datumctl inventory neighbors region-us-central --edge=located-in --direction=in`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch direction { + case "out", "in", "both": + default: + return fmt.Errorf("invalid value %q for --direction; allowed: out, in, both", direction) + } + c, err := newClient() + if err != nil { + return err + } + return neighbors(cmd.Context(), cmd, c, args[0], edgeType, direction) + }, + } + cmd.Flags().StringVar(&edgeType, "edge", "", "Only follow edges of this type") + cmd.Flags().StringVar(&direction, "direction", "both", "Edge direction: out, in, or both") + return cmd +} + +func neighbors(ctx context.Context, cmd *cobra.Command, c client.Client, node, edgeType, direction string) error { + var edges inventoryv1alpha2.EdgeList + if err := c.List(ctx, &edges); err != nil { + return listErr("edges", err) + } + var nodes inventoryv1alpha2.NodeList + if err := c.List(ctx, &nodes); err != nil { + return listErr("nodes", err) + } + typeOf := map[string]string{} + for _, n := range nodes.Items { + typeOf[n.Name] = n.Spec.Type + } + + type hop struct{ edgeType, dir, neighbor, neighborType string } + var hops []hop + for _, e := range edges.Items { + if edgeType != "" && e.Spec.Type != edgeType { + continue + } + if (direction == "out" || direction == "both") && e.Spec.From.Name == node { + hops = append(hops, hop{e.Spec.Type, "out", e.Spec.To.Name, orNone(typeOf[e.Spec.To.Name])}) + } + if (direction == "in" || direction == "both") && e.Spec.To.Name == node { + hops = append(hops, hop{e.Spec.Type, "in", e.Spec.From.Name, orNone(typeOf[e.Spec.From.Name])}) + } + } + sort.Slice(hops, func(i, j int) bool { + if hops[i].edgeType != hops[j].edgeType { + return hops[i].edgeType < hops[j].edgeType + } + return hops[i].neighbor < hops[j].neighbor + }) + + rows := make([][]string, 0, len(hops)) + for _, h := range hops { + rows = append(rows, []string{h.edgeType, h.dir, h.neighbor, h.neighborType}) + } + return printTable(cmd.OutOrStdout(), []string{"EDGE-TYPE", "DIRECTION", "NEIGHBOR", "NEIGHBOR-TYPE"}, rows) +} diff --git a/plugin_test.go b/plugin_test.go index a302f1f..7d66584 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -4,16 +4,56 @@ package main import ( "bytes" + "context" "strings" "testing" + "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" - inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" ) -func readyConds(status metav1.ConditionStatus) []metav1.Condition { - return []metav1.Condition{{Type: "Ready", Status: status}} +func readyConds(s metav1.ConditionStatus) []metav1.Condition { + return []metav1.Condition{{Type: "Ready", Status: s}} +} + +func node(name, typ string, attrs map[string]string) inventoryv1alpha2.Node { + n := inventoryv1alpha2.Node{} + n.Name = name + n.Spec.Type = typ + n.Spec.Attributes = attrs + n.Status.Conditions = readyConds(metav1.ConditionTrue) + return n +} + +func edge(name, typ, from, to string) inventoryv1alpha2.Edge { + e := inventoryv1alpha2.Edge{} + e.Name = name + e.Spec.Type = typ + e.Spec.From = inventoryv1alpha2.NodeReference{Name: from} + e.Spec.To = inventoryv1alpha2.NodeReference{Name: to} + e.Status.Conditions = readyConds(metav1.ConditionTrue) + return e +} + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := inventoryv1alpha2.AddToScheme(s); err != nil { + t.Fatalf("scheme: %v", err) + } + return s +} + +func tableCmd() (*cobra.Command, *bytes.Buffer) { + var buf bytes.Buffer + c := &cobra.Command{} + c.Flags().StringP("output", "o", "table", "") + c.SetOut(&buf) + return c, &buf } func TestReady(t *testing.T) { @@ -23,94 +63,113 @@ func TestReady(t *testing.T) { 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) { +func TestPrintTableEmpty(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} + t.Errorf("empty table message missing: %q", buf.String()) } - 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"}}, + nodes := inventoryv1alpha2.NodeList{Items: []inventoryv1alpha2.Node{ + node("region-uc", "Region", nil), + node("site-dfw1", "Site", nil), + node("host-1", "Host", nil), + }} + edges := inventoryv1alpha2.EdgeList{Items: []inventoryv1alpha2.Edge{ + edge("e1", "located-in", "site-dfw1", "region-uc"), + edge("e2", "located-in", "host-1", "site-dfw1"), }} - 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) + printTree(&buf, nodes, edges, "located-in", "Region") out := buf.String() - for _, want := range []string{"us-central-2", "eu-west-1", "clusters: edge-1", " us-central-2a", " node-1"} { + for _, want := range []string{"region-uc (Region)", " site-dfw1 (Site)", " host-1 (Host)"} { 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"), + nodes := inventoryv1alpha2.NodeList{Items: []inventoryv1alpha2.Node{ + node("a", "Site", nil), node("b", "Site", nil), node("c", "Region", nil), + }} + edges := inventoryv1alpha2.EdgeList{Items: []inventoryv1alpha2.Edge{ + edge("e1", "located-in", "a", "c"), }} var buf bytes.Buffer - printSummary(&buf, inventoryv1alpha1.ProviderList{}, inventoryv1alpha1.RegionList{}, sites, inventoryv1alpha1.ClusterList{}, inventoryv1alpha1.NodeList{}) + printSummary(&buf, nodes, edges) out := buf.String() - for _, want := range []string{"Totals", "Per region", "Sites per provider", "netactuate", "r1"} { + for _, want := range []string{"Nodes (3 total)", "Site", "Region", "Edges (1 total)", "located-in"} { if !strings.Contains(out, want) { t.Errorf("summary missing %q:\n%s", want, out) } } } + +func TestGetEdgesFilters(t *testing.T) { + c := fakeclient.NewClientBuilder().WithScheme(newScheme(t)).WithObjects( + ptr(edge("e1", "located-in", "site-dfw1", "region-uc")), + ptr(edge("e2", "member-of", "host-1", "cluster-a")), + ).Build() + cmd, buf := tableCmd() + if err := getEdges(context.Background(), cmd, c, "located-in", "", ""); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "located-in") || strings.Contains(out, "member-of") { + t.Errorf("--type filter wrong:\n%s", out) + } +} + +func TestNeighbors(t *testing.T) { + c := fakeclient.NewClientBuilder().WithScheme(newScheme(t)).WithObjects( + ptr(node("site-dfw1", "Site", nil)), + ptr(node("region-uc", "Region", nil)), + ptr(node("host-1", "Host", nil)), + ptr(edge("e1", "located-in", "site-dfw1", "region-uc")), + ptr(edge("e2", "located-in", "host-1", "site-dfw1")), + ).Build() + cmd, buf := tableCmd() + if err := neighbors(context.Background(), cmd, c, "site-dfw1", "located-in", "both"); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "region-uc") || !strings.Contains(out, "host-1") { + t.Errorf("neighbors missing expected nodes:\n%s", out) + } + if !strings.Contains(out, "out") || !strings.Contains(out, "in") { + t.Errorf("neighbors missing both directions:\n%s", out) + } +} + +func TestAttributeColumnsFallbackUnion(t *testing.T) { + c := fakeclient.NewClientBuilder().WithScheme(newScheme(t)).Build() + nodes := []inventoryv1alpha2.Node{ + node("a", "Site", map[string]string{"displayName": "DFW", "siteType": "Edge"}), + node("b", "Site", map[string]string{"displayName": "ORD"}), + } + keys := attributeColumns(context.Background(), c, "Site", nodes) + if strings.Join(keys, ",") != "displayName,siteType" { + t.Errorf("fallback union = %v, want [displayName siteType]", keys) + } +} + +func TestAttributeColumnsFromNodeType(t *testing.T) { + nt := inventoryv1alpha2.NodeType{} + nt.Name = "Site" + nt.Spec.Attributes = []inventoryv1alpha2.AttributeSchema{ + {Key: "displayName", Type: inventoryv1alpha2.AttributeString}, + {Key: "siteType", Type: inventoryv1alpha2.AttributeString}, + } + c := fakeclient.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(&nt).Build() + keys := attributeColumns(context.Background(), c, "Site", nil) + if strings.Join(keys, ",") != "displayName,siteType" { + t.Errorf("NodeType-driven columns = %v, want schema order", keys) + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/summary.go b/summary.go index 529e7bf..dc7baa9 100644 --- a/summary.go +++ b/summary.go @@ -10,118 +10,61 @@ import ( "github.com/spf13/cobra" - inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" ) 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, + Use: "summary", + Short: "Show fleet-wide inventory counts", + Long: `Print fleet-wide counts: nodes per node type and edges per edge type.`, + Example: ` datumctl inventory summary`, + Args: cobra.NoArgs, 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) - } + var nodes inventoryv1alpha2.NodeList if err := c.List(ctx, &nodes); err != nil { return listErr("nodes", err) } - - printSummary(cmd.OutOrStdout(), providers, regions, sites, clusters, nodes) + var edges inventoryv1alpha2.EdgeList + if err := c.List(ctx, &edges); err != nil { + return listErr("edges", err) + } + printSummary(cmd.OutOrStdout(), nodes, edges) 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{} +func printSummary(out io.Writer, nodes inventoryv1alpha2.NodeList, edges inventoryv1alpha2.EdgeList) { + nodeByType := map[string]int{} for _, n := range nodes.Items { - r := n.Labels[inventoryv1alpha1.TopologyRegionLabel] - if r == "" { - r = none - } - nodesPerRegion[r]++ + nodeByType[n.Spec.Type]++ } - 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])}) + edgeByType := map[string]int{} + for _, e := range edges.Items { + edgeByType[e.Spec.Type]++ } - _ = 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) + fmt.Fprintf(out, "Nodes (%d total)\n", len(nodes.Items)) + _ = printTable(out, []string{"NODE-TYPE", "COUNT"}, countRows(nodeByType)) + fmt.Fprintf(out, "\nEdges (%d total)\n", len(edges.Items)) + _ = printTable(out, []string{"EDGE-TYPE", "COUNT"}, countRows(edgeByType)) } -func sortedKeys(m map[string]int) []string { +func countRows(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 + rows := make([][]string, 0, len(keys)) + for _, k := range keys { + rows = append(rows, []string{k, strconv.Itoa(m[k])}) } - 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 + return rows } diff --git a/tree.go b/tree.go index 4e9e6f1..f272bc7 100644 --- a/tree.go +++ b/tree.go @@ -6,111 +6,101 @@ import ( "fmt" "io" "sort" - "strings" "github.com/spf13/cobra" - inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" ) func newTreeCmd() *cobra.Command { - var region string + var edgeType, rootType 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. + Short: "Show the containment hierarchy built from edges", + Long: `Print the inventory as a tree built from containment edges. -Use --region to scope the tree to a single region.`, - Example: ` # Full topology tree +By default it follows "located-in" edges (a node is located-in its parent) and +roots the tree at "Region" nodes, so you see region → site → node. Override the +relationship with --edge and the root asset class with --root-type.`, + Example: ` # region -> site -> node, via located-in datumctl inventory tree - # Just one region - datumctl inventory tree --region us-central-2`, - Args: cobra.NoArgs, - SilenceUsage: true, + # rack containment + datumctl inventory tree --edge=mounted-in --root-type=Rack`, + Args: cobra.NoArgs, 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 + var nodes inventoryv1alpha2.NodeList if err := c.List(ctx, &nodes); err != nil { return listErr("nodes", err) } - - printTree(cmd.OutOrStdout(), region, regions, sites, clusters, nodes) + var edges inventoryv1alpha2.EdgeList + if err := c.List(ctx, &edges); err != nil { + return listErr("edges", err) + } + printTree(cmd.OutOrStdout(), nodes, edges, edgeType, rootType) return nil }, } - cmd.Flags().StringVar(®ion, "region", "", "Limit the tree to a single region") + cmd.Flags().StringVar(&edgeType, "edge", "located-in", "Containment edge type to follow (child is FROM, parent is TO)") + cmd.Flags().StringVar(&rootType, "root-type", "Region", "Node type to root the tree at") 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{} +func printTree(out io.Writer, nodes inventoryv1alpha2.NodeList, edges inventoryv1alpha2.EdgeList, edgeType, rootType string) { + typeOf := 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) + typeOf[n.Name] = n.Spec.Type } - sort.Strings(names) - - printed := 0 - for _, region := range names { - if regionFilter != "" && region != regionFilter { + // children[parent] = child names, from `child located-in parent` edges. + children := map[string][]string{} + for _, e := range edges.Items { + if e.Spec.Type != edgeType { continue } - printed++ - fmt.Fprintln(out, region) + children[e.Spec.To.Name] = append(children[e.Spec.To.Name], e.Spec.From.Name) + } - if cls := clustersByRegion[region]; len(cls) > 0 { - sort.Strings(cls) - fmt.Fprintf(out, " clusters: %s\n", strings.Join(cls, ", ")) + roots := make([]string, 0) + for _, n := range nodes.Items { + if n.Spec.Type == rootType { + roots = append(roots, n.Name) } + } + sort.Strings(roots) - 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 len(roots) == 0 { + fmt.Fprintf(out, "No %s nodes found.\n", rootType) + return } + for _, r := range roots { + printNode(out, r, typeOf, children, 0, map[string]bool{}) + } +} - if printed == 0 { - fmt.Fprintln(out, "No matching inventory found.") +func printNode(out io.Writer, name string, typeOf map[string]string, children map[string][]string, depth int, seen map[string]bool) { + indent := "" + for i := 0; i < depth; i++ { + indent += " " + } + t := typeOf[name] + if t == "" { + t = "?" + } + fmt.Fprintf(out, "%s%s (%s)\n", indent, name, t) + if seen[name] { + fmt.Fprintf(out, "%s ...cycle\n", indent) + return + } + seen[name] = true + kids := append([]string(nil), children[name]...) + sort.Strings(kids) + for _, k := range kids { + printNode(out, k, typeOf, children, depth+1, seen) } + delete(seen, name) } diff --git a/types.go b/types.go new file mode 100644 index 0000000..22d0afb --- /dev/null +++ b/types.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" +) + +func newTypesCmd() *cobra.Command { + return &cobra.Command{ + Use: "types", + Short: "List node and edge types (the schema registry)", + Long: `List the NodeType and EdgeType definitions that make up the inventory schema +registry. Each type declares the attribute keys its nodes or edges may carry; +'datumctl inventory get ' uses these to choose columns.`, + Example: ` datumctl inventory types + datumctl inventory types -o yaml`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := newClient() + if err != nil { + return err + } + ctx := cmd.Context() + var nodeTypes inventoryv1alpha2.NodeTypeList + if err := c.List(ctx, &nodeTypes); err != nil { + return listErr("nodetypes", err) + } + var edgeTypes inventoryv1alpha2.EdgeTypeList + if err := c.List(ctx, &edgeTypes); err != nil { + return listErr("edgetypes", err) + } + sort.Slice(nodeTypes.Items, func(i, j int) bool { return nodeTypes.Items[i].Name < nodeTypes.Items[j].Name }) + sort.Slice(edgeTypes.Items, func(i, j int) bool { return edgeTypes.Items[i].Name < edgeTypes.Items[j].Name }) + + format, _ := cmd.Flags().GetString("output") + switch format { + case "json": + return writeMarshaled(cmd, json.MarshalIndent, nodeTypes, edgeTypes) + case "yaml": + return writeMarshaled(cmd, func(v any, _, _ string) ([]byte, error) { return yaml.Marshal(v) }, nodeTypes, edgeTypes) + case "", "table": + rows := make([][]string, 0, len(nodeTypes.Items)+len(edgeTypes.Items)) + for _, nt := range nodeTypes.Items { + rows = append(rows, []string{"NodeType", nt.Name, orNone(nt.Spec.DisplayName), attrKeys(nodeAttrKeys(nt))}) + } + for _, et := range edgeTypes.Items { + rows = append(rows, []string{"EdgeType", et.Name, orNone(et.Spec.DisplayName), attrKeys(edgeAttrKeys(et))}) + } + return printTable(cmd.OutOrStdout(), []string{"KIND", "NAME", "DISPLAY", "ATTRIBUTES"}, rows) + default: + return fmt.Errorf("invalid value %q for --output; allowed: table, json, yaml", format) + } + }, + } +} + +func nodeAttrKeys(nt inventoryv1alpha2.NodeType) []string { + keys := make([]string, 0, len(nt.Spec.Attributes)) + for _, a := range nt.Spec.Attributes { + k := a.Key + if a.Required { + k += "*" + } + keys = append(keys, k) + } + return keys +} + +func edgeAttrKeys(et inventoryv1alpha2.EdgeType) []string { + keys := make([]string, 0, len(et.Spec.Attributes)) + for _, a := range et.Spec.Attributes { + k := a.Key + if a.Required { + k += "*" + } + keys = append(keys, k) + } + return keys +} + +func attrKeys(keys []string) string { + if len(keys) == 0 { + return none + } + return strings.Join(keys, ", ") +} + +func writeMarshaled(cmd *cobra.Command, marshal func(any, string, string) ([]byte, error), nodeTypes inventoryv1alpha2.NodeTypeList, edgeTypes inventoryv1alpha2.EdgeTypeList) error { + payload := map[string]any{"nodeTypes": nodeTypes.Items, "edgeTypes": edgeTypes.Items} + b, err := marshal(payload, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), strings.TrimRight(string(b), "\n")) + return err +}