diff --git a/README.md b/README.md index 0fe6d65..74e9e98 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..15ab3cc --- /dev/null +++ b/apply.go @@ -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 +} diff --git a/apply_test.go b/apply_test.go new file mode 100644 index 0000000..84be544 --- /dev/null +++ b/apply_test.go @@ -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") + } +} diff --git a/main.go b/main.go index 4d6702d..d34d196 100644 --- a/main.go +++ b/main.go @@ -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 {