Skip to content

Commit e5cad8d

Browse files
authored
Merge pull request #6 from datum-cloud/feat/inventory-apply
feat: add inventory apply for declarative population
2 parents cd17542 + c3ce6bb commit e5cad8d

4 files changed

Lines changed: 307 additions & 1 deletion

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,35 @@ exposes it as `datumctl inventory`.
2626
| `datumctl inventory nodes [--region R] [--site S] [--cluster C]` | List nodes |
2727
| `datumctl inventory tree [--region R]` | region → site → node hierarchy |
2828
| `datumctl inventory summary` | Fleet-wide counts |
29+
| `datumctl inventory apply -f FILE [--dry-run=server]` | Create/update objects from a manifest |
2930

30-
All subcommands accept `-o table|json|yaml` (default `table`).
31+
The list subcommands accept `-o table|json|yaml` (default `table`).
3132

3233
`--region`, `--site`, and `--cluster` filter server-side using the
3334
`topology.inventory.miloapis.com/*` labels the inventory controllers propagate
3435
onto objects. `--provider` filters on the site's `providerRef`.
3536

37+
## Populating the inventory
38+
39+
`apply` is an idempotent, declarative upsert for inventory objects — for
40+
loading the inventory from declared configuration, not fleet management:
41+
42+
```sh
43+
# Apply a manifest (objects land in dependency order: provider, region,
44+
# site, cluster, node — regardless of order in the file)
45+
datumctl inventory apply -f fleet.yaml
46+
47+
# Pipe from a renderer
48+
render-fleet | datumctl inventory apply -f -
49+
50+
# Validate against the server without persisting
51+
datumctl inventory apply -f fleet.yaml --dry-run=server
52+
```
53+
54+
It uses server-side apply with field manager `datumctl-inventory`, so
55+
re-applying the same manifest makes no changes. Only `Provider`, `Region`,
56+
`Site`, `Cluster`, and `Node` are accepted.
57+
3658
Inventory objects are cluster-scoped on the Datum Cloud platform root, so the
3759
plugin talks to the platform API directly and takes no organization or project
3860
scope.

