diff --git a/api/v1alpha1/egressshardclaim_types.go b/api/v1alpha1/egressshardclaim_types.go new file mode 100644 index 0000000..07c9c10 --- /dev/null +++ b/api/v1alpha1/egressshardclaim_types.go @@ -0,0 +1,204 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EgressShardClaim is one attachment's standing request for internet egress +// on the node it landed on. It is written by the controller that owns the +// cell and read by the node. +// +// The claim is the contract between the two. The cell controller creates one +// when an attachment's network declares egress and the attachment has reported +// its node, and deletes it when the declaration is withdrawn or the attachment +// goes. The node's installer lists the claims naming it on every sweep and +// keeps each VRF's egress route in step: a VRF with a claim routes toward the +// node's shard, a VRF without one does not. That is what lets egress be turned +// on or off for a running workload without re-attaching it. +// +// The claim also records the binding. Status names the shard on the node, so +// the answer to "which shard does this attachment leave through" is readable, +// and a node without a usable shard produces a condition a consumer can see. +// +// There is one claim per attachment, owned by it, so an attachment that goes +// takes its claim with it. The claim names no selector, no address and no +// pool: the node is the binding, and the claim writes it down. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=egressclaim +// +kubebuilder:printcolumn:name="ATTACHMENT",type="string",JSONPath=".spec.attachment.name" +// +kubebuilder:printcolumn:name="VPC",type="string",JSONPath=".spec.vpc.name" +// +kubebuilder:printcolumn:name="NODE",type="string",JSONPath=".spec.nodeName" +// +kubebuilder:printcolumn:name="SHARD",type="string",JSONPath=".status.shardRef.name" +// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="REASON",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].reason` +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type EgressShardClaim struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +required + Spec EgressShardClaimSpec `json:"spec"` + + // +optional + Status EgressShardClaimStatus `json:"status,omitempty"` +} + +// Label keys carried by an EgressShardClaim so that the two readers who list +// claims can narrow the query server-side. A label restates a fact the spec or +// status already holds; the spec and status are what a reader trusts. +const ( + // LabelEgressShardClaimNode restates spec.nodeName. A node's installer + // lists the claims carrying its own name to learn which of its VRFs + // declare egress. + LabelEgressShardClaimNode string = "network.datumapis.com/egress-node" + + // LabelEgressShardClaimShard names the shard the claim is bound to, the + // value being the shard's name. A shard holds no list of the attachments + // it serves, so "what does this shard serve" is answered by listing the + // claims carrying this label. + LabelEgressShardClaimShard string = "network.datumapis.com/egress-shard" +) + +// EgressShardClaimSpec is the attachment, the VRF, the node and the families +// one claim stands for. +// +// The whole spec is immutable. An attachment that lands on a different node +// is a different request, so the claim is replaced rather than edited. +// +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable; an attachment that moved nodes gets a new claim" +type EgressShardClaimSpec struct { + // Attachment is the attachment this claim stands for. It is in the + // claim's own namespace and owns the claim. + // +required + Attachment EgressShardClaimAttachmentRef `json:"attachment"` + + // VPC is the VPC the attachment is on. The node derives the VRF it + // programs from this name, the same way it does at attach time, so the + // node needs nothing else to find the routing table this claim governs. + // +required + VPC EgressShardClaimVPCRef `json:"vpc"` + + // NodeName is the node the attachment landed on, and therefore the node + // whose shard serves it and whose installer acts on this claim. + // +kubebuilder:validation:MinLength=1 + // +required + NodeName string `json:"nodeName"` + + // Families are the destination address families the attachment's network + // declared, so the shard on the node is one that translates them. + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=2 + // +required + Families []EgressAddressFamily `json:"families"` +} + +// EgressShardClaimAttachmentRef names the attachment a claim stands for. +type EgressShardClaimAttachmentRef struct { + // Name of the attachment, in the claim's namespace. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` +} + +// EgressShardClaimVPCRef names the VPC an attachment is on. +type EgressShardClaimVPCRef struct { + // Name of the VPC, in the claim's namespace. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` +} + +// EgressAddressFamily is a destination address family an egress shard +// translates toward. +// +// +kubebuilder:validation:Enum=IPv6;IPv4 +type EgressAddressFamily string + +const ( + // EgressAddressFamilyIPv6 is reached by NAT66 through the shard's IPv6 + // address. + EgressAddressFamilyIPv6 EgressAddressFamily = "IPv6" + + // EgressAddressFamilyIPv4 is reached by NAT64 through the shard's IPv4 + // address. + EgressAddressFamilyIPv4 EgressAddressFamily = "IPv4" +) + +// EgressShardClaimShardRef names the shard a claim is bound to. +type EgressShardClaimShardRef struct { + // Namespace of the EgressShard. + // +kubebuilder:validation:MinLength=1 + // +required + Namespace string `json:"namespace"` + + // Name of the EgressShard. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` +} + +// EgressShardClaimStatus is the shard a claim was bound to. +type EgressShardClaimStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ShardRef is the shard on the attachment's node. + // + // Absent means the node holds no shard this claim can bind to, which is + // what an attachment on a node an operator has not commissioned reads. + // +optional + ShardRef *EgressShardClaimShardRef `json:"shardRef,omitempty"` +} + +// Reasons reported on an EgressShardClaim's Ready condition. +const ( + // EgressShardClaimReasonBound means this attachment egresses through the + // shard status names. + EgressShardClaimReasonBound string = "Bound" + + // EgressShardClaimReasonNoShardOnNode means no shard names the node the + // attachment landed on. + EgressShardClaimReasonNoShardOnNode string = "NoShardOnNode" + + // EgressShardClaimReasonShardNotReady means the shard on the node has not + // reported the identifier a node routes toward. + EgressShardClaimReasonShardNotReady string = "ShardNotReady" + + // EgressShardClaimReasonShardMismatch means the shard's spec and the + // identity its process reported disagree, so which one the node runs is + // unknown and nothing is bound to it. + EgressShardClaimReasonShardMismatch string = "ShardMismatch" + + // EgressShardClaimReasonFamilyUnsupported means the shard on the node + // translates none of a family the network declared. + EgressShardClaimReasonFamilyUnsupported string = "FamilyUnsupported" + + // EgressShardClaimReasonShardMissing means the bound shard no longer + // exists. The node's instances lost their egress with it. + EgressShardClaimReasonShardMissing string = "ShardMissing" + + // EgressShardClaimReasonShardTerminating means the bound shard is being + // deleted. The binding stands, and the shard is held until the claim goes. + EgressShardClaimReasonShardTerminating string = "ShardTerminating" +) + +// +kubebuilder:object:root=true + +// EgressShardClaimList contains a list of EgressShardClaim. +type EgressShardClaimList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EgressShardClaim `json:"items"` +} + +func init() { + SchemeBuilder.Register(&EgressShardClaim{}, &EgressShardClaimList{}) +} diff --git a/api/v1alpha1/egressshardclaim_types_test.go b/api/v1alpha1/egressshardclaim_types_test.go new file mode 100644 index 0000000..2ca5d85 --- /dev/null +++ b/api/v1alpha1/egressshardclaim_types_test.go @@ -0,0 +1,124 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestEgressShardClaim() *EgressShardClaim { + return &EgressShardClaim{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "network.datumapis.com/v1alpha1", + Kind: "EgressShardClaim", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "web-0-eth1", + Namespace: "tenant-a", + Labels: map[string]string{LabelEgressShardClaimNode: "node-a"}, + }, + Spec: EgressShardClaimSpec{ + Attachment: EgressShardClaimAttachmentRef{Name: "web-0-eth1"}, + VPC: EgressShardClaimVPCRef{Name: "vpc-blue"}, + NodeName: "node-a", + Families: []EgressAddressFamily{EgressAddressFamilyIPv6}, + }, + Status: EgressShardClaimStatus{ + ShardRef: &EgressShardClaimShardRef{Namespace: "galactic-system", Name: "node-a"}, + Conditions: []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: EgressShardClaimReasonBound, + }}, + }, + } +} + +func TestEgressShardClaimDeepCopy(t *testing.T) { + orig := newTestEgressShardClaim() + dup := orig.DeepCopy() + + dup.Spec.Families[0] = EgressAddressFamilyIPv4 + dup.Spec.NodeName = "node-b" + dup.Status.ShardRef.Name = "node-b" + dup.Labels[LabelEgressShardClaimNode] = "node-b" + + if orig.Spec.Families[0] != EgressAddressFamilyIPv6 { + t.Errorf("Families mutated: got %q", orig.Spec.Families[0]) + } + if orig.Spec.NodeName != "node-a" { + t.Errorf("NodeName mutated: got %q", orig.Spec.NodeName) + } + if orig.Status.ShardRef.Name != "node-a" { + t.Errorf("ShardRef mutated: got %q", orig.Status.ShardRef.Name) + } + if orig.Labels[LabelEgressShardClaimNode] != "node-a" { + t.Errorf("Labels mutated: got %q", orig.Labels[LabelEgressShardClaimNode]) + } +} + +func TestEgressShardClaimDeepCopyNil(t *testing.T) { + var c *EgressShardClaim + if c.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +func TestEgressShardClaimJSONRoundTrip(t *testing.T) { + orig := newTestEgressShardClaim() + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got EgressShardClaim + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Spec.Attachment.Name != "web-0-eth1" { + t.Errorf("Attachment.Name = %q", got.Spec.Attachment.Name) + } + if got.Spec.VPC.Name != "vpc-blue" { + t.Errorf("VPC.Name = %q", got.Spec.VPC.Name) + } + if got.Spec.NodeName != "node-a" { + t.Errorf("NodeName = %q", got.Spec.NodeName) + } + if len(got.Spec.Families) != 1 || got.Spec.Families[0] != EgressAddressFamilyIPv6 { + t.Errorf("Families = %v", got.Spec.Families) + } + if got.Status.ShardRef == nil || got.Status.ShardRef.Name != "node-a" { + t.Errorf("ShardRef = %v", got.Status.ShardRef) + } +} + +func TestEgressShardClaimJSONFieldNames(t *testing.T) { + data, err := json.Marshal(newTestEgressShardClaim()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + spec, ok := raw["spec"].(map[string]any) + if !ok { + t.Fatal("spec missing") + } + for _, key := range []string{"attachment", "vpc", "nodeName", "families"} { + if _, present := spec[key]; !present { + t.Errorf("spec.%s missing from wire form", key) + } + } + status, ok := raw["status"].(map[string]any) + if !ok { + t.Fatal("status missing") + } + if _, present := status["shardRef"]; !present { + t.Error("status.shardRef missing from wire form") + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 17eafb4..64368a1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1303,6 +1303,159 @@ func (in *EgressShard) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardClaim) DeepCopyInto(out *EgressShardClaim) { + *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 EgressShardClaim. +func (in *EgressShardClaim) DeepCopy() *EgressShardClaim { + if in == nil { + return nil + } + out := new(EgressShardClaim) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EgressShardClaim) 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 *EgressShardClaimAttachmentRef) DeepCopyInto(out *EgressShardClaimAttachmentRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimAttachmentRef. +func (in *EgressShardClaimAttachmentRef) DeepCopy() *EgressShardClaimAttachmentRef { + if in == nil { + return nil + } + out := new(EgressShardClaimAttachmentRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardClaimList) DeepCopyInto(out *EgressShardClaimList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EgressShardClaim, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimList. +func (in *EgressShardClaimList) DeepCopy() *EgressShardClaimList { + if in == nil { + return nil + } + out := new(EgressShardClaimList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EgressShardClaimList) 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 *EgressShardClaimShardRef) DeepCopyInto(out *EgressShardClaimShardRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimShardRef. +func (in *EgressShardClaimShardRef) DeepCopy() *EgressShardClaimShardRef { + if in == nil { + return nil + } + out := new(EgressShardClaimShardRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardClaimSpec) DeepCopyInto(out *EgressShardClaimSpec) { + *out = *in + out.Attachment = in.Attachment + out.VPC = in.VPC + if in.Families != nil { + in, out := &in.Families, &out.Families + *out = make([]EgressAddressFamily, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimSpec. +func (in *EgressShardClaimSpec) DeepCopy() *EgressShardClaimSpec { + if in == nil { + return nil + } + out := new(EgressShardClaimSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardClaimStatus) DeepCopyInto(out *EgressShardClaimStatus) { + *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]) + } + } + if in.ShardRef != nil { + in, out := &in.ShardRef, &out.ShardRef + *out = new(EgressShardClaimShardRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimStatus. +func (in *EgressShardClaimStatus) DeepCopy() *EgressShardClaimStatus { + if in == nil { + return nil + } + out := new(EgressShardClaimStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardClaimVPCRef) DeepCopyInto(out *EgressShardClaimVPCRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardClaimVPCRef. +func (in *EgressShardClaimVPCRef) DeepCopy() *EgressShardClaimVPCRef { + if in == nil { + return nil + } + out := new(EgressShardClaimVPCRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EgressShardList) DeepCopyInto(out *EgressShardList) { *out = *in diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ea068d4..5f80010 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -7,6 +7,7 @@ resources: - network.datumapis.com_bgppolicies.yaml - network.datumapis.com_bgprouters.yaml - network.datumapis.com_bgpvrfinstances.yaml + - network.datumapis.com_egressshardclaims.yaml - network.datumapis.com_egressshards.yaml - network.datumapis.com_networkegresspolicies.yaml - network.datumapis.com_networkgateways.yaml diff --git a/config/crd/network.datumapis.com_egressshardclaims.yaml b/config/crd/network.datumapis.com_egressshardclaims.yaml new file mode 100644 index 0000000..338507c --- /dev/null +++ b/config/crd/network.datumapis.com_egressshardclaims.yaml @@ -0,0 +1,238 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: egressshardclaims.network.datumapis.com +spec: + group: network.datumapis.com + names: + kind: EgressShardClaim + listKind: EgressShardClaimList + plural: egressshardclaims + shortNames: + - egressclaim + singular: egressshardclaim + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.attachment.name + name: ATTACHMENT + type: string + - jsonPath: .spec.vpc.name + name: VPC + type: string + - jsonPath: .spec.nodeName + name: NODE + type: string + - jsonPath: .status.shardRef.name + name: SHARD + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: READY + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].reason + name: REASON + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + EgressShardClaim is one attachment's standing request for internet egress + on the node it landed on. It is written by the controller that owns the + cell and read by the node. + + The claim is the contract between the two. The cell controller creates one + when an attachment's network declares egress and the attachment has reported + its node, and deletes it when the declaration is withdrawn or the attachment + goes. The node's installer lists the claims naming it on every sweep and + keeps each VRF's egress route in step: a VRF with a claim routes toward the + node's shard, a VRF without one does not. That is what lets egress be turned + on or off for a running workload without re-attaching it. + + The claim also records the binding. Status names the shard on the node, so + the answer to "which shard does this attachment leave through" is readable, + and a node without a usable shard produces a condition a consumer can see. + + There is one claim per attachment, owned by it, so an attachment that goes + takes its claim with it. The claim names no selector, no address and no + pool: the node is the binding, and the claim writes it down. + 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: |- + EgressShardClaimSpec is the attachment, the VRF, the node and the families + one claim stands for. + + The whole spec is immutable. An attachment that lands on a different node + is a different request, so the claim is replaced rather than edited. + properties: + attachment: + description: |- + Attachment is the attachment this claim stands for. It is in the + claim's own namespace and owns the claim. + properties: + name: + description: Name of the attachment, in the claim's namespace. + minLength: 1 + type: string + required: + - name + type: object + families: + description: |- + Families are the destination address families the attachment's network + declared, so the shard on the node is one that translates them. + items: + description: |- + EgressAddressFamily is a destination address family an egress shard + translates toward. + enum: + - IPv6 + - IPv4 + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + nodeName: + description: |- + NodeName is the node the attachment landed on, and therefore the node + whose shard serves it and whose installer acts on this claim. + minLength: 1 + type: string + vpc: + description: |- + VPC is the VPC the attachment is on. The node derives the VRF it + programs from this name, the same way it does at attach time, so the + node needs nothing else to find the routing table this claim governs. + properties: + name: + description: Name of the VPC, in the claim's namespace. + minLength: 1 + type: string + required: + - name + type: object + required: + - attachment + - families + - nodeName + - vpc + type: object + x-kubernetes-validations: + - message: spec is immutable; an attachment that moved nodes gets a new + claim + rule: self == oldSelf + status: + description: EgressShardClaimStatus is the shard a claim was bound to. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + shardRef: + description: |- + ShardRef is the shard on the attachment's node. + + Absent means the node holds no shard this claim can bind to, which is + what an attachment on a node an operator has not commissioned reads. + properties: + name: + description: Name of the EgressShard. + minLength: 1 + type: string + namespace: + description: Namespace of the EgressShard. + minLength: 1 + type: string + required: + - name + - namespace + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/docs/api/bgp.md b/docs/api/bgp.md index 07d24b9..48f72d5 100644 --- a/docs/api/bgp.md +++ b/docs/api/bgp.md @@ -16,6 +16,7 @@ Package v1alpha1 contains API Schema definitions for the network.datumapis.com/v - [BGPRouter](#bgprouter) - [BGPVRFInstance](#bgpvrfinstance) - [EgressShard](#egressshard) +- [EgressShardClaim](#egressshardclaim) - [ServiceVIPBinding](#servicevipbinding) @@ -962,6 +963,25 @@ _Appears in:_ | `iPv6PrefixAdvertisement` | EVPNRouteTypeIPv6PrefixAdvertisement is Type-5: IPv6 Prefix Advertisement route.
| +#### EgressAddressFamily + +_Underlying type:_ _string_ + +EgressAddressFamily is a destination address family an egress shard +translates toward. + +_Validation:_ +- Enum: [IPv6 IPv4] + +_Appears in:_ +- [EgressShardClaimSpec](#egressshardclaimspec) + +| Field | Description | +| --- | --- | +| `IPv6` | EgressAddressFamilyIPv6 is reached by NAT66 through the shard's IPv6
address.
| +| `IPv4` | EgressAddressFamilyIPv4 is reached by NAT64 through the shard's IPv4
address.
| + + #### EgressShard @@ -1019,6 +1039,135 @@ resource that placed traffic on it. | `status` _[EgressShardStatus](#egressshardstatus)_ | | | | +#### EgressShardClaim + + + +EgressShardClaim is one attachment's standing request for internet egress +on the node it landed on. It is written by the controller that owns the +cell and read by the node. + +The claim is the contract between the two. The cell controller creates one +when an attachment's network declares egress and the attachment has reported +its node, and deletes it when the declaration is withdrawn or the attachment +goes. The node's installer lists the claims naming it on every sweep and +keeps each VRF's egress route in step: a VRF with a claim routes toward the +node's shard, a VRF without one does not. That is what lets egress be turned +on or off for a running workload without re-attaching it. + +The claim also records the binding. Status names the shard on the node, so +the answer to "which shard does this attachment leave through" is readable, +and a node without a usable shard produces a condition a consumer can see. + +There is one claim per attachment, owned by it, so an attachment that goes +takes its claim with it. The claim names no selector, no address and no +pool: the node is the binding, and the claim writes it down. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `network.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `EgressShardClaim` | | | +| `kind` _string_ | 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 | | | +| `apiVersion` _string_ | 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 | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[EgressShardClaimSpec](#egressshardclaimspec)_ | | | | +| `status` _[EgressShardClaimStatus](#egressshardclaimstatus)_ | | | | + + +#### EgressShardClaimAttachmentRef + + + +EgressShardClaimAttachmentRef names the attachment a claim stands for. + + + +_Appears in:_ +- [EgressShardClaimSpec](#egressshardclaimspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the attachment, in the claim's namespace. | | MinLength: 1
| + + +#### EgressShardClaimShardRef + + + +EgressShardClaimShardRef names the shard a claim is bound to. + + + +_Appears in:_ +- [EgressShardClaimStatus](#egressshardclaimstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `namespace` _string_ | Namespace of the EgressShard. | | MinLength: 1
| +| `name` _string_ | Name of the EgressShard. | | MinLength: 1
| + + +#### EgressShardClaimSpec + + + +EgressShardClaimSpec is the attachment, the VRF, the node and the families +one claim stands for. + +The whole spec is immutable. An attachment that lands on a different node +is a different request, so the claim is replaced rather than edited. + + + +_Appears in:_ +- [EgressShardClaim](#egressshardclaim) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `attachment` _[EgressShardClaimAttachmentRef](#egressshardclaimattachmentref)_ | Attachment is the attachment this claim stands for. It is in the
claim's own namespace and owns the claim. | | | +| `vpc` _[EgressShardClaimVPCRef](#egressshardclaimvpcref)_ | VPC is the VPC the attachment is on. The node derives the VRF it
programs from this name, the same way it does at attach time, so the
node needs nothing else to find the routing table this claim governs. | | | +| `nodeName` _string_ | NodeName is the node the attachment landed on, and therefore the node
whose shard serves it and whose installer acts on this claim. | | MinLength: 1
| +| `families` _[EgressAddressFamily](#egressaddressfamily) array_ | Families are the destination address families the attachment's network
declared, so the shard on the node is one that translates them. | | Enum: [IPv6 IPv4]
MaxItems: 2
MinItems: 1
| + + +#### EgressShardClaimStatus + + + +EgressShardClaimStatus is the shard a claim was bound to. + + + +_Appears in:_ +- [EgressShardClaim](#egressshardclaim) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | | | | +| `shardRef` _[EgressShardClaimShardRef](#egressshardclaimshardref)_ | ShardRef is the shard on the attachment's node.
Absent means the node holds no shard this claim can bind to, which is
what an attachment on a node an operator has not commissioned reads. | | | + + +#### EgressShardClaimVPCRef + + + +EgressShardClaimVPCRef names the VPC an attachment is on. + + + +_Appears in:_ +- [EgressShardClaimSpec](#egressshardclaimspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the VPC, in the claim's namespace. | | MinLength: 1
| + + #### EgressShardSpec diff --git a/docs/api/gateway.md b/docs/api/gateway.md index cff36ee..1646b98 100644 --- a/docs/api/gateway.md +++ b/docs/api/gateway.md @@ -10,6 +10,7 @@ Package v1alpha1 contains API Schema definitions for the network.datumapis.com/v ### Resource Types - [EgressShard](#egressshard) +- [EgressShardClaim](#egressshardclaim) - [NetworkEgressPolicy](#networkegresspolicy) - [NetworkGateway](#networkgateway) - [NetworkRule](#networkrule) @@ -120,6 +121,134 @@ resource that placed traffic on it. | `status` _[EgressShardStatus](#egressshardstatus)_ | | | | +#### EgressShardClaim + + + +EgressShardClaim is one attachment's standing request for internet egress +on the node it landed on. It is written by the controller that owns the +cell and read by the node. + +The claim is the contract between the two. The cell controller creates one +when an attachment's network declares egress and the attachment has reported +its node, and deletes it when the declaration is withdrawn or the attachment +goes. The node's installer lists the claims naming it on every sweep and +keeps each VRF's egress route in step: a VRF with a claim routes toward the +node's shard, a VRF without one does not. That is what lets egress be turned +on or off for a running workload without re-attaching it. + +The claim also records the binding. Status names the shard on the node, so +the answer to "which shard does this attachment leave through" is readable, +and a node without a usable shard produces a condition a consumer can see. + +There is one claim per attachment, owned by it, so an attachment that goes +takes its claim with it. The claim names no selector, no address and no +pool: the node is the binding, and the claim writes it down. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `network.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `EgressShardClaim` | | | +| `kind` _string_ | 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 | | | +| `apiVersion` _string_ | 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 | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[EgressShardClaimSpec](#egressshardclaimspec)_ | | | | +| `status` _[EgressShardClaimStatus](#egressshardclaimstatus)_ | | | | + + +#### EgressShardClaimAttachmentRef + + + +EgressShardClaimAttachmentRef names the attachment a claim stands for. + + + +_Appears in:_ +- [EgressShardClaimSpec](#egressshardclaimspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the attachment, in the claim's namespace. | | MinLength: 1
| + + +#### EgressShardClaimShardRef + + + +EgressShardClaimShardRef names the shard a claim is bound to. + + + +_Appears in:_ +- [EgressShardClaimStatus](#egressshardclaimstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `namespace` _string_ | Namespace of the EgressShard. | | MinLength: 1
| +| `name` _string_ | Name of the EgressShard. | | MinLength: 1
| + + +#### EgressShardClaimSpec + + + +EgressShardClaimSpec is the attachment, the VRF, the node and the families +one claim stands for. + +The whole spec is immutable. An attachment that lands on a different node +is a different request, so the claim is replaced rather than edited. + + + +_Appears in:_ +- [EgressShardClaim](#egressshardclaim) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `attachment` _[EgressShardClaimAttachmentRef](#egressshardclaimattachmentref)_ | Attachment is the attachment this claim stands for. It is in the
claim's own namespace and owns the claim. | | | +| `vpc` _[EgressShardClaimVPCRef](#egressshardclaimvpcref)_ | VPC is the VPC the attachment is on. The node derives the VRF it
programs from this name, the same way it does at attach time, so the
node needs nothing else to find the routing table this claim governs. | | | +| `nodeName` _string_ | NodeName is the node the attachment landed on, and therefore the node
whose shard serves it and whose installer acts on this claim. | | MinLength: 1
| + + +#### EgressShardClaimStatus + + + +EgressShardClaimStatus is the shard a claim was bound to. + + + +_Appears in:_ +- [EgressShardClaim](#egressshardclaim) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | | | | +| `shardRef` _[EgressShardClaimShardRef](#egressshardclaimshardref)_ | ShardRef is the shard on the attachment's node.
Absent means the node holds no shard this claim can bind to, which is
what an attachment on a node an operator has not commissioned reads. | | | + + +#### EgressShardClaimVPCRef + + + +EgressShardClaimVPCRef names the VPC an attachment is on. + + + +_Appears in:_ +- [EgressShardClaimSpec](#egressshardclaimspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the VPC, in the claim's namespace. | | MinLength: 1
| + + #### EgressShardSpec diff --git a/test/e2e/Taskfile.yaml b/test/e2e/Taskfile.yaml index 2c057fd..c10d4b8 100644 --- a/test/e2e/Taskfile.yaml +++ b/test/e2e/Taskfile.yaml @@ -92,6 +92,7 @@ tasks: crd/bgppeers.network.datumapis.com crd/bgpvrfinstances.network.datumapis.com crd/egressshards.network.datumapis.com + crd/egressshardclaims.network.datumapis.com --timeout=60s deploy-bgp-routers: diff --git a/test/e2e/tests/egress-shard-claim-crd-schema/chainsaw-test.yaml b/test/e2e/tests/egress-shard-claim-crd-schema/chainsaw-test.yaml new file mode 100644 index 0000000..aa68db5 --- /dev/null +++ b/test/e2e/tests/egress-shard-claim-crd-schema/chainsaw-test.yaml @@ -0,0 +1,159 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: egress-shard-claim-crd-schema +spec: + description: > + Verify the rules the API server alone enforces on an EgressShardClaim: a + claim needs its attachment, VPC, node and at least one family; families + are limited to the ones a shard translates; the whole spec is immutable + once written, so an attachment that moves nodes gets a new claim rather + than an edited one; and status stays writable so a binder can record the + shard. Does not require any operator to be running. + steps: + - name: accept-a-complete-claim + try: + - apply: + resource: + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim + labels: + network.datumapis.com/egress-node: bgp-e2e-worker + spec: + attachment: + name: e2e-attachment + vpc: + name: e2e-vpc + nodeName: bgp-e2e-worker + families: [IPv6] + - assert: + resource: + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim + spec: + nodeName: bgp-e2e-worker + (status.shardRef == null): true + + - name: reject-a-claim-missing-its-vpc + try: + - script: + content: | + set -e + if OUTPUT=$(kubectl apply -n "$NAMESPACE" -f - 2>&1 <<'EOF' + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim-no-vpc + spec: + attachment: + name: e2e-attachment + nodeName: bgp-e2e-worker + families: [IPv6] + EOF + ); then + echo "FAIL: a claim naming no VPC was accepted" + exit 1 + fi + echo "$OUTPUT" | grep -qi "vpc" || { echo "FAIL: unexpected error: $OUTPUT"; exit 1; } + echo "OK: a claim naming no VPC is rejected" + + - name: reject-a-claim-with-no-family + try: + - script: + content: | + set -e + if OUTPUT=$(kubectl apply -n "$NAMESPACE" -f - 2>&1 <<'EOF' + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim-no-family + spec: + attachment: + name: e2e-attachment + vpc: + name: e2e-vpc + nodeName: bgp-e2e-worker + families: [] + EOF + ); then + echo "FAIL: a claim with no family was accepted" + exit 1 + fi + echo "$OUTPUT" | grep -qi "families" || { echo "FAIL: unexpected error: $OUTPUT"; exit 1; } + echo "OK: a claim with no family is rejected" + + - name: reject-a-family-no-shard-translates + try: + - script: + content: | + set -e + if OUTPUT=$(kubectl apply -n "$NAMESPACE" -f - 2>&1 <<'EOF' + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim-bad-family + spec: + attachment: + name: e2e-attachment + vpc: + name: e2e-vpc + nodeName: bgp-e2e-worker + families: [IPX] + EOF + ); then + echo "FAIL: an unknown family was accepted" + exit 1 + fi + echo "$OUTPUT" | grep -qi "families" || { echo "FAIL: unexpected error: $OUTPUT"; exit 1; } + echo "OK: an unknown family is rejected" + + - name: reject-moving-a-claim-to-another-node + try: + - script: + content: | + set -e + if OUTPUT=$(kubectl patch egressshardclaim e2e-claim -n "$NAMESPACE" --type=merge -p ' + {"spec":{"nodeName":"bgp-e2e-worker2"}}' 2>&1); then + echo "FAIL: the spec was edited" + exit 1 + fi + echo "$OUTPUT" | grep -q "spec is immutable" || { echo "FAIL: unexpected error: $OUTPUT"; exit 1; } + echo "OK: the spec is immutable" + + - name: reject-adding-a-family-to-a-claim + try: + - script: + content: | + set -e + if OUTPUT=$(kubectl patch egressshardclaim e2e-claim -n "$NAMESPACE" --type=merge -p ' + {"spec":{"families":["IPv6","IPv4"]}}' 2>&1); then + echo "FAIL: the families were edited" + exit 1 + fi + echo "$OUTPUT" | grep -q "spec is immutable" || { echo "FAIL: unexpected error: $OUTPUT"; exit 1; } + echo "OK: the families are immutable" + + - name: status-stays-writable + try: + - script: + content: | + set -e + kubectl patch egressshardclaim e2e-claim -n "$NAMESPACE" --subresource=status --type=merge -p ' + {"status":{"shardRef":{"namespace":"galactic-system","name":"bgp-e2e-worker"}, + "conditions":[{"type":"Ready","status":"True","reason":"Bound","message":"bound","lastTransitionTime":"2026-01-01T00:00:00Z"}]}}' + echo "OK: status written" + - assert: + resource: + apiVersion: network.datumapis.com/v1alpha1 + kind: EgressShardClaim + metadata: + name: e2e-claim + status: + shardRef: + namespace: galactic-system + name: bgp-e2e-worker + (conditions[?type == 'Ready'].reason | [0]): Bound