From b2a1e071c32c3668d5bb03a72fc93abdb44ba844 Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Fri, 12 Jun 2026 13:31:53 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(graph):=20prototype=20property-graph?= =?UTF-8?q?=20model=20(Node=20+=20Edge)=20=E2=80=94=20M1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype of the M1 changeset from RFC milo-os/inventory#43: collapse the NetBox-inspired per-kind hierarchy into a property graph of typed nodes and edges, both carrying key/value attribute bags validated against a schema registry. New v1alpha2 kinds: - Node — graph vertex; spec.type names its asset class (Region, Site, Host [the former compute Node], ...), spec.attributes its data. - Edge — directed relationship; spec.type names the relationship class (located-in, member-of, ...), with from/to endpoints + attributes. - NodeType — closed attribute schema for a class of nodes. - EdgeType — closed attribute schema + endpoint-type constraints for edges. Validation (internal/graph) is shared between admission webhooks and reconcilers: attributes are checked against the NodeType/EdgeType schema (unknown keys, missing-required, type/enum parsing); edges verify endpoint existence and endpoint-type constraints. The Node DELETE webhook is a generic delete-guard — one Edge-endpoint field indexer replaces the per-kind reference indexers of v1alpha1. The graph kinds use group graph.inventory.miloapis.com so the prototype installs side-by-side with v1alpha1, whose `Node` kind would otherwise collide at the CRD level (the very collision the RFC resolves via type: Host). The production migration reclaims inventory.miloapis.com after v1alpha1 removal. Not in scope for this prototype: envtest suite wiring, IAM ProtectedResource/ Roles, topology-label propagation, CR migration tooling (M2). Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha2/common_types.go | 59 +++ api/v1alpha2/edge_types.go | 107 ++++ api/v1alpha2/edgetype_types.go | 94 ++++ api/v1alpha2/groupversion_info.go | 33 ++ api/v1alpha2/node_types.go | 87 ++++ api/v1alpha2/nodetype_types.go | 73 +++ api/v1alpha2/zz_generated.deepcopy.go | 487 ++++++++++++++++++ cmd/inventory/main.go | 33 ++ .../graph.inventory.miloapis.com_edges.yaml | 186 +++++++ ...raph.inventory.miloapis.com_edgetypes.yaml | 195 +++++++ .../graph.inventory.miloapis.com_nodes.yaml | 158 ++++++ ...raph.inventory.miloapis.com_nodetypes.yaml | 177 +++++++ config/base/crd/kustomization.yaml | 4 + config/base/webhook/kustomization.yaml | 12 + config/base/webhook/manifests.yaml | 41 ++ config/components/controller_rbac/role.yaml | 20 + config/samples/graph_v1alpha2_demo.yaml | 80 +++ config/samples/kustomization.yaml | 1 + internal/graph/edge_controller.go | 100 ++++ internal/graph/indexers.go | 57 ++ internal/graph/node_controller.go | 85 +++ internal/graph/validate.go | 149 ++++++ internal/webhook/v1alpha2/edge_webhook.go | 72 +++ internal/webhook/v1alpha2/node_webhook.go | 63 +++ 24 files changed, 2373 insertions(+) create mode 100644 api/v1alpha2/common_types.go create mode 100644 api/v1alpha2/edge_types.go create mode 100644 api/v1alpha2/edgetype_types.go create mode 100644 api/v1alpha2/groupversion_info.go create mode 100644 api/v1alpha2/node_types.go create mode 100644 api/v1alpha2/nodetype_types.go create mode 100644 api/v1alpha2/zz_generated.deepcopy.go create mode 100644 config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml create mode 100644 config/base/crd/bases/graph.inventory.miloapis.com_edgetypes.yaml create mode 100644 config/base/crd/bases/graph.inventory.miloapis.com_nodes.yaml create mode 100644 config/base/crd/bases/graph.inventory.miloapis.com_nodetypes.yaml create mode 100644 config/samples/graph_v1alpha2_demo.yaml create mode 100644 internal/graph/edge_controller.go create mode 100644 internal/graph/indexers.go create mode 100644 internal/graph/node_controller.go create mode 100644 internal/graph/validate.go create mode 100644 internal/webhook/v1alpha2/edge_webhook.go create mode 100644 internal/webhook/v1alpha2/node_webhook.go diff --git a/api/v1alpha2/common_types.go b/api/v1alpha2/common_types.go new file mode 100644 index 0000000..3c8ab57 --- /dev/null +++ b/api/v1alpha2/common_types.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +// NodeReference is a reference to a graph Node by name. Both Edge endpoints +// and any future node-to-node pointers use this type. +type NodeReference struct { + // Name of the referenced Node. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// AttributeValueType enumerates the scalar value kinds an attribute may hold. +// Attribute values are always stored as strings on the wire; the type drives +// how the admission webhook parses and validates them. +// +// +kubebuilder:validation:Enum=String;Integer;Float;Boolean +type AttributeValueType string + +const ( + // AttributeString is an arbitrary UTF-8 string. + AttributeString AttributeValueType = "String" + // AttributeInteger is a base-10 signed integer. + AttributeInteger AttributeValueType = "Integer" + // AttributeFloat is a decimal floating-point number. + AttributeFloat AttributeValueType = "Float" + // AttributeBoolean is "true" or "false". + AttributeBoolean AttributeValueType = "Boolean" +) + +// AttributeSchema describes one allowed attribute key on a Node or Edge type. +// The set of AttributeSchemas on a NodeType/EdgeType is the closed schema the +// admission webhook validates each object's attributes against. +type AttributeSchema struct { + // Key is the attribute name (the map key in spec.attributes). + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Key string `json:"key"` + + // Type is the scalar kind the value must parse as. + // + // +kubebuilder:validation:Required + Type AttributeValueType `json:"type"` + + // Required marks the attribute as mandatory. Objects missing a required + // attribute are rejected at admission. + // + // +optional + Required bool `json:"required,omitempty"` + + // Enum optionally restricts a String attribute to a fixed set of values. + // + // +optional + // +listType=atomic + Enum []string `json:"enum,omitempty"` +} diff --git a/api/v1alpha2/edge_types.go b/api/v1alpha2/edge_types.go new file mode 100644 index 0000000..d3c1fa6 --- /dev/null +++ b/api/v1alpha2/edge_types.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EdgeSpec defines the desired state of a graph Edge. +// +// +kubebuilder:validation:XValidation:rule="self.type == oldSelf.type",message="type is immutable" +// +kubebuilder:validation:XValidation:rule="self.from != self.to",message="edge endpoints must be distinct" +type EdgeSpec struct { + // Type names the relationship class. The value must match the name of an + // existing EdgeType, which constrains the endpoint node types and the + // attributes this edge may carry. Typical values: located-in, member-of, + // mounted-in, connects, realized-by, provided-by. This field is immutable. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Type string `json:"type"` + + // From is the source endpoint Node. + // + // +kubebuilder:validation:Required + From NodeReference `json:"from"` + + // To is the target endpoint Node. + // + // +kubebuilder:validation:Required + To NodeReference `json:"to"` + + // Attributes is the edge's key/value attribute bag. The admission webhook + // validates these against the matching EdgeType's attribute schema. + // + // +optional + Attributes map[string]string `json:"attributes,omitempty"` +} + +// EdgeStatus defines the observed state of a graph Edge. +type EdgeStatus struct { + // Represents the observations of an edge's current state. + // Known condition types are: "Ready", "EndpointsResolved". + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +const ( + // EdgeReady is the condition type indicating the Edge has been accepted + // and both endpoints resolve. + EdgeReady = "Ready" + + // EdgeEndpointsResolved is set once the controller has verified both + // endpoint Nodes exist. Reported alongside Ready for observability. + EdgeEndpointsResolved = "EndpointsResolved" +) + +const ( + // EdgeReadyReason indicates the Edge is accepted and ready for use. + EdgeReadyReason = "Accepted" + + // EdgePendingReason indicates the Edge has not yet been reconciled. + EdgePendingReason = "Pending" + + // EdgeTypeNotFoundReason indicates the referenced EdgeType does not exist. + EdgeTypeNotFoundReason = "EdgeTypeNotFound" + + // EdgeEndpointNotFoundReason indicates an endpoint Node does not exist. + EdgeEndpointNotFoundReason = "EndpointNotFound" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" +// +kubebuilder:printcolumn:name="From",type="string",JSONPath=".spec.from.name" +// +kubebuilder:printcolumn:name="To",type="string",JSONPath=".spec.to.name" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].reason` + +// Edge is a directed relationship between two graph Nodes. Its relationship +// class is given by spec.type and its descriptive data by spec.attributes. It +// supersedes the v1alpha1 Link/Cable/Circuit kinds and the inline parent refs. +// See RFC milo-os/inventory#43. +type Edge struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec EdgeSpec `json:"spec,omitempty"` + + // +kubebuilder:default={conditions:{{type:"Ready",status:"False",reason:"Pending",message:"Waiting for reconciliation",lastTransitionTime:"1970-01-01T00:00:00Z"}}} + Status EdgeStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// EdgeList contains a list of Edge. +type EdgeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Edge `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Edge{}, &EdgeList{}) +} diff --git a/api/v1alpha2/edgetype_types.go b/api/v1alpha2/edgetype_types.go new file mode 100644 index 0000000..803d101 --- /dev/null +++ b/api/v1alpha2/edgetype_types.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EndpointConstraint restricts which NodeTypes an Edge's endpoints may point +// at. An empty list on either side means "any node type". +type EndpointConstraint struct { + // FromTypes is the set of allowed NodeType names for the edge's `from` + // endpoint. Empty means any. + // + // +optional + // +listType=set + FromTypes []string `json:"fromTypes,omitempty"` + + // ToTypes is the set of allowed NodeType names for the edge's `to` + // endpoint. Empty means any. + // + // +optional + // +listType=set + ToTypes []string `json:"toTypes,omitempty"` +} + +// EdgeTypeSpec describes the closed attribute schema and endpoint constraints +// for Edges whose spec.type equals this EdgeType's metadata.name. +type EdgeTypeSpec struct { + // DisplayName is a human-readable label for the edge type. + // + // +optional + DisplayName string `json:"displayName,omitempty"` + + // Endpoints constrains the NodeTypes the edge may connect. + // + // +optional + Endpoints EndpointConstraint `json:"endpoints,omitempty"` + + // Attributes is the closed set of attribute keys an Edge of this type may + // carry. + // + // +optional + // +listType=map + // +listMapKey=key + Attributes []AttributeSchema `json:"attributes,omitempty"` +} + +// EdgeTypeStatus defines the observed state of an EdgeType. +type EdgeTypeStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +const ( + // EdgeTypeReady indicates the EdgeType has been accepted. + EdgeTypeReady = "Ready" + // EdgeTypeReadyReason indicates the EdgeType is accepted and ready. + EdgeTypeReadyReason = "Accepted" + // EdgeTypePendingReason indicates the EdgeType has not yet reconciled. + EdgeTypePendingReason = "Pending" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Display",type="string",JSONPath=".spec.displayName" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status` + +// EdgeType describes the attribute schema and endpoint constraints for a +// class of graph Edges. See RFC milo-os/inventory#43. +type EdgeType struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec EdgeTypeSpec `json:"spec,omitempty"` + + // +kubebuilder:default={conditions:{{type:"Ready",status:"False",reason:"Pending",message:"Waiting for reconciliation",lastTransitionTime:"1970-01-01T00:00:00Z"}}} + Status EdgeTypeStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// EdgeTypeList contains a list of EdgeType. +type EdgeTypeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EdgeType `json:"items"` +} + +func init() { + SchemeBuilder.Register(&EdgeType{}, &EdgeTypeList{}) +} diff --git a/api/v1alpha2/groupversion_info.go b/api/v1alpha2/groupversion_info.go new file mode 100644 index 0000000..5aef701 --- /dev/null +++ b/api/v1alpha2/groupversion_info.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package v1alpha2 contains API Schema definitions for the property-graph +// model of the inventory.miloapis.com API group. It replaces the per-kind +// v1alpha1 hierarchy with two generic kinds -- Node (graph vertex) and Edge +// (graph relationship) -- whose shape is described by the NodeType and +// EdgeType schema-registry kinds. See RFC milo-os/inventory#43. +// +// The prototype uses the group graph.inventory.miloapis.com so it installs +// side-by-side with the v1alpha1 hierarchy (whose `Node` kind would otherwise +// collide at the CRD level -- the very collision RFC #43 resolves). The +// production migration reclaims the inventory.miloapis.com group once +// v1alpha1 is removed. +// +// +kubebuilder:object:generate=true +// +groupName=graph.inventory.miloapis.com +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "graph.inventory.miloapis.com", Version: "v1alpha2"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha2/node_types.go b/api/v1alpha2/node_types.go new file mode 100644 index 0000000..b9b61f6 --- /dev/null +++ b/api/v1alpha2/node_types.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodeSpec defines the desired state of a graph Node. +// +// +kubebuilder:validation:XValidation:rule="self.type == oldSelf.type",message="type is immutable" +type NodeSpec struct { + // Type names the node's asset class. The value must match the name of an + // existing NodeType, which describes the attributes this node may carry. + // Typical values are the former v1alpha1 kinds: Region, Site, Cluster, + // NetworkDevice, Rack, Provider, Circuit, Cable, Port, VirtualMachine, + // Host (the former compute Node), Fleet. This field is immutable. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Type string `json:"type"` + + // Attributes is the node's key/value attribute bag. The admission webhook + // validates these against the matching NodeType's attribute schema. + // + // +optional + Attributes map[string]string `json:"attributes,omitempty"` +} + +// NodeStatus defines the observed state of a graph Node. +type NodeStatus struct { + // Represents the observations of a node's current state. + // Known condition types are: "Ready". + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +const ( + // NodeReady is the condition type indicating the Node has been accepted + // and validated against its NodeType. + NodeReady = "Ready" +) + +const ( + // NodeReadyReason indicates the Node is accepted and ready for use. + NodeReadyReason = "Accepted" + + // NodePendingReason indicates the Node has not yet been reconciled. + NodePendingReason = "Pending" + + // NodeTypeNotFoundReason indicates the referenced NodeType does not exist. + NodeTypeNotFoundReason = "NodeTypeNotFound" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=invnode +// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].reason` + +// Node is a vertex in the inventory property graph. Its asset class is given +// by spec.type and its descriptive data by spec.attributes. It supersedes the +// per-kind v1alpha1 inventory kinds. See RFC milo-os/inventory#43. +type Node struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec NodeSpec `json:"spec,omitempty"` + + // +kubebuilder:default={conditions:{{type:"Ready",status:"False",reason:"Pending",message:"Waiting for reconciliation",lastTransitionTime:"1970-01-01T00:00:00Z"}}} + Status NodeStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// NodeList contains a list of Node. +type NodeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Node `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Node{}, &NodeList{}) +} diff --git a/api/v1alpha2/nodetype_types.go b/api/v1alpha2/nodetype_types.go new file mode 100644 index 0000000..bd9ff41 --- /dev/null +++ b/api/v1alpha2/nodetype_types.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodeTypeSpec describes the closed attribute schema for Nodes whose +// spec.type equals this NodeType's metadata.name. +type NodeTypeSpec struct { + // DisplayName is a human-readable label for the node type. + // + // +optional + DisplayName string `json:"displayName,omitempty"` + + // Attributes is the closed set of attribute keys a Node of this type may + // carry. Keys outside this set are rejected at admission; keys marked + // Required must be present. + // + // +optional + // +listType=map + // +listMapKey=key + Attributes []AttributeSchema `json:"attributes,omitempty"` +} + +// NodeTypeStatus defines the observed state of a NodeType. +type NodeTypeStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +const ( + // NodeTypeReady indicates the NodeType has been accepted. + NodeTypeReady = "Ready" + // NodeTypeReadyReason indicates the NodeType is accepted and ready. + NodeTypeReadyReason = "Accepted" + // NodeTypePendingReason indicates the NodeType has not yet reconciled. + NodeTypePendingReason = "Pending" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Display",type="string",JSONPath=".spec.displayName" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status` + +// NodeType describes the attribute schema for a class of graph Nodes. It is +// the source of truth the admission webhook validates Node.spec.attributes +// against. See RFC milo-os/inventory#43. +type NodeType struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec NodeTypeSpec `json:"spec,omitempty"` + + // +kubebuilder:default={conditions:{{type:"Ready",status:"False",reason:"Pending",message:"Waiting for reconciliation",lastTransitionTime:"1970-01-01T00:00:00Z"}}} + Status NodeTypeStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// NodeTypeList contains a list of NodeType. +type NodeTypeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NodeType `json:"items"` +} + +func init() { + SchemeBuilder.Register(&NodeType{}, &NodeTypeList{}) +} diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go new file mode 100644 index 0000000..bc395c6 --- /dev/null +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -0,0 +1,487 @@ +//go:build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AttributeSchema) DeepCopyInto(out *AttributeSchema) { + *out = *in + if in.Enum != nil { + in, out := &in.Enum, &out.Enum + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AttributeSchema. +func (in *AttributeSchema) DeepCopy() *AttributeSchema { + if in == nil { + return nil + } + out := new(AttributeSchema) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Edge) DeepCopyInto(out *Edge) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Edge. +func (in *Edge) DeepCopy() *Edge { + if in == nil { + return nil + } + out := new(Edge) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Edge) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeList) DeepCopyInto(out *EdgeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Edge, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeList. +func (in *EdgeList) DeepCopy() *EdgeList { + if in == nil { + return nil + } + out := new(EdgeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EdgeList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeSpec) DeepCopyInto(out *EdgeSpec) { + *out = *in + out.From = in.From + out.To = in.To + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeSpec. +func (in *EdgeSpec) DeepCopy() *EdgeSpec { + if in == nil { + return nil + } + out := new(EdgeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeStatus) DeepCopyInto(out *EdgeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeStatus. +func (in *EdgeStatus) DeepCopy() *EdgeStatus { + if in == nil { + return nil + } + out := new(EdgeStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeType) DeepCopyInto(out *EdgeType) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeType. +func (in *EdgeType) DeepCopy() *EdgeType { + if in == nil { + return nil + } + out := new(EdgeType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EdgeType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeTypeList) DeepCopyInto(out *EdgeTypeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EdgeType, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeTypeList. +func (in *EdgeTypeList) DeepCopy() *EdgeTypeList { + if in == nil { + return nil + } + out := new(EdgeTypeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EdgeTypeList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeTypeSpec) DeepCopyInto(out *EdgeTypeSpec) { + *out = *in + in.Endpoints.DeepCopyInto(&out.Endpoints) + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make([]AttributeSchema, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeTypeSpec. +func (in *EdgeTypeSpec) DeepCopy() *EdgeTypeSpec { + if in == nil { + return nil + } + out := new(EdgeTypeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EdgeTypeStatus) DeepCopyInto(out *EdgeTypeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EdgeTypeStatus. +func (in *EdgeTypeStatus) DeepCopy() *EdgeTypeStatus { + if in == nil { + return nil + } + out := new(EdgeTypeStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointConstraint) DeepCopyInto(out *EndpointConstraint) { + *out = *in + if in.FromTypes != nil { + in, out := &in.FromTypes, &out.FromTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ToTypes != nil { + in, out := &in.ToTypes, &out.ToTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointConstraint. +func (in *EndpointConstraint) DeepCopy() *EndpointConstraint { + if in == nil { + return nil + } + out := new(EndpointConstraint) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Node) DeepCopyInto(out *Node) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Node. +func (in *Node) DeepCopy() *Node { + if in == nil { + return nil + } + out := new(Node) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Node) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeList) DeepCopyInto(out *NodeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Node, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeList. +func (in *NodeList) DeepCopy() *NodeList { + if in == nil { + return nil + } + out := new(NodeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeReference) DeepCopyInto(out *NodeReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeReference. +func (in *NodeReference) DeepCopy() *NodeReference { + if in == nil { + return nil + } + out := new(NodeReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeSpec) DeepCopyInto(out *NodeSpec) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeSpec. +func (in *NodeSpec) DeepCopy() *NodeSpec { + if in == nil { + return nil + } + out := new(NodeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeStatus) DeepCopyInto(out *NodeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeStatus. +func (in *NodeStatus) DeepCopy() *NodeStatus { + if in == nil { + return nil + } + out := new(NodeStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeType) DeepCopyInto(out *NodeType) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeType. +func (in *NodeType) DeepCopy() *NodeType { + if in == nil { + return nil + } + out := new(NodeType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeTypeList) DeepCopyInto(out *NodeTypeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeType, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeTypeList. +func (in *NodeTypeList) DeepCopy() *NodeTypeList { + if in == nil { + return nil + } + out := new(NodeTypeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeTypeList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeTypeSpec) DeepCopyInto(out *NodeTypeSpec) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make([]AttributeSchema, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeTypeSpec. +func (in *NodeTypeSpec) DeepCopy() *NodeTypeSpec { + if in == nil { + return nil + } + out := new(NodeTypeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeTypeStatus) DeepCopyInto(out *NodeTypeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeTypeStatus. +func (in *NodeTypeStatus) DeepCopy() *NodeTypeStatus { + if in == nil { + return nil + } + out := new(NodeTypeStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/inventory/main.go b/cmd/inventory/main.go index 02e38bb..cf9129d 100644 --- a/cmd/inventory/main.go +++ b/cmd/inventory/main.go @@ -17,8 +17,11 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" inventorycontroller "go.miloapis.com/inventory/internal/controller" + "go.miloapis.com/inventory/internal/graph" webhookv1alpha1 "go.miloapis.com/inventory/internal/webhook/v1alpha1" + webhookv1alpha2 "go.miloapis.com/inventory/internal/webhook/v1alpha2" // +kubebuilder:scaffold:imports ) @@ -36,6 +39,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(inventoryv1alpha1.AddToScheme(scheme)) + utilruntime.Must(inventoryv1alpha2.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -91,6 +95,11 @@ func main() { os.Exit(1) } + if err := graph.SetupIndexers(ctx, mgr); err != nil { + setupLog.Error(err, "unable to set up graph field indexers") + os.Exit(1) + } + // eventRecorder emits best-effort activity events on Ready transitions, // feeding the platform Activity system's human-readable timelines. It is // shared by every controller. @@ -197,6 +206,21 @@ func main() { os.Exit(1) } + if err := (&graph.NodeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GraphNode") + os.Exit(1) + } + if err := (&graph.EdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GraphEdge") + os.Exit(1) + } + if err := webhookv1alpha1.SetupRegionWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Region") os.Exit(1) @@ -246,6 +270,15 @@ func main() { os.Exit(1) } + if err := webhookv1alpha2.SetupNodeWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "GraphNode") + os.Exit(1) + } + if err := webhookv1alpha2.SetupEdgeWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "GraphEdge") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml b/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml new file mode 100644 index 0000000..3348e7d --- /dev/null +++ b/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml @@ -0,0 +1,186 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: edges.graph.inventory.miloapis.com +spec: + group: graph.inventory.miloapis.com + names: + kind: Edge + listKind: EdgeList + plural: edges + singular: edge + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .spec.from.name + name: From + type: string + - jsonPath: .spec.to.name + name: To + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].reason + name: Reason + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + Edge is a directed relationship between two graph Nodes. Its relationship + class is given by spec.type and its descriptive data by spec.attributes. It + supersedes the v1alpha1 Link/Cable/Circuit kinds and the inline parent refs. + See RFC milo-os/inventory#43. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EdgeSpec defines the desired state of a graph Edge. + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes is the edge's key/value attribute bag. The admission webhook + validates these against the matching EdgeType's attribute schema. + type: object + from: + description: From is the source endpoint Node. + properties: + name: + description: Name of the referenced Node. + minLength: 1 + type: string + required: + - name + type: object + to: + description: To is the target endpoint Node. + properties: + name: + description: Name of the referenced Node. + minLength: 1 + type: string + required: + - name + type: object + type: + description: |- + Type names the relationship class. The value must match the name of an + existing EdgeType, which constrains the endpoint node types and the + attributes this edge may carry. Typical values: located-in, member-of, + mounted-in, connects, realized-by, provided-by. This field is immutable. + minLength: 1 + type: string + required: + - from + - to + - type + type: object + x-kubernetes-validations: + - message: type is immutable + rule: self.type == oldSelf.type + - message: edge endpoints must be distinct + rule: self.from != self.to + status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for reconciliation + reason: Pending + status: "False" + type: Ready + description: EdgeStatus defines the observed state of a graph Edge. + properties: + conditions: + description: |- + Represents the observations of an edge's current state. + Known condition types are: "Ready", "EndpointsResolved". + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/crd/bases/graph.inventory.miloapis.com_edgetypes.yaml b/config/base/crd/bases/graph.inventory.miloapis.com_edgetypes.yaml new file mode 100644 index 0000000..684c491 --- /dev/null +++ b/config/base/crd/bases/graph.inventory.miloapis.com_edgetypes.yaml @@ -0,0 +1,195 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: edgetypes.graph.inventory.miloapis.com +spec: + group: graph.inventory.miloapis.com + names: + kind: EdgeType + listKind: EdgeTypeList + plural: edgetypes + singular: edgetype + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.displayName + name: Display + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + EdgeType describes the attribute schema and endpoint constraints for a + class of graph Edges. See RFC milo-os/inventory#43. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + EdgeTypeSpec describes the closed attribute schema and endpoint constraints + for Edges whose spec.type equals this EdgeType's metadata.name. + properties: + attributes: + description: |- + Attributes is the closed set of attribute keys an Edge of this type may + carry. + items: + description: |- + AttributeSchema describes one allowed attribute key on a Node or Edge type. + The set of AttributeSchemas on a NodeType/EdgeType is the closed schema the + admission webhook validates each object's attributes against. + properties: + enum: + description: Enum optionally restricts a String attribute to + a fixed set of values. + items: + type: string + type: array + x-kubernetes-list-type: atomic + key: + description: Key is the attribute name (the map key in spec.attributes). + minLength: 1 + type: string + required: + description: |- + Required marks the attribute as mandatory. Objects missing a required + attribute are rejected at admission. + type: boolean + type: + description: Type is the scalar kind the value must parse as. + enum: + - String + - Integer + - Float + - Boolean + type: string + required: + - key + - type + type: object + type: array + x-kubernetes-list-map-keys: + - key + x-kubernetes-list-type: map + displayName: + description: DisplayName is a human-readable label for the edge type. + type: string + endpoints: + description: Endpoints constrains the NodeTypes the edge may connect. + properties: + fromTypes: + description: |- + FromTypes is the set of allowed NodeType names for the edge's `from` + endpoint. Empty means any. + items: + type: string + type: array + x-kubernetes-list-type: set + toTypes: + description: |- + ToTypes is the set of allowed NodeType names for the edge's `to` + endpoint. Empty means any. + items: + type: string + type: array + x-kubernetes-list-type: set + type: object + type: object + status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for reconciliation + reason: Pending + status: "False" + type: Ready + description: EdgeTypeStatus defines the observed state of an EdgeType. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/crd/bases/graph.inventory.miloapis.com_nodes.yaml b/config/base/crd/bases/graph.inventory.miloapis.com_nodes.yaml new file mode 100644 index 0000000..b56b667 --- /dev/null +++ b/config/base/crd/bases/graph.inventory.miloapis.com_nodes.yaml @@ -0,0 +1,158 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: nodes.graph.inventory.miloapis.com +spec: + group: graph.inventory.miloapis.com + names: + kind: Node + listKind: NodeList + plural: nodes + shortNames: + - invnode + singular: node + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].reason + name: Reason + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + Node is a vertex in the inventory property graph. Its asset class is given + by spec.type and its descriptive data by spec.attributes. It supersedes the + per-kind v1alpha1 inventory kinds. See RFC milo-os/inventory#43. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: NodeSpec defines the desired state of a graph Node. + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes is the node's key/value attribute bag. The admission webhook + validates these against the matching NodeType's attribute schema. + type: object + type: + description: |- + Type names the node's asset class. The value must match the name of an + existing NodeType, which describes the attributes this node may carry. + Typical values are the former v1alpha1 kinds: Region, Site, Cluster, + NetworkDevice, Rack, Provider, Circuit, Cable, Port, VirtualMachine, + Host (the former compute Node), Fleet. This field is immutable. + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type is immutable + rule: self.type == oldSelf.type + status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for reconciliation + reason: Pending + status: "False" + type: Ready + description: NodeStatus defines the observed state of a graph Node. + properties: + conditions: + description: |- + Represents the observations of a node's current state. + Known condition types are: "Ready". + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/crd/bases/graph.inventory.miloapis.com_nodetypes.yaml b/config/base/crd/bases/graph.inventory.miloapis.com_nodetypes.yaml new file mode 100644 index 0000000..6ad9e65 --- /dev/null +++ b/config/base/crd/bases/graph.inventory.miloapis.com_nodetypes.yaml @@ -0,0 +1,177 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: nodetypes.graph.inventory.miloapis.com +spec: + group: graph.inventory.miloapis.com + names: + kind: NodeType + listKind: NodeTypeList + plural: nodetypes + singular: nodetype + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.displayName + name: Display + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + NodeType describes the attribute schema for a class of graph Nodes. It is + the source of truth the admission webhook validates Node.spec.attributes + against. See RFC milo-os/inventory#43. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + NodeTypeSpec describes the closed attribute schema for Nodes whose + spec.type equals this NodeType's metadata.name. + properties: + attributes: + description: |- + Attributes is the closed set of attribute keys a Node of this type may + carry. Keys outside this set are rejected at admission; keys marked + Required must be present. + items: + description: |- + AttributeSchema describes one allowed attribute key on a Node or Edge type. + The set of AttributeSchemas on a NodeType/EdgeType is the closed schema the + admission webhook validates each object's attributes against. + properties: + enum: + description: Enum optionally restricts a String attribute to + a fixed set of values. + items: + type: string + type: array + x-kubernetes-list-type: atomic + key: + description: Key is the attribute name (the map key in spec.attributes). + minLength: 1 + type: string + required: + description: |- + Required marks the attribute as mandatory. Objects missing a required + attribute are rejected at admission. + type: boolean + type: + description: Type is the scalar kind the value must parse as. + enum: + - String + - Integer + - Float + - Boolean + type: string + required: + - key + - type + type: object + type: array + x-kubernetes-list-map-keys: + - key + x-kubernetes-list-type: map + displayName: + description: DisplayName is a human-readable label for the node type. + type: string + type: object + status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for reconciliation + reason: Pending + status: "False" + type: Ready + description: NodeTypeStatus defines the observed state of a NodeType. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/base/crd/kustomization.yaml b/config/base/crd/kustomization.yaml index f341476..b231a49 100644 --- a/config/base/crd/kustomization.yaml +++ b/config/base/crd/kustomization.yaml @@ -14,6 +14,10 @@ resources: - bases/inventory.miloapis.com_circuits.yaml - bases/inventory.miloapis.com_virtualmachines.yaml - bases/inventory.miloapis.com_links.yaml +- bases/graph.inventory.miloapis.com_nodetypes.yaml +- bases/graph.inventory.miloapis.com_edgetypes.yaml +- bases/graph.inventory.miloapis.com_nodes.yaml +- bases/graph.inventory.miloapis.com_edges.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/base/webhook/kustomization.yaml b/config/base/webhook/kustomization.yaml index 628198f..0269d12 100644 --- a/config/base/webhook/kustomization.yaml +++ b/config/base/webhook/kustomization.yaml @@ -91,6 +91,18 @@ patches: - op: replace path: /webhooks/11/clientConfig/service/namespace value: inventory-system + - op: replace + path: /webhooks/12/clientConfig/service/name + value: inventory-webhook + - op: replace + path: /webhooks/12/clientConfig/service/namespace + value: inventory-system + - op: replace + path: /webhooks/13/clientConfig/service/name + value: inventory-webhook + - op: replace + path: /webhooks/13/clientConfig/service/namespace + value: inventory-system target: kind: ValidatingWebhookConfiguration name: validating-webhook-configuration diff --git a/config/base/webhook/manifests.yaml b/config/base/webhook/manifests.yaml index e8f74da..9011c46 100644 --- a/config/base/webhook/manifests.yaml +++ b/config/base/webhook/manifests.yaml @@ -242,3 +242,44 @@ webhooks: resources: - virtualmachines sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-graph-inventory-miloapis-com-v1alpha2-edge + failurePolicy: Fail + name: vedge.v1alpha2.graph.inventory.miloapis.com + rules: + - apiGroups: + - graph.inventory.miloapis.com + apiVersions: + - v1alpha2 + operations: + - CREATE + - UPDATE + resources: + - edges + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-graph-inventory-miloapis-com-v1alpha2-node + failurePolicy: Fail + name: vnode.v1alpha2.graph.inventory.miloapis.com + rules: + - apiGroups: + - graph.inventory.miloapis.com + apiVersions: + - v1alpha2 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - nodes + sideEffects: None diff --git a/config/components/controller_rbac/role.yaml b/config/components/controller_rbac/role.yaml index 1e91012..4153642 100644 --- a/config/components/controller_rbac/role.yaml +++ b/config/components/controller_rbac/role.yaml @@ -11,6 +11,26 @@ rules: verbs: - create - patch +- apiGroups: + - graph.inventory.miloapis.com + resources: + - edges + - edgetypes + - nodes + - nodetypes + verbs: + - get + - list + - watch +- apiGroups: + - graph.inventory.miloapis.com + resources: + - edges/status + - nodes/status + verbs: + - get + - patch + - update - apiGroups: - inventory.miloapis.com resources: diff --git a/config/samples/graph_v1alpha2_demo.yaml b/config/samples/graph_v1alpha2_demo.yaml new file mode 100644 index 0000000..364e41e --- /dev/null +++ b/config/samples/graph_v1alpha2_demo.yaml @@ -0,0 +1,80 @@ +# Property-graph model prototype (RFC milo-os/inventory#43). +# +# Demonstrates the v1alpha2 model: NodeType/EdgeType define closed attribute +# schemas; Node/Edge are validated against them by the admission webhook. +# A Site node and a Host node (the former compute Node) are linked by a +# `located-in` edge. +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: NodeType +metadata: + name: Site +spec: + displayName: Site + attributes: + - key: displayName + type: String + required: true + - key: siteType + type: String + enum: [Datacenter, AvailabilityZone, Edge, Virtual] + required: true +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: NodeType +metadata: + name: Host +spec: + displayName: Host (compute node) + attributes: + - key: cpuCores + type: Integer + required: true + - key: cpuArchitecture + type: String + enum: [amd64, arm64] + required: true + - key: memoryBytes + type: Integer +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: EdgeType +metadata: + name: located-in +spec: + displayName: located in + endpoints: + fromTypes: [Host, NetworkDevice, Rack] + toTypes: [Site] +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Node +metadata: + name: site-iad1 +spec: + type: Site + attributes: + displayName: US East 1 + siteType: Datacenter +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Node +metadata: + name: host-001 +spec: + type: Host + attributes: + cpuCores: "64" + cpuArchitecture: amd64 + memoryBytes: "274877906944" +--- +apiVersion: graph.inventory.miloapis.com/v1alpha2 +kind: Edge +metadata: + name: host-001-in-iad1 +spec: + type: located-in + from: + name: host-001 + to: + name: site-iad1 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index bc3ed47..237e781 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -13,4 +13,5 @@ resources: - inventory_v1alpha1_virtualmachine.yaml - inventory_v1alpha1_link.yaml - inventory_v1alpha1_fleet.yaml +- graph_v1alpha2_demo.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/graph/edge_controller.go b/internal/graph/edge_controller.go new file mode 100644 index 0000000..abbdf9d --- /dev/null +++ b/internal/graph/edge_controller.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package graph + +import ( + "context" + "reflect" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" + "go.miloapis.com/inventory/internal/controller" +) + +// EdgeReconciler reconciles graph Edge objects. It re-validates the Edge +// (EdgeType, endpoint existence, endpoint-type constraints, attributes) and +// reflects the result in the Ready and EndpointsResolved conditions. +type EdgeReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=edges,verbs=get;list;watch +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=edges/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=edgetypes,verbs=get;list;watch + +func (r *EdgeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + edge := &inventoryv1alpha2.Edge{} + if err := r.Get(ctx, req.NamespacedName, edge); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + original := edge.DeepCopy() + + if err := ValidateEdge(ctx, r.Client, edge); err != nil { + controller.SetNotReady(edge.GetGeneration(), &edge.Status.Conditions, + inventoryv1alpha2.EdgeEndpointNotFoundReason, err.Error()) + r.setEndpointsResolved(edge, metav1.ConditionFalse, err.Error()) + } else { + controller.SetReady(edge.GetGeneration(), &edge.Status.Conditions, + inventoryv1alpha2.EdgeReadyReason, "Edge accepted") + r.setEndpointsResolved(edge, metav1.ConditionTrue, "Both endpoints resolve") + } + + if reflect.DeepEqual(original.Status, edge.Status) { + return ctrl.Result{}, nil + } + if err := r.Status().Patch(ctx, edge, client.MergeFrom(original)); err != nil { + log.Error(err, "failed to patch Edge status") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *EdgeReconciler) setEndpointsResolved(edge *inventoryv1alpha2.Edge, status metav1.ConditionStatus, msg string) { + reason := inventoryv1alpha2.EdgeReadyReason + if status == metav1.ConditionFalse { + reason = inventoryv1alpha2.EdgeEndpointNotFoundReason + } + meta.SetStatusCondition(&edge.Status.Conditions, metav1.Condition{ + Type: inventoryv1alpha2.EdgeEndpointsResolved, + Status: status, + Reason: reason, + Message: msg, + ObservedGeneration: edge.GetGeneration(), + }) +} + +// SetupWithManager registers the Edge controller. SetupIndexers must have +// already been called. +func (r *EdgeReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&inventoryv1alpha2.Edge{}). + Watches(&inventoryv1alpha2.Node{}, handler.EnqueueRequestsFromMapFunc(r.edgesForNode)). + Named("graph-edge"). + Complete(r) +} + +// edgesForNode enqueues every Edge that has the changed Node as an endpoint +// so endpoint-existence status stays current. +func (r *EdgeReconciler) edgesForNode(ctx context.Context, obj client.Object) []reconcile.Request { + edges, err := EdgesReferencing(ctx, r.Client, obj.GetName()) + if err != nil { + return nil + } + reqs := make([]reconcile.Request, 0, len(edges)) + for i := range edges { + reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&edges[i])}) + } + return reqs +} diff --git a/internal/graph/indexers.go b/internal/graph/indexers.go new file mode 100644 index 0000000..a582e9f --- /dev/null +++ b/internal/graph/indexers.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package graph + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" +) + +const ( + // IndexEdgeEndpointName indexes Edges by both endpoint Node names + // (spec.from.name and spec.to.name). A single Edge appears under up to two + // values. This one index replaces the per-kind reference indexers of the + // v1alpha1 model: the Node delete-guard counts Edges touching a Node, and + // the Edge controller wakes on endpoint changes through it. + IndexEdgeEndpointName = "spec.endpoints.name" +) + +// SetupIndexers registers the graph field indexers against the manager's +// cache. It must be called once before the graph controllers start. +func SetupIndexers(ctx context.Context, mgr ctrl.Manager) error { + idx := mgr.GetFieldIndexer() + + if err := idx.IndexField(ctx, &inventoryv1alpha2.Edge{}, IndexEdgeEndpointName, func(obj client.Object) []string { + edge, ok := obj.(*inventoryv1alpha2.Edge) + if !ok { + return nil + } + var names []string + if edge.Spec.From.Name != "" { + names = append(names, edge.Spec.From.Name) + } + if edge.Spec.To.Name != "" { + names = append(names, edge.Spec.To.Name) + } + return names + }); err != nil { + return fmt.Errorf("indexing Edge.%s: %w", IndexEdgeEndpointName, err) + } + + return nil +} + +// EdgesReferencing returns the Edges that have the named Node as either +// endpoint. The Node delete-guard webhook uses this. +func EdgesReferencing(ctx context.Context, c client.Client, nodeName string) ([]inventoryv1alpha2.Edge, error) { + var edges inventoryv1alpha2.EdgeList + if err := c.List(ctx, &edges, client.MatchingFields{IndexEdgeEndpointName: nodeName}); err != nil { + return nil, err + } + return edges.Items, nil +} diff --git a/internal/graph/node_controller.go b/internal/graph/node_controller.go new file mode 100644 index 0000000..4deec49 --- /dev/null +++ b/internal/graph/node_controller.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package graph + +import ( + "context" + "reflect" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" + "go.miloapis.com/inventory/internal/controller" +) + +// NodeReconciler reconciles graph Node objects. It re-validates the Node +// against its NodeType and reflects the result in the Ready condition. The +// admission webhook is the primary gate; this controller keeps status honest +// when a NodeType changes after the Node was admitted. +type NodeReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=nodes,verbs=get;list;watch +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=nodes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=graph.inventory.miloapis.com,resources=nodetypes,verbs=get;list;watch + +func (r *NodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + node := &inventoryv1alpha2.Node{} + if err := r.Get(ctx, req.NamespacedName, node); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + original := node.DeepCopy() + + if err := ValidateNode(ctx, r.Client, node); err != nil { + controller.SetNotReady(node.GetGeneration(), &node.Status.Conditions, + inventoryv1alpha2.NodeTypeNotFoundReason, err.Error()) + } else { + controller.SetReady(node.GetGeneration(), &node.Status.Conditions, + inventoryv1alpha2.NodeReadyReason, "Node accepted") + } + + if reflect.DeepEqual(original.Status, node.Status) { + return ctrl.Result{}, nil + } + if err := r.Status().Patch(ctx, node, client.MergeFrom(original)); err != nil { + log.Error(err, "failed to patch Node status") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// SetupWithManager registers the Node controller. SetupIndexers must have +// already been called. +func (r *NodeReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&inventoryv1alpha2.Node{}). + Watches(&inventoryv1alpha2.NodeType{}, handler.EnqueueRequestsFromMapFunc(r.nodesForType)). + Named("graph-node"). + Complete(r) +} + +// nodesForType enqueues every Node whose spec.type matches the changed +// NodeType so their Ready condition tracks schema edits. +func (r *NodeReconciler) nodesForType(ctx context.Context, obj client.Object) []reconcile.Request { + var nodes inventoryv1alpha2.NodeList + if err := r.List(ctx, &nodes); err != nil { + return nil + } + var reqs []reconcile.Request + for i := range nodes.Items { + if nodes.Items[i].Spec.Type == obj.GetName() { + reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&nodes.Items[i])}) + } + } + return reqs +} diff --git a/internal/graph/validate.go b/internal/graph/validate.go new file mode 100644 index 0000000..c7a0b31 --- /dev/null +++ b/internal/graph/validate.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package graph holds the reconcilers, field indexers, and shared validation +// logic for the v1alpha2 property-graph model (Node + Edge, described by +// NodeType + EdgeType). The admission webhooks in internal/webhook/v1alpha2 +// reuse the validation helpers here so the webhook and controller halves stay +// in sync. See RFC milo-os/inventory#43. +package graph + +import ( + "context" + "fmt" + "sort" + "strconv" + + apierrors "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" +) + +// ValidateAttributes checks an attribute bag against a closed schema. It +// rejects unknown keys, missing required keys, values that do not parse as +// their declared type, and String values outside a declared enum. The schema +// owner string (e.g. `NodeType "Site"`) is used only in error messages. +func ValidateAttributes(owner string, schema []inventoryv1alpha2.AttributeSchema, attrs map[string]string) error { + byKey := make(map[string]inventoryv1alpha2.AttributeSchema, len(schema)) + for _, s := range schema { + byKey[s.Key] = s + } + + for key, val := range attrs { + s, ok := byKey[key] + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("%s does not allow attribute %q", owner, key)) + } + if err := validateValue(owner, s, val); err != nil { + return err + } + } + + for _, missing := range missingRequired(byKey, attrs) { + return apierrors.NewBadRequest(fmt.Sprintf("%s requires attribute %q", owner, missing)) + } + return nil +} + +func missingRequired(byKey map[string]inventoryv1alpha2.AttributeSchema, attrs map[string]string) []string { + var missing []string + for key, s := range byKey { + if !s.Required { + continue + } + if _, ok := attrs[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + return missing +} + +func validateValue(owner string, s inventoryv1alpha2.AttributeSchema, val string) error { + switch s.Type { + case inventoryv1alpha2.AttributeInteger: + if _, err := strconv.ParseInt(val, 10, 64); err != nil { + return apierrors.NewBadRequest(fmt.Sprintf("%s attribute %q must be an integer, got %q", owner, s.Key, val)) + } + case inventoryv1alpha2.AttributeFloat: + if _, err := strconv.ParseFloat(val, 64); err != nil { + return apierrors.NewBadRequest(fmt.Sprintf("%s attribute %q must be a float, got %q", owner, s.Key, val)) + } + case inventoryv1alpha2.AttributeBoolean: + if val != "true" && val != "false" { + return apierrors.NewBadRequest(fmt.Sprintf("%s attribute %q must be true or false, got %q", owner, s.Key, val)) + } + case inventoryv1alpha2.AttributeString: + if len(s.Enum) > 0 && !contains(s.Enum, val) { + return apierrors.NewBadRequest(fmt.Sprintf("%s attribute %q must be one of %v, got %q", owner, s.Key, s.Enum, val)) + } + } + return nil +} + +func contains(set []string, v string) bool { + for _, s := range set { + if s == v { + return true + } + } + return false +} + +// ValidateNode resolves the Node's NodeType and validates its attributes +// against that type's schema. A missing NodeType is rejected. +func ValidateNode(ctx context.Context, c client.Client, node *inventoryv1alpha2.Node) error { + var nt inventoryv1alpha2.NodeType + if err := c.Get(ctx, types.NamespacedName{Name: node.Spec.Type}, &nt); err != nil { + if apierrors.IsNotFound(err) { + return apierrors.NewBadRequest(fmt.Sprintf("NodeType %q not found", node.Spec.Type)) + } + return err + } + return ValidateAttributes(fmt.Sprintf("NodeType %q", nt.Name), nt.Spec.Attributes, node.Spec.Attributes) +} + +// ValidateEdge resolves the Edge's EdgeType, verifies both endpoint Nodes +// exist and satisfy the type's endpoint constraints, and validates the edge's +// attributes against the type's schema. +func ValidateEdge(ctx context.Context, c client.Client, edge *inventoryv1alpha2.Edge) error { + var et inventoryv1alpha2.EdgeType + if err := c.Get(ctx, types.NamespacedName{Name: edge.Spec.Type}, &et); err != nil { + if apierrors.IsNotFound(err) { + return apierrors.NewBadRequest(fmt.Sprintf("EdgeType %q not found", edge.Spec.Type)) + } + return err + } + + from, err := getNode(ctx, c, edge.Spec.From.Name, "from") + if err != nil { + return err + } + to, err := getNode(ctx, c, edge.Spec.To.Name, "to") + if err != nil { + return err + } + + if cs := et.Spec.Endpoints.FromTypes; len(cs) > 0 && !contains(cs, from.Spec.Type) { + return apierrors.NewBadRequest(fmt.Sprintf( + "EdgeType %q does not allow a %q node as `from` (allowed: %v)", et.Name, from.Spec.Type, cs)) + } + if cs := et.Spec.Endpoints.ToTypes; len(cs) > 0 && !contains(cs, to.Spec.Type) { + return apierrors.NewBadRequest(fmt.Sprintf( + "EdgeType %q does not allow a %q node as `to` (allowed: %v)", et.Name, to.Spec.Type, cs)) + } + + return ValidateAttributes(fmt.Sprintf("EdgeType %q", et.Name), et.Spec.Attributes, edge.Spec.Attributes) +} + +func getNode(ctx context.Context, c client.Client, name, side string) (*inventoryv1alpha2.Node, error) { + var n inventoryv1alpha2.Node + if err := c.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil { + if apierrors.IsNotFound(err) { + return nil, apierrors.NewBadRequest(fmt.Sprintf("%s endpoint Node %q not found", side, name)) + } + return nil, err + } + return &n, nil +} diff --git a/internal/webhook/v1alpha2/edge_webhook.go b/internal/webhook/v1alpha2/edge_webhook.go new file mode 100644 index 0000000..266dfe9 --- /dev/null +++ b/internal/webhook/v1alpha2/edge_webhook.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" + "go.miloapis.com/inventory/internal/graph" +) + +// +kubebuilder:webhook:path=/validate-graph-inventory-miloapis-com-v1alpha2-edge,mutating=false,failurePolicy=fail,sideEffects=None,groups=graph.inventory.miloapis.com,resources=edges,verbs=create;update,versions=v1alpha2,name=vedge.v1alpha2.graph.inventory.miloapis.com,admissionReviewVersions=v1 + +var edgeLog = logf.Log.WithName("graph-edge-webhook") + +// EdgeValidator validates graph Edge CREATE/UPDATE: the EdgeType exists, both +// endpoint Nodes exist and satisfy the type's endpoint constraints, and the +// attribute bag matches the type schema. +type EdgeValidator struct { + Client client.Client +} + +var _ admission.Validator[*inventoryv1alpha2.Edge] = &EdgeValidator{} + +func (v *EdgeValidator) ValidateCreate(ctx context.Context, obj *inventoryv1alpha2.Edge) (admission.Warnings, error) { + edgeLog.Info("validating create", "name", obj.Name, "type", obj.Spec.Type) + return nil, graph.ValidateEdge(ctx, v.Client, obj) +} + +func (v *EdgeValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *inventoryv1alpha2.Edge) (admission.Warnings, error) { + return nil, graph.ValidateEdge(ctx, v.Client, newObj) +} + +func (v *EdgeValidator) ValidateDelete(ctx context.Context, obj *inventoryv1alpha2.Edge) (admission.Warnings, error) { + return nil, nil +} + +// SetupEdgeWebhookWithManager registers the EdgeValidator with the manager. +func SetupEdgeWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &inventoryv1alpha2.Edge{}). + WithValidator(&EdgeValidator{Client: mgr.GetClient()}). + Complete() +} + +// edgeNames returns up to five edge names for inclusion in a rejection +// message; truncationSuffix summarizes any remainder. +func edgeNames(edges []inventoryv1alpha2.Edge) []string { + limit := len(edges) + if limit > maxEdgeNames { + limit = maxEdgeNames + } + out := make([]string, 0, limit) + for i := 0; i < limit; i++ { + out = append(out, edges[i].Name) + } + return out +} + +const maxEdgeNames = 5 + +func truncationSuffix(total int) string { + if total <= maxEdgeNames { + return "" + } + return fmt.Sprintf(" (and %d more)", total-maxEdgeNames) +} diff --git a/internal/webhook/v1alpha2/node_webhook.go b/internal/webhook/v1alpha2/node_webhook.go new file mode 100644 index 0000000..7a36c98 --- /dev/null +++ b/internal/webhook/v1alpha2/node_webhook.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha2 + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" + "go.miloapis.com/inventory/internal/graph" +) + +// +kubebuilder:webhook:path=/validate-graph-inventory-miloapis-com-v1alpha2-node,mutating=false,failurePolicy=fail,sideEffects=None,groups=graph.inventory.miloapis.com,resources=nodes,verbs=create;update;delete,versions=v1alpha2,name=vnode.v1alpha2.graph.inventory.miloapis.com,admissionReviewVersions=v1 + +var nodeLog = logf.Log.WithName("graph-node-webhook") + +// NodeValidator validates graph Node admission. CREATE/UPDATE validate the +// attribute bag against the Node's NodeType; DELETE rejects removal while any +// Edge still references the Node (the generic delete-guard that replaces the +// per-kind v1alpha1 guards). +type NodeValidator struct { + Client client.Client +} + +var _ admission.Validator[*inventoryv1alpha2.Node] = &NodeValidator{} + +func (v *NodeValidator) ValidateCreate(ctx context.Context, obj *inventoryv1alpha2.Node) (admission.Warnings, error) { + nodeLog.Info("validating create", "name", obj.Name, "type", obj.Spec.Type) + return nil, graph.ValidateNode(ctx, v.Client, obj) +} + +func (v *NodeValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *inventoryv1alpha2.Node) (admission.Warnings, error) { + return nil, graph.ValidateNode(ctx, v.Client, newObj) +} + +func (v *NodeValidator) ValidateDelete(ctx context.Context, obj *inventoryv1alpha2.Node) (admission.Warnings, error) { + nodeLog.Info("validating delete", "name", obj.Name) + + edges, err := graph.EdgesReferencing(ctx, v.Client, obj.Name) + if err != nil { + return nil, err + } + if len(edges) > 0 { + return nil, apierrors.NewBadRequest(fmt.Sprintf( + "cannot delete Node %s: %d Edge(s) still reference it: %v%s", + obj.Name, len(edges), edgeNames(edges), truncationSuffix(len(edges)), + )) + } + return nil, nil +} + +// SetupNodeWebhookWithManager registers the NodeValidator with the manager. +func SetupNodeWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &inventoryv1alpha2.Node{}). + WithValidator(&NodeValidator{Client: mgr.GetClient()}). + Complete() +} From f4171a04b16320cb3bfb336ed7091736c27e3d09 Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Fri, 12 Jun 2026 13:49:40 -0400 Subject: [PATCH 2/2] test(graph): wire v1alpha2 into envtest suite + Node/Edge specs Register the v1alpha2 scheme, graph indexers, graph reconcilers, and graph webhooks in the controller envtest suite so the property-graph model is exercised against a real apiserver. Adds graph_controller_test.go covering: - Node Ready when NodeType exists and attributes validate - Node admission rejects: missing NodeType, unknown attribute, missing required attribute, non-parsing Integer, out-of-enum String - Edge Ready + EndpointsResolved when both endpoints exist - Edge admission rejects: missing endpoint Node, endpoint-type-constraint violation - Node DELETE blocked while an Edge references it (generic delete-guard) Also fixes the Edge distinct-endpoints CEL rule: structs are not comparable with `!=`, so compare self.from.name != self.to.name. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha2/edge_types.go | 2 +- .../graph.inventory.miloapis.com_edges.yaml | 2 +- internal/controller/graph_controller_test.go | 254 ++++++++++++++++++ internal/controller/suite_test.go | 15 ++ 4 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 internal/controller/graph_controller_test.go diff --git a/api/v1alpha2/edge_types.go b/api/v1alpha2/edge_types.go index d3c1fa6..87208d3 100644 --- a/api/v1alpha2/edge_types.go +++ b/api/v1alpha2/edge_types.go @@ -9,7 +9,7 @@ import ( // EdgeSpec defines the desired state of a graph Edge. // // +kubebuilder:validation:XValidation:rule="self.type == oldSelf.type",message="type is immutable" -// +kubebuilder:validation:XValidation:rule="self.from != self.to",message="edge endpoints must be distinct" +// +kubebuilder:validation:XValidation:rule="self.from.name != self.to.name",message="edge endpoints must be distinct" type EdgeSpec struct { // Type names the relationship class. The value must match the name of an // existing EdgeType, which constrains the endpoint node types and the diff --git a/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml b/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml index 3348e7d..aa9ed8f 100644 --- a/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml +++ b/config/base/crd/bases/graph.inventory.miloapis.com_edges.yaml @@ -106,7 +106,7 @@ spec: - message: type is immutable rule: self.type == oldSelf.type - message: edge endpoints must be distinct - rule: self.from != self.to + rule: self.from.name != self.to.name status: default: conditions: diff --git a/internal/controller/graph_controller_test.go b/internal/controller/graph_controller_test.go new file mode 100644 index 0000000..273a07f --- /dev/null +++ b/internal/controller/graph_controller_test.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller_test + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func deleteGraphNode(name string) { + n := &inventoryv1alpha2.Node{} + n.Name = name + Expect(client.IgnoreNotFound(k8sClient.Delete(testCtx, n))).To(Succeed()) +} + +func deleteEdge(name string) { + e := &inventoryv1alpha2.Edge{} + e.Name = name + Expect(client.IgnoreNotFound(k8sClient.Delete(testCtx, e))).To(Succeed()) +} + +func deleteNodeType(name string) { + nt := &inventoryv1alpha2.NodeType{} + nt.Name = name + Expect(client.IgnoreNotFound(k8sClient.Delete(testCtx, nt))).To(Succeed()) +} + +func deleteEdgeType(name string) { + et := &inventoryv1alpha2.EdgeType{} + et.Name = name + Expect(client.IgnoreNotFound(k8sClient.Delete(testCtx, et))).To(Succeed()) +} + +func makeSiteNodeType(name string) *inventoryv1alpha2.NodeType { + return &inventoryv1alpha2.NodeType{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: inventoryv1alpha2.NodeTypeSpec{ + Attributes: []inventoryv1alpha2.AttributeSchema{ + {Key: "displayName", Type: inventoryv1alpha2.AttributeString, Required: true}, + {Key: "siteType", Type: inventoryv1alpha2.AttributeString, Required: true, + Enum: []string{"Datacenter", "Edge"}}, + {Key: "cpuCores", Type: inventoryv1alpha2.AttributeInteger}, + }, + }, + } +} + +func makeHostNodeType(name string) *inventoryv1alpha2.NodeType { + return &inventoryv1alpha2.NodeType{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: inventoryv1alpha2.NodeTypeSpec{ + Attributes: []inventoryv1alpha2.AttributeSchema{ + {Key: "cpuCores", Type: inventoryv1alpha2.AttributeInteger, Required: true}, + }, + }, + } +} + +func makeLocatedInEdgeType(name, fromType, toType string) *inventoryv1alpha2.EdgeType { + return &inventoryv1alpha2.EdgeType{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: inventoryv1alpha2.EdgeTypeSpec{ + Endpoints: inventoryv1alpha2.EndpointConstraint{ + FromTypes: []string{fromType}, + ToTypes: []string{toType}, + }, + }, + } +} + +func makeGraphNode(name, nodeType string, attrs map[string]string) *inventoryv1alpha2.Node { + return &inventoryv1alpha2.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: inventoryv1alpha2.NodeSpec{Type: nodeType, Attributes: attrs}, + } +} + +func makeEdge(name, edgeType, from, to string) *inventoryv1alpha2.Edge { + return &inventoryv1alpha2.Edge{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: inventoryv1alpha2.EdgeSpec{ + Type: edgeType, + From: inventoryv1alpha2.NodeReference{Name: from}, + To: inventoryv1alpha2.NodeReference{Name: to}, + }, + } +} + +var _ = Describe("Graph Node Controller", func() { + var siteTypeName, hostTypeName, nodeName string + + BeforeEach(func() { + siteTypeName = uniqueName("site") + hostTypeName = uniqueName("host") + nodeName = uniqueName("node") + }) + + AfterEach(func() { + deleteGraphNode(nodeName) + deleteNodeType(siteTypeName) + deleteNodeType(hostTypeName) + }) + + It("becomes Ready when its NodeType exists and attributes are valid", func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + + node := makeGraphNode(nodeName, siteTypeName, map[string]string{ + "displayName": "US East 1", + "siteType": "Datacenter", + }) + Expect(k8sClient.Create(testCtx, node)).To(Succeed()) + + Eventually(func(g Gomega) { + var fetched inventoryv1alpha2.Node + g.Expect(k8sClient.Get(testCtx, client.ObjectKeyFromObject(node), &fetched)).To(Succeed()) + ready := meta.FindStatusCondition(fetched.Status.Conditions, inventoryv1alpha2.NodeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + }).WithTimeout(defaultTimeout).WithPolling(defaultInterval).Should(Succeed()) + }) + + It("is rejected at admission when the NodeType does not exist", func() { + node := makeGraphNode(nodeName, siteTypeName, map[string]string{"displayName": "x", "siteType": "Edge"}) + err := k8sClient.Create(testCtx, node) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("NodeType")) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("is rejected when an attribute is unknown", func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + node := makeGraphNode(nodeName, siteTypeName, map[string]string{ + "displayName": "x", "siteType": "Edge", "bogus": "1", + }) + err := k8sClient.Create(testCtx, node) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not allow attribute")) + }) + + It("is rejected when a required attribute is missing", func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + node := makeGraphNode(nodeName, siteTypeName, map[string]string{"displayName": "x"}) + err := k8sClient.Create(testCtx, node) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("requires attribute")) + }) + + It("is rejected when an Integer attribute does not parse", func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + node := makeGraphNode(nodeName, siteTypeName, map[string]string{ + "displayName": "x", "siteType": "Edge", "cpuCores": "lots", + }) + err := k8sClient.Create(testCtx, node) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must be an integer")) + }) + + It("is rejected when a String attribute is outside its enum", func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + node := makeGraphNode(nodeName, siteTypeName, map[string]string{ + "displayName": "x", "siteType": "Orbital", + }) + err := k8sClient.Create(testCtx, node) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must be one of")) + }) +}) + +var _ = Describe("Graph Edge Controller", func() { + var siteTypeName, hostTypeName, edgeTypeName string + var siteNode, hostNode, edgeName string + + BeforeEach(func() { + siteTypeName = uniqueName("site") + hostTypeName = uniqueName("host") + edgeTypeName = uniqueName("locatedin") + siteNode = uniqueName("site") + hostNode = uniqueName("host") + edgeName = uniqueName("edge") + }) + + AfterEach(func() { + deleteEdge(edgeName) + deleteGraphNode(hostNode) + deleteGraphNode(siteNode) + deleteEdgeType(edgeTypeName) + deleteNodeType(siteTypeName) + deleteNodeType(hostTypeName) + }) + + setup := func() { + Expect(k8sClient.Create(testCtx, makeSiteNodeType(siteTypeName))).To(Succeed()) + Expect(k8sClient.Create(testCtx, makeHostNodeType(hostTypeName))).To(Succeed()) + Expect(k8sClient.Create(testCtx, makeLocatedInEdgeType(edgeTypeName, hostTypeName, siteTypeName))).To(Succeed()) + Expect(k8sClient.Create(testCtx, makeGraphNode(siteNode, siteTypeName, map[string]string{ + "displayName": "US East 1", "siteType": "Datacenter", + }))).To(Succeed()) + Expect(k8sClient.Create(testCtx, makeGraphNode(hostNode, hostTypeName, map[string]string{ + "cpuCores": "64", + }))).To(Succeed()) + } + + It("becomes Ready with EndpointsResolved=True when both endpoints exist", func() { + setup() + edge := makeEdge(edgeName, edgeTypeName, hostNode, siteNode) + Expect(k8sClient.Create(testCtx, edge)).To(Succeed()) + + Eventually(func(g Gomega) { + var fetched inventoryv1alpha2.Edge + g.Expect(k8sClient.Get(testCtx, client.ObjectKeyFromObject(edge), &fetched)).To(Succeed()) + ready := meta.FindStatusCondition(fetched.Status.Conditions, inventoryv1alpha2.EdgeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + resolved := meta.FindStatusCondition(fetched.Status.Conditions, inventoryv1alpha2.EdgeEndpointsResolved) + g.Expect(resolved).NotTo(BeNil()) + g.Expect(resolved.Status).To(Equal(metav1.ConditionTrue)) + }).WithTimeout(defaultTimeout).WithPolling(defaultInterval).Should(Succeed()) + }) + + It("is rejected at admission when an endpoint Node does not exist", func() { + setup() + edge := makeEdge(edgeName, edgeTypeName, hostNode, uniqueName("ghost")) + err := k8sClient.Create(testCtx, edge) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("is rejected when an endpoint violates the EdgeType endpoint-type constraint", func() { + setup() + // from/to swapped: a Site cannot be the `from` of this located-in type. + edge := makeEdge(edgeName, edgeTypeName, siteNode, hostNode) + err := k8sClient.Create(testCtx, edge) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not allow")) + }) + + It("blocks deletion of a Node while an Edge still references it", func() { + setup() + edge := makeEdge(edgeName, edgeTypeName, hostNode, siteNode) + Expect(k8sClient.Create(testCtx, edge)).To(Succeed()) + + n := &inventoryv1alpha2.Node{} + n.Name = siteNode + err := k8sClient.Delete(testCtx, n) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("still reference it")) + }) +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 05ce53f..ccb4f35 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -24,8 +24,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" inventoryv1alpha1 "go.miloapis.com/inventory/api/v1alpha1" + inventoryv1alpha2 "go.miloapis.com/inventory/api/v1alpha2" inventorycontroller "go.miloapis.com/inventory/internal/controller" + "go.miloapis.com/inventory/internal/graph" webhookv1alpha1 "go.miloapis.com/inventory/internal/webhook/v1alpha1" + webhookv1alpha2 "go.miloapis.com/inventory/internal/webhook/v1alpha2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -67,6 +70,7 @@ var _ = BeforeSuite(func() { utilruntime.Must(clientgoscheme.AddToScheme(testScheme)) utilruntime.Must(inventoryv1alpha1.AddToScheme(testScheme)) + utilruntime.Must(inventoryv1alpha2.AddToScheme(testScheme)) k8sClient, err = client.New(cfg, client.Options{Scheme: testScheme}) Expect(err).NotTo(HaveOccurred()) @@ -86,6 +90,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(inventorycontroller.SetupIndexers(testCtx, mgr)).To(Succeed()) + Expect(graph.SetupIndexers(testCtx, mgr)).To(Succeed()) Expect((&inventorycontroller.RegionReconciler{ Client: mgr.GetClient(), @@ -135,6 +140,14 @@ var _ = BeforeSuite(func() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr)).To(Succeed()) + Expect((&graph.NodeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr)).To(Succeed()) + Expect((&graph.EdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr)).To(Succeed()) Expect(webhookv1alpha1.SetupRegionWebhookWithManager(mgr)).To(Succeed()) Expect(webhookv1alpha1.SetupProviderWebhookWithManager(mgr)).To(Succeed()) @@ -148,6 +161,8 @@ var _ = BeforeSuite(func() { Expect(webhookv1alpha1.SetupCableWebhookWithManager(mgr)).To(Succeed()) Expect(webhookv1alpha1.SetupCircuitWebhookWithManager(mgr)).To(Succeed()) Expect(webhookv1alpha1.SetupVirtualMachineWebhookWithManager(mgr)).To(Succeed()) + Expect(webhookv1alpha2.SetupNodeWebhookWithManager(mgr)).To(Succeed()) + Expect(webhookv1alpha2.SetupEdgeWebhookWithManager(mgr)).To(Succeed()) go func() { defer GinkgoRecover()