apply.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"io"
9+
"os"
10+
"sort"
11+
"strings"
12+
13+
"github.com/spf13/cobra"
14+
"k8s.io/apimachinery/pkg/runtime"
15+
"k8s.io/apimachinery/pkg/runtime/serializer"
16+
utilyaml "k8s.io/apimachinery/pkg/util/yaml"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
18+
19+
inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1"
20+
)
21+
22+
const fieldManager = "datumctl-inventory"
23+
24+
// applyOrder lists the inventory kinds apply handles, in dependency order:
25+
// parents are applied before the children that reference them.
26+
var applyOrder = []string{"Provider", "Region", "Site", "Cluster", "Node"}
27+
28+
func kindOrder(kind string) (int, bool) {
29+
for i, k := range applyOrder {
30+
if k == kind {
31+
return i, true
32+
}
33+
}
34+
return 0, false
35+
}
36+
37+
func newApplyCmd() *cobra.Command {
38+
var files []string
39+
var dryRun string
40+
cmd := &cobra.Command{
41+
Use: "apply -f FILE",
42+
Short: "Create or update inventory objects from a manifest",
43+
Long: `Create or update inventory objects (providers, regions, sites, clusters,
44+
nodes) from a YAML or JSON manifest.
45+
46+
apply is an idempotent, declarative upsert: re-applying the same manifest makes
47+
no changes. Objects are applied in dependency order (providers, then regions,
48+
then sites, then clusters, then nodes) so a single mixed manifest lands cleanly.
49+
It uses server-side apply with field manager "datumctl-inventory".
50+
51+
This is for populating the inventory from declared configuration — not fleet
52+
management. Inventory lives on the Datum Cloud platform root, so apply takes no
53+
organization or project scope.`,
54+
Example: ` # Apply a manifest file
55+
datumctl inventory apply -f fleet.yaml
56+
57+
# Apply from stdin (e.g. piped from a renderer)
58+
render-fleet | datumctl inventory apply -f -
59+
60+
# Validate against the server without persisting
61+
datumctl inventory apply -f fleet.yaml --dry-run=server`,
62+
Args: cobra.NoArgs,
63+
RunE: func(cmd *cobra.Command, _ []string) error {
64+
if len(files) == 0 {
65+
return fmt.Errorf("at least one -f/--filename is required")
66+
}
67+
var server bool
68+
switch dryRun {
69+
case "", "none":
70+
server = false
71+
case "server":
72+
server = true
73+
default:
74+
return fmt.Errorf("invalid value %q for --dry-run; allowed: none, server", dryRun)
75+
}
76+
77+
objs, err := readManifests(cmd.InOrStdin(), files)
78+
if err != nil {
79+
return err
80+
}
81+
if len(objs) == 0 {
82+
return fmt.Errorf("no inventory objects found in input")
83+
}
84+
sort.SliceStable(objs, func(i, j int) bool { return objs[i].order < objs[j].order })
85+
86+
c, err := newClient()
87+
if err != nil {
88+
return err
89+
}
90+
return applyObjects(cmd.Context(), cmd.OutOrStdout(), c, objs, server)
91+
},
92+
}
93+
cmd.Flags().StringArrayVarP(&files, "filename", "f", nil, "Manifest file (YAML or JSON), or - for stdin. Repeatable.")
94+
cmd.Flags().StringVar(&dryRun, "dry-run", "none", `Must be "none" or "server". "server" validates against the API without persisting.`)
95+
return cmd
96+
}
97+
98+
type applyObj struct {
99+
obj client.Object
100+
kind string
101+
order int
102+
}
103+
104+
// readManifests parses every document from the given files (and stdin for "-")
105+
// into typed inventory objects, giving client-side validation and rejecting
106+
// kinds apply does not handle.
107+
func readManifests(stdin io.Reader, files []string) ([]applyObj, error) {
108+
scheme := runtime.NewScheme()
109+
if err := inventoryv1alpha1.AddToScheme(scheme); err != nil {
110+
return nil, fmt.Errorf("build scheme: %w", err)
111+
}
112+
decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer()
113+
114+
var out []applyObj
115+
for _, f := range files {
116+
r, closeFn, err := openInput(stdin, f)
117+
if err != nil {
118+
return nil, err
119+
}
120+
docs := utilyaml.NewYAMLOrJSONDecoder(r, 4096)
121+
for {
122+
var raw runtime.RawExtension
123+
if derr := docs.Decode(&raw); derr != nil {
124+
if derr == io.EOF {
125+
break
126+
}
127+
closeFn()
128+
return nil, fmt.Errorf("parse %s: %w", f, derr)
129+
}
130+
if len(raw.Raw) == 0 {
131+
continue
132+
}
133+
obj, gvk, derr := decoder.Decode(raw.Raw, nil, nil)
134+
if derr != nil {
135+
closeFn()
136+
return nil, fmt.Errorf("decode %s: %w", f, derr)
137+
}
138+
order, ok := kindOrder(gvk.Kind)
139+
if !ok {
140+
closeFn()
141+
return nil, fmt.Errorf("unsupported kind %q in %s (apply handles: %s)", gvk.Kind, f, strings.Join(applyOrder, ", "))
142+
}
143+
co, ok := obj.(client.Object)
144+
if !ok {
145+
closeFn()
146+
return nil, fmt.Errorf("%s in %s is not an applyable object", gvk.Kind, f)
147+
}
148+
co.GetObjectKind().SetGroupVersionKind(*gvk)
149+
out = append(out, applyObj{obj: co, kind: gvk.Kind, order: order})
150+
}
151+
closeFn()
152+
}
153+
return out, nil
154+
}
155+
156+
func openInput(stdin io.Reader, f string) (io.Reader, func(), error) {
157+
if f == "-" {
158+
return stdin, func() {}, nil
159+
}
160+
fh, err := os.Open(f)
161+
if err != nil {
162+
return nil, func() {}, fmt.Errorf("open %s: %w", f, err)
163+
}
164+
return fh, func() { _ = fh.Close() }, nil
165+
}
166+
167+
func applyObjects(ctx context.Context, w io.Writer, c client.Client, objs []applyObj, server bool) error {
168+
opts := []client.PatchOption{client.FieldOwner(fieldManager), client.ForceOwnership}
169+
suffix := ""
170+
if server {
171+
opts = append(opts, client.DryRunAll)
172+
suffix = " (server dry-run)"
173+
}
174+
for _, o := range objs {
175+
if err := c.Patch(ctx, o.obj, client.Apply, opts...); err != nil {
176+
return fmt.Errorf("apply %s/%s: %w", strings.ToLower(o.kind), o.obj.GetName(), err)
177+
}
178+
fmt.Fprintf(w, "applied %s/%s%s\n", strings.ToLower(o.kind), o.obj.GetName(), suffix)
179+
}
180+
return nil
181+
}

apply_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
3+
package main
4+
5+
import (
6+
"sort"
7+
"strings"
8+
"testing"
9+
)
10+
11+
const sampleManifest = `
12+
apiVersion: inventory.miloapis.com/v1alpha1
13+
kind: Node
14+
metadata:
15+
name: node-a
16+
spec:
17+
siteRef:
18+
name: us-central-1a
19+
hardware:
20+
cpuCores: 8
21+
cpuArchitecture: amd64
22+
memoryBytes: 1073741824
23+
---
24+
apiVersion: inventory.miloapis.com/v1alpha1
25+
kind: Provider
26+
metadata:
27+
name: netactuate
28+
spec:
29+
displayName: NetActuate
30+
type: Hosting
31+
---
32+
apiVersion: inventory.miloapis.com/v1alpha1
33+
kind: Site
34+
metadata:
35+
name: us-central-1a
36+
spec:
37+
displayName: Dallas
38+
type: AvailabilityZone
39+
regionRef:
40+
name: us-central-1
41+
`
42+
43+
func TestReadManifestsParsesAndOrders(t *testing.T) {
44+
objs, err := readManifests(strings.NewReader(sampleManifest), []string{"-"})
45+
if err != nil {
46+
t.Fatalf("readManifests: %v", err)
47+
}
48+
if len(objs) != 3 {
49+
t.Fatalf("got %d objects, want 3", len(objs))
50+
}
51+
sort.SliceStable(objs, func(i, j int) bool { return objs[i].order < objs[j].order })
52+
gotKinds := []string{objs[0].kind, objs[1].kind, objs[2].kind}
53+
want := []string{"Provider", "Site", "Node"}
54+
for i := range want {
55+
if gotKinds[i] != want[i] {
56+
t.Errorf("order[%d] = %s, want %s (full: %v)", i, gotKinds[i], want[i], gotKinds)
57+
}
58+
}
59+
// GVK must be set on each object so server-side apply has apiVersion/kind.
60+
for _, o := range objs {
61+
if o.obj.GetObjectKind().GroupVersionKind().Kind == "" {
62+
t.Errorf("%s/%s missing GVK", o.kind, o.obj.GetName())
63+
}
64+
}
65+
}
66+
67+
func TestReadManifestsRejectsUnsupportedKind(t *testing.T) {
68+
const m = `
69+
apiVersion: inventory.miloapis.com/v1alpha1
70+
kind: Rack
71+
metadata:
72+
name: rack-a
73+
`
74+
_, err := readManifests(strings.NewReader(m), []string{"-"})
75+
if err == nil || !strings.Contains(err.Error(), "unsupported kind") {
76+
t.Fatalf("want unsupported-kind error, got %v", err)
77+
}
78+
}
79+
80+
func TestReadManifestsEmpty(t *testing.T) {
81+
objs, err := readManifests(strings.NewReader("\n---\n"), []string{"-"})
82+
if err != nil {
83+
t.Fatalf("unexpected error: %v", err)
84+
}
85+
if len(objs) != 0 {
86+
t.Fatalf("got %d objects, want 0", len(objs))
87+
}
88+
}
89+
90+
func TestKindOrder(t *testing.T) {
91+
if _, ok := kindOrder("Provider"); !ok {
92+
t.Error("Provider should be ordered")
93+
}
94+
p, _ := kindOrder("Provider")
95+
n, _ := kindOrder("Node")
96+
if !(p < n) {
97+
t.Errorf("Provider (%d) should sort before Node (%d)", p, n)
98+
}
99+
if _, ok := kindOrder("Rack"); ok {
100+
t.Error("Rack should not be applyable")
101+
}
102+
}

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ platform API directly; they do not take an organization or project scope.`,
6464
newListCmd(nodesView),
6565
newTreeCmd(),
6666
newSummaryCmd(),
67+
newApplyCmd(),
6768
)
6869

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

0 commit comments

Comments
 (0)