Skip to content
Merged
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,35 @@ exposes it as `datumctl inventory`.
| `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 |

All subcommands accept `-o table|json|yaml` (default `table`).
The list subcommands 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`.

## Populating the inventory

`apply` is an idempotent, declarative upsert for inventory 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

# 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
```

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.

Inventory objects are cluster-scoped on the Datum Cloud platform root, so the
plugin talks to the platform API directly and takes no organization or project
scope.
Expand Down
181 changes: 181 additions & 0 deletions apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-only

package main

import (
"context"
"fmt"
"io"
"os"
"sort"
"strings"

"github.com/spf13/cobra"
"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"
)

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"}

func kindOrder(kind string) (int, bool) {
for i, k := range applyOrder {
if k == kind {
return i, true
}
}
return 0, false
}

func newApplyCmd() *cobra.Command {
var files []string
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.

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".

This is for populating the inventory from declared configuration — not fleet
management. Inventory lives on the Datum Cloud platform root, so apply takes no
organization or project scope.`,
Example: ` # Apply a manifest file
datumctl inventory apply -f fleet.yaml

# Apply from stdin (e.g. piped from a renderer)
render-fleet | datumctl inventory apply -f -

# Validate against the server without persisting
datumctl inventory apply -f fleet.yaml --dry-run=server`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if len(files) == 0 {
return fmt.Errorf("at least one -f/--filename is required")
}
var server bool
switch dryRun {
case "", "none":
server = false
case "server":
server = true
default:
return fmt.Errorf("invalid value %q for --dry-run; allowed: none, server", dryRun)
}

objs, err := readManifests(cmd.InOrStdin(), files)
if err != nil {
return err
}
if len(objs) == 0 {
return fmt.Errorf("no inventory objects found in input")
}
sort.SliceStable(objs, func(i, j int) bool { return objs[i].order < objs[j].order })

c, err := newClient()
if err != nil {
return err
}
return applyObjects(cmd.Context(), cmd.OutOrStdout(), c, objs, server)
},
}
cmd.Flags().StringArrayVarP(&files, "filename", "f", nil, "Manifest file (YAML or JSON), or - for stdin. Repeatable.")
cmd.Flags().StringVar(&dryRun, "dry-run", "none", `Must be "none" or "server". "server" validates against the API without persisting.`)
return cmd
}

type applyObj struct {
obj client.Object
kind string
order int
}

// readManifests parses every document from the given files (and stdin for "-")
// into typed inventory objects, giving client-side validation and rejecting
// kinds apply does not handle.
func readManifests(stdin io.Reader, files []string) ([]applyObj, error) {
scheme := runtime.NewScheme()
if err := inventoryv1alpha1.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("build scheme: %w", err)
}
decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer()

var out []applyObj
for _, f := range files {
r, closeFn, err := openInput(stdin, f)
if err != nil {
return nil, err
}
docs := utilyaml.NewYAMLOrJSONDecoder(r, 4096)
for {
var raw runtime.RawExtension
if derr := docs.Decode(&raw); derr != nil {
if derr == io.EOF {
break
}
closeFn()
return nil, fmt.Errorf("parse %s: %w", f, derr)
}
if len(raw.Raw) == 0 {
continue
}
obj, gvk, derr := decoder.Decode(raw.Raw, nil, nil)
if derr != nil {
closeFn()
return nil, fmt.Errorf("decode %s: %w", f, derr)
}
order, ok := kindOrder(gvk.Kind)
if !ok {
closeFn()
return nil, fmt.Errorf("unsupported kind %q in %s (apply handles: %s)", gvk.Kind, f, strings.Join(applyOrder, ", "))
}
co, ok := obj.(client.Object)
if !ok {
closeFn()
return nil, fmt.Errorf("%s in %s is not an applyable object", gvk.Kind, f)
}
co.GetObjectKind().SetGroupVersionKind(*gvk)
out = append(out, applyObj{obj: co, kind: gvk.Kind, order: order})
}
closeFn()
}
return out, nil
}

func openInput(stdin io.Reader, f string) (io.Reader, func(), error) {
if f == "-" {
return stdin, func() {}, nil
}
fh, err := os.Open(f)
if err != nil {
return nil, func() {}, fmt.Errorf("open %s: %w", f, err)
}
return fh, func() { _ = fh.Close() }, nil
}

func applyObjects(ctx context.Context, w io.Writer, c client.Client, objs []applyObj, server bool) error {
opts := []client.PatchOption{client.FieldOwner(fieldManager), client.ForceOwnership}
suffix := ""
if server {
opts = append(opts, client.DryRunAll)
suffix = " (server dry-run)"
}
for _, o := range objs {
if err := c.Patch(ctx, o.obj, client.Apply, opts...); err != nil {
return fmt.Errorf("apply %s/%s: %w", strings.ToLower(o.kind), o.obj.GetName(), err)
}
fmt.Fprintf(w, "applied %s/%s%s\n", strings.ToLower(o.kind), o.obj.GetName(), suffix)
}
return nil
}
102 changes: 102 additions & 0 deletions apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-only

package main

import (
"sort"
"strings"
"testing"
)

const sampleManifest = `
apiVersion: inventory.miloapis.com/v1alpha1
kind: Node
metadata:
name: node-a
spec:
siteRef:
name: us-central-1a
hardware:
cpuCores: 8
cpuArchitecture: amd64
memoryBytes: 1073741824
---
apiVersion: inventory.miloapis.com/v1alpha1
kind: Provider
metadata:
name: netactuate
spec:
displayName: NetActuate
type: Hosting
---
apiVersion: inventory.miloapis.com/v1alpha1
kind: Site
metadata:
name: us-central-1a
spec:
displayName: Dallas
type: AvailabilityZone
regionRef:
name: us-central-1
`

func TestReadManifestsParsesAndOrders(t *testing.T) {
objs, err := readManifests(strings.NewReader(sampleManifest), []string{"-"})
if err != nil {
t.Fatalf("readManifests: %v", err)
}
if len(objs) != 3 {
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"}
for i := range want {
if gotKinds[i] != want[i] {
t.Errorf("order[%d] = %s, want %s (full: %v)", i, gotKinds[i], want[i], gotKinds)
}
}
// 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())
}
}
}

func TestReadManifestsRejectsUnsupportedKind(t *testing.T) {
const m = `
apiVersion: inventory.miloapis.com/v1alpha1
kind: Rack
metadata:
name: rack-a
`
_, err := readManifests(strings.NewReader(m), []string{"-"})
if err == nil || !strings.Contains(err.Error(), "unsupported kind") {
t.Fatalf("want unsupported-kind error, got %v", err)
}
}

func TestReadManifestsEmpty(t *testing.T) {
objs, err := readManifests(strings.NewReader("\n---\n"), []string{"-"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 0 {
t.Fatalf("got %d objects, want 0", len(objs))
}
}

func TestKindOrder(t *testing.T) {
if _, ok := kindOrder("Provider"); !ok {
t.Error("Provider should be ordered")
}
p, _ := kindOrder("Provider")
n, _ := kindOrder("Node")
if !(p < n) {
t.Errorf("Provider (%d) should sort before Node (%d)", p, n)
}
if _, ok := kindOrder("Rack"); ok {
t.Error("Rack should not be applyable")
}
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ platform API directly; they do not take an organization or project scope.`,
newListCmd(nodesView),
newTreeCmd(),
newSummaryCmd(),
newApplyCmd(),
)

if err := root.Execute(); err != nil {
Expand Down