diff --git a/.crd-ref-docs.yaml b/.crd-ref-docs.yaml index 2056c5d..6d75485 100644 --- a/.crd-ref-docs.yaml +++ b/.crd-ref-docs.yaml @@ -10,6 +10,9 @@ processor: - "NetworkRuleProtocol" - "NetworkRuleSpec" - "NetworkRuleStatus" + - "NetworkEgressPolicy" + - "NetworkEgressPolicySpec" + - "NetworkEgressPolicyStatus" ignoreFields: [] render: diff --git a/api/v1alpha1/egresspolicy_types.go b/api/v1alpha1/egresspolicy_types.go new file mode 100644 index 0000000..32803f3 --- /dev/null +++ b/api/v1alpha1/egresspolicy_types.go @@ -0,0 +1,113 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkEgressPolicy enables internet egress for a single tenant +// VPC/VPCAttachment, served by the shared hyperconverged gateway engine's +// masquerade (SNAT/PAT) datapath. Unlike NetworkRule, it carries no +// VIP/backend/port: egress is on or off for a (vpcRef, vpcAttachmentRef) +// pair, existence-implies-enabled, not a per-flow rule — because the +// destination of an egress flow is an arbitrary internet address, not a +// pre-configured backend list. +// +// It is namespaced (deployed to galactic-system) and tenant-writable; like +// NetworkRule, vpcRef/vpcAttachmentRef are opaque string identifiers because +// the VPC API is owned by a separate companion operator, not this repo. An +// admission webhook (implemented by the consuming controller) must verify +// the requester is authorized for vpcRef/vpcAttachmentRef before a policy is +// accepted — see the Accepted condition. +// +// Presence of an accepted NetworkEgressPolicy resolves only *enablement* +// (should this tenant reach the egress datapath at all) — a routing-layer +// decision (does the tenant's VRF have a default route toward the shared +// egress_sid locator), not a per-packet datapath lookup. *Isolation* +// (preventing two tenants with colliding ULA source addresses from +// colliding in the egress connection table) is a separate, datapath-level +// concern resolved by tagging each flow with the tenant/VRF identifier +// carried in the egress_sid locator's own Argument bits, not by anything in +// this spec. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=netegress +// +kubebuilder:printcolumn:name="VPC",type="string",JSONPath=".spec.vpcRef" +// +kubebuilder:printcolumn:name="VPC-ATTACHMENT",type="string",JSONPath=".spec.vpcAttachmentRef" +// +kubebuilder:printcolumn:name="ASSIGNED-NODE",type="string",JSONPath=".status.assignedGatewayNode" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type NetworkEgressPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NetworkEgressPolicySpec `json:"spec,omitempty"` + Status NetworkEgressPolicyStatus `json:"status,omitempty"` +} + +// NetworkEgressPolicySpec defines the desired egress-enablement state for a +// tenant VPC/VPCAttachment. +type NetworkEgressPolicySpec struct { + // VPCRef is the opaque identifier of the target VPC this policy applies + // to. This repo does not own the VPC API and does not validate the + // identifier beyond non-emptiness; the admission webhook of the + // consuming controller is responsible for verifying the requester is + // authorized for this VPC before the policy is accepted. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VPCRef string `json:"vpcRef"` + + // VPCAttachmentRef is the opaque identifier of the target + // VPCAttachment this policy applies to. Like VPCRef, this is an opaque + // string reference validated by the admission webhook, not by this API. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VPCAttachmentRef string `json:"vpcAttachmentRef"` +} + +// NetworkEgressPolicyStatus defines the observed state of a +// NetworkEgressPolicy. +type NetworkEgressPolicyStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // AssignedGatewayNode is the name of the NetworkGateway-backed gateway + // node this policy's tenant should route egress traffic through, + // mirroring NetworkRule's own status.primaryNode field and computed + // the same way: assigned_node = hash(vpcRef) % + // (design plan §4.5 — a tenant's egress node and its primary ingress + // node are the same node, by design, so both fields are computed by + // the identical AssignPrimaryNode function). The controller consuming + // this CRD sets this field exactly once, at creation. + // + // This value must never be silently recomputed by a reconciler once + // set, for the exact same reason NetworkRuleStatus.PrimaryNode's own + // doc comment gives: recomputing it on a later reconcile can flip + // which node a tenant's egress traffic routes through and cause an + // avoidable traffic flap; a reconciler that observes a stale or + // removed node here must surface that via a condition instead of + // overwriting the value. + // +optional + AssignedGatewayNode string `json:"assignedGatewayNode,omitempty"` + + // Conditions contains the standard conditions for this resource, + // including Accepted (see AcceptedReasonOwnershipVerified / + // AcceptedReasonOwnershipDenied in rule_types.go, reused as-is here). + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// NetworkEgressPolicyList is a list of NetworkEgressPolicy resources. +// +kubebuilder:object:root=true +type NetworkEgressPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NetworkEgressPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&NetworkEgressPolicy{}, &NetworkEgressPolicyList{}) +} diff --git a/api/v1alpha1/egresspolicy_types_test.go b/api/v1alpha1/egresspolicy_types_test.go new file mode 100644 index 0000000..5f45b69 --- /dev/null +++ b/api/v1alpha1/egresspolicy_types_test.go @@ -0,0 +1,231 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestEgressPolicy() *NetworkEgressPolicy { + return &NetworkEgressPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "network.datumapis.com/v1alpha1", + Kind: "NetworkEgressPolicy", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-egress-policy", Namespace: "galactic-system"}, + Spec: NetworkEgressPolicySpec{ + VPCRef: "vpc-a", + VPCAttachmentRef: "vpcattachment-a", + }, + } +} + +// TestNetworkEgressPolicyDeepCopy verifies that DeepCopy produces an +// independent copy: mutations to the copy must not affect the original. +func TestNetworkEgressPolicyDeepCopy(t *testing.T) { + orig := newTestEgressPolicy() + dup := orig.DeepCopy() + + dup.Spec.VPCRef = "vpc-b" + dup.Spec.VPCAttachmentRef = "vpcattachment-b" + dup.Status.Conditions = append(dup.Status.Conditions, metav1.Condition{Type: ConditionTypeAccepted}) + + if orig.Spec.VPCRef != "vpc-a" { + t.Errorf("VPCRef mutated: got %q", orig.Spec.VPCRef) + } + if orig.Spec.VPCAttachmentRef != "vpcattachment-a" { + t.Errorf("VPCAttachmentRef mutated: got %q", orig.Spec.VPCAttachmentRef) + } + if len(orig.Status.Conditions) != 0 { + t.Errorf("Conditions mutated: got %v", orig.Status.Conditions) + } +} + +// TestNetworkEgressPolicyDeepCopyNil verifies DeepCopy on a nil pointer +// returns nil. +func TestNetworkEgressPolicyDeepCopyNil(t *testing.T) { + var p *NetworkEgressPolicy + if p.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestNetworkEgressPolicyJSONRoundTrip verifies that the struct serialises +// and deserialises through JSON without data loss. +func TestNetworkEgressPolicyJSONRoundTrip(t *testing.T) { + orig := newTestEgressPolicy() + orig.Status.Conditions = []metav1.Condition{ + {Type: ConditionTypeAccepted, Status: metav1.ConditionTrue, Reason: AcceptedReasonOwnershipVerified}, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got NetworkEgressPolicy + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.VPCRef != orig.Spec.VPCRef { + t.Errorf("VPCRef: got %q, want %q", got.Spec.VPCRef, orig.Spec.VPCRef) + } + if got.Spec.VPCAttachmentRef != orig.Spec.VPCAttachmentRef { + t.Errorf("VPCAttachmentRef: got %q, want %q", got.Spec.VPCAttachmentRef, orig.Spec.VPCAttachmentRef) + } + if len(got.Status.Conditions) != 1 || got.Status.Conditions[0].Reason != AcceptedReasonOwnershipVerified { + t.Errorf("Conditions: got %v", got.Status.Conditions) + } +} + +// TestNetworkEgressPolicyListDeepCopy verifies that +// NetworkEgressPolicyList.DeepCopy produces independent copies of each item. +func TestNetworkEgressPolicyListDeepCopy(t *testing.T) { + list := &NetworkEgressPolicyList{ + Items: []NetworkEgressPolicy{*newTestEgressPolicy()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.VPCRef = "vpc-b" + + if list.Items[0].Spec.VPCRef != "vpc-a" { + t.Errorf("original list item mutated via copy") + } +} + +// TestNetworkEgressPolicyFieldNames verifies the JSON keys for spec fields +// match the CRD schema ("vpcRef", "vpcAttachmentRef") and that no +// backend/port/VIP fields exist — egress enablement is on/off for a +// (vpcRef, vpcAttachmentRef) pair, not a per-flow rule. +func TestNetworkEgressPolicyFieldNames(t *testing.T) { + orig := newTestEgressPolicy() + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + spec, ok := m["spec"].(map[string]any) + if !ok { + t.Fatalf("spec not found or wrong type: %v", m["spec"]) + } + if v, ok := spec["vpcRef"]; !ok || v != "vpc-a" { + t.Errorf("expected spec.vpcRef=%q, got %v", "vpc-a", spec["vpcRef"]) + } + if v, ok := spec["vpcAttachmentRef"]; !ok || v != "vpcattachment-a" { + t.Errorf("expected spec.vpcAttachmentRef=%q, got %v", "vpcattachment-a", spec["vpcAttachmentRef"]) + } + for _, unexpected := range []string{"vipAddresses", "protocol", "port", "backends"} { + if _, ok := spec[unexpected]; ok { + t.Errorf("unexpected field %q present in spec: %v", unexpected, spec) + } + } +} + +// TestNetworkGatewayEgressAddressFieldName verifies NetworkGatewayStatus's +// new EgressAddress field round-trips under the JSON key "egressAddress" +// and stays independently settable from SRv6Address (a gateway node may +// have one, both, or neither populated). +func TestNetworkGatewayEgressAddressFieldName(t *testing.T) { + gw := newTestGateway() + gw.Status.EgressAddress = "2001:db8:ffff::1" + + data, err := json.Marshal(gw) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + status, ok := m["status"].(map[string]any) + if !ok { + t.Fatalf("status not found or wrong type: %v", m["status"]) + } + if v, ok := status["egressAddress"]; !ok || v != "2001:db8:ffff::1" { + t.Errorf("expected status.egressAddress=%q, got %v", "2001:db8:ffff::1", status["egressAddress"]) + } + if v, ok := status["sRv6Address"]; !ok || v != "2001:db8:1::1" { + t.Errorf("expected status.sRv6Address unaffected, got %v", v) + } +} + +// TestNetworkGatewayEgressSIDFieldName verifies NetworkGatewayStatus's new +// EgressSID field round-trips under the JSON key "egressSID" (design plan +// §3.1/§4.3) and stays independently settable from SRv6Address/ +// EgressAddress. +func TestNetworkGatewayEgressSIDFieldName(t *testing.T) { + gw := newTestGateway() + gw.Status.EgressAddress = "2001:db8:ffff::1" + gw.Status.EgressSID = "2001:db8:eeee::1" + + data, err := json.Marshal(gw) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + status, ok := m["status"].(map[string]any) + if !ok { + t.Fatalf("status not found or wrong type: %v", m["status"]) + } + if v, ok := status["egressSID"]; !ok || v != "2001:db8:eeee::1" { + t.Errorf("expected status.egressSID=%q, got %v", "2001:db8:eeee::1", status["egressSID"]) + } + if v, ok := status["egressAddress"]; !ok || v != "2001:db8:ffff::1" { + t.Errorf("expected status.egressAddress unaffected, got %v", v) + } +} + +// TestNetworkEgressPolicyAssignedGatewayNodeFieldName verifies the new +// AssignedGatewayNode field round-trips under the JSON key +// "assignedGatewayNode" (design plan §4.5). +func TestNetworkEgressPolicyAssignedGatewayNodeFieldName(t *testing.T) { + orig := newTestEgressPolicy() + orig.Status.AssignedGatewayNode = "gw-node-a" + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + status, ok := m["status"].(map[string]any) + if !ok { + t.Fatalf("status not found or wrong type: %v", m["status"]) + } + if v, ok := status["assignedGatewayNode"]; !ok || v != "gw-node-a" { + t.Errorf("expected status.assignedGatewayNode=%q, got %v", "gw-node-a", status["assignedGatewayNode"]) + } +} + +// TestNetworkEgressPolicyDeepCopyIncludesAssignedGatewayNode extends +// TestNetworkEgressPolicyDeepCopy to cover the new field: mutating the copy +// must not affect the original. +func TestNetworkEgressPolicyDeepCopyIncludesAssignedGatewayNode(t *testing.T) { + orig := newTestEgressPolicy() + orig.Status.AssignedGatewayNode = "gw-node-a" + + dup := orig.DeepCopy() + dup.Status.AssignedGatewayNode = "gw-node-b" + + if orig.Status.AssignedGatewayNode != "gw-node-a" { + t.Errorf("AssignedGatewayNode mutated: got %q", orig.Status.AssignedGatewayNode) + } +} diff --git a/api/v1alpha1/gateway_types.go b/api/v1alpha1/gateway_types.go index ca95e21..4642ba7 100644 --- a/api/v1alpha1/gateway_types.go +++ b/api/v1alpha1/gateway_types.go @@ -29,6 +29,8 @@ import ( // +kubebuilder:resource:scope=Namespaced,shortName=netgw // +kubebuilder:printcolumn:name="TARGET",type="string",JSONPath=".spec.targetRef.name" // +kubebuilder:printcolumn:name="SRV6-ADDRESS",type="string",JSONPath=".status.sRv6Address" +// +kubebuilder:printcolumn:name="EGRESS-ADDRESS",type="string",JSONPath=".status.egressAddress" +// +kubebuilder:printcolumn:name="EGRESS-SID",type="string",JSONPath=".status.egressSID" // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" type NetworkGateway struct { metav1.TypeMeta `json:",inline"` @@ -65,6 +67,35 @@ type NetworkGatewayStatus struct { // +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="sRv6Address must be a valid IPv6 address" SRv6Address string `json:"sRv6Address,omitempty"` + // EgressAddress is this gateway node's own publicly-routable IPv6 + // address, used as the masquerade SNAT source for every egress flow + // this node translates on behalf of tenant VPC backends reaching the + // internet. Unlike SRv6Address (reachable only within the SRv6 fabric), + // this address must additionally be reachable from the public internet + // — an eBGP/uplink-peering concern outside this API. Operator-supplied + // via GALACTIC_GATEWAY_EGRESS_ADDRESS; there is no in-cluster + // derivation mechanism yet, the same gap SRv6Address itself has today. + // A gateway node not offering egress leaves this field empty. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="egressAddress must be a valid IPv6 address" + EgressAddress string `json:"egressAddress,omitempty"` + + // EgressSID is this gateway node's own egress_sid uSID *locator* + // (design plan §3.1) — the reserved Argument range's Block+Node-ID + // portion tenant VRF default routes encapsulate toward. Unlike + // EgressAddress (a plain, publicly-routable address, no uSID + // structure), this is a real uSID: other nodes need a kernel route to + // it before they can install a SEG6 encap route naming it as the + // destination (the same reason SRv6Address is advertised into BGP), + // so this is published and advertised the same way SRv6Address/ + // EgressAddress already are. Operator-supplied via + // GALACTIC_GATEWAY_EGRESS_SID; a gateway node not offering egress + // leaves this field empty, always paired with EgressAddress (both + // set, or neither). + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="egressSID must be a valid IPv6 address" + EgressSID string `json:"egressSID,omitempty"` + // Conditions contains the standard conditions for this resource. // // +listType=map diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 525e865..402f5b3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1306,6 +1306,102 @@ func (in *LocalSecretRef) DeepCopy() *LocalSecretRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkEgressPolicy) DeepCopyInto(out *NetworkEgressPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkEgressPolicy. +func (in *NetworkEgressPolicy) DeepCopy() *NetworkEgressPolicy { + if in == nil { + return nil + } + out := new(NetworkEgressPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkEgressPolicy) 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 *NetworkEgressPolicyList) DeepCopyInto(out *NetworkEgressPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkEgressPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkEgressPolicyList. +func (in *NetworkEgressPolicyList) DeepCopy() *NetworkEgressPolicyList { + if in == nil { + return nil + } + out := new(NetworkEgressPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkEgressPolicyList) 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 *NetworkEgressPolicySpec) DeepCopyInto(out *NetworkEgressPolicySpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkEgressPolicySpec. +func (in *NetworkEgressPolicySpec) DeepCopy() *NetworkEgressPolicySpec { + if in == nil { + return nil + } + out := new(NetworkEgressPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkEgressPolicyStatus) DeepCopyInto(out *NetworkEgressPolicyStatus) { + *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 NetworkEgressPolicyStatus. +func (in *NetworkEgressPolicyStatus) DeepCopy() *NetworkEgressPolicyStatus { + if in == nil { + return nil + } + out := new(NetworkEgressPolicyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkGateway) DeepCopyInto(out *NetworkGateway) { *out = *in diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 8580f1d..00a8e0b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -7,3 +7,4 @@ resources: - network.datumapis.com_bgppolicies.yaml - network.datumapis.com_bgprouters.yaml - network.datumapis.com_bgpvrfinstances.yaml + - network.datumapis.com_networkegresspolicies.yaml diff --git a/config/crd/network.datumapis.com_networkegresspolicies.yaml b/config/crd/network.datumapis.com_networkegresspolicies.yaml new file mode 100644 index 0000000..dffef23 --- /dev/null +++ b/config/crd/network.datumapis.com_networkegresspolicies.yaml @@ -0,0 +1,200 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: networkegresspolicies.network.datumapis.com +spec: + group: network.datumapis.com + names: + kind: NetworkEgressPolicy + listKind: NetworkEgressPolicyList + plural: networkegresspolicies + shortNames: + - netegress + singular: networkegresspolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.vpcRef + name: VPC + type: string + - jsonPath: .spec.vpcAttachmentRef + name: VPC-ATTACHMENT + type: string + - jsonPath: .status.assignedGatewayNode + name: ASSIGNED-NODE + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NetworkEgressPolicy enables internet egress for a single tenant + VPC/VPCAttachment, served by the shared hyperconverged gateway engine's + masquerade (SNAT/PAT) datapath. Unlike NetworkRule, it carries no + VIP/backend/port: egress is on or off for a (vpcRef, vpcAttachmentRef) + pair, existence-implies-enabled, not a per-flow rule — because the + destination of an egress flow is an arbitrary internet address, not a + pre-configured backend list. + + It is namespaced (deployed to galactic-system) and tenant-writable; like + NetworkRule, vpcRef/vpcAttachmentRef are opaque string identifiers because + the VPC API is owned by a separate companion operator, not this repo. An + admission webhook (implemented by the consuming controller) must verify + the requester is authorized for vpcRef/vpcAttachmentRef before a policy is + accepted — see the Accepted condition. + + Presence of an accepted NetworkEgressPolicy resolves only *enablement* + (should this tenant reach the egress datapath at all) — a routing-layer + decision (does the tenant's VRF have a default route toward the shared + egress_sid locator), not a per-packet datapath lookup. *Isolation* + (preventing two tenants with colliding ULA source addresses from + colliding in the egress connection table) is a separate, datapath-level + concern resolved by tagging each flow with the tenant/VRF identifier + carried in the egress_sid locator's own Argument bits, not by anything in + this spec. + 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: |- + NetworkEgressPolicySpec defines the desired egress-enablement state for a + tenant VPC/VPCAttachment. + properties: + vpcAttachmentRef: + description: |- + VPCAttachmentRef is the opaque identifier of the target + VPCAttachment this policy applies to. Like VPCRef, this is an opaque + string reference validated by the admission webhook, not by this API. + minLength: 1 + type: string + vpcRef: + description: |- + VPCRef is the opaque identifier of the target VPC this policy applies + to. This repo does not own the VPC API and does not validate the + identifier beyond non-emptiness; the admission webhook of the + consuming controller is responsible for verifying the requester is + authorized for this VPC before the policy is accepted. + minLength: 1 + type: string + required: + - vpcAttachmentRef + - vpcRef + type: object + status: + description: |- + NetworkEgressPolicyStatus defines the observed state of a + NetworkEgressPolicy. + properties: + assignedGatewayNode: + description: |- + AssignedGatewayNode is the name of the NetworkGateway-backed gateway + node this policy's tenant should route egress traffic through, + mirroring NetworkRule's own status.primaryNode field and computed + the same way: assigned_node = hash(vpcRef) % + (design plan §4.5 — a tenant's egress node and its primary ingress + node are the same node, by design, so both fields are computed by + the identical AssignPrimaryNode function). The controller consuming + this CRD sets this field exactly once, at creation. + + This value must never be silently recomputed by a reconciler once + set, for the exact same reason NetworkRuleStatus.PrimaryNode's own + doc comment gives: recomputing it on a later reconcile can flip + which node a tenant's egress traffic routes through and cause an + avoidable traffic flap; a reconciler that observes a stale or + removed node here must surface that via a condition instead of + overwriting the value. + type: string + conditions: + description: |- + Conditions contains the standard conditions for this resource, + including Accepted (see AcceptedReasonOwnershipVerified / + AcceptedReasonOwnershipDenied in rule_types.go, reused as-is here). + 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: + description: ObservedGeneration is the .metadata.generation this status + was computed from. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/network.datumapis.com_networkgateways.yaml b/config/crd/network.datumapis.com_networkgateways.yaml index 746702b..87dedc8 100644 --- a/config/crd/network.datumapis.com_networkgateways.yaml +++ b/config/crd/network.datumapis.com_networkgateways.yaml @@ -23,6 +23,12 @@ spec: - jsonPath: .status.sRv6Address name: SRV6-ADDRESS type: string + - jsonPath: .status.egressAddress + name: EGRESS-ADDRESS + type: string + - jsonPath: .status.egressSID + name: EGRESS-SID + type: string - jsonPath: .metadata.creationTimestamp name: AGE type: date @@ -153,6 +159,39 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + egressAddress: + description: |- + EgressAddress is this gateway node's own publicly-routable IPv6 + address, used as the masquerade SNAT source for every egress flow + this node translates on behalf of tenant VPC backends reaching the + internet. Unlike SRv6Address (reachable only within the SRv6 fabric), + this address must additionally be reachable from the public internet + — an eBGP/uplink-peering concern outside this API. Operator-supplied + via GALACTIC_GATEWAY_EGRESS_ADDRESS; there is no in-cluster + derivation mechanism yet, the same gap SRv6Address itself has today. + A gateway node not offering egress leaves this field empty. + type: string + x-kubernetes-validations: + - message: egressAddress must be a valid IPv6 address + rule: self == '' || isIP(self) + egressSID: + description: |- + EgressSID is this gateway node's own egress_sid uSID *locator* + (design plan §3.1) — the reserved Argument range's Block+Node-ID + portion tenant VRF default routes encapsulate toward. Unlike + EgressAddress (a plain, publicly-routable address, no uSID + structure), this is a real uSID: other nodes need a kernel route to + it before they can install a SEG6 encap route naming it as the + destination (the same reason SRv6Address is advertised into BGP), + so this is published and advertised the same way SRv6Address/ + EgressAddress already are. Operator-supplied via + GALACTIC_GATEWAY_EGRESS_SID; a gateway node not offering egress + leaves this field empty, always paired with EgressAddress (both + set, or neither). + type: string + x-kubernetes-validations: + - message: egressSID must be a valid IPv6 address + rule: self == '' || isIP(self) observedGeneration: description: ObservedGeneration is the .metadata.generation this status was computed from. diff --git a/docs/api/gateway.md b/docs/api/gateway.md index f75dca8..cb32f8e 100644 --- a/docs/api/gateway.md +++ b/docs/api/gateway.md @@ -9,11 +9,93 @@ Package v1alpha1 contains API Schema definitions for the network.datumapis.com/v1alpha1 API group. ### Resource Types +- [NetworkEgressPolicy](#networkegresspolicy) - [NetworkGateway](#networkgateway) - [NetworkRule](#networkrule) +#### NetworkEgressPolicy + + + +NetworkEgressPolicy enables internet egress for a single tenant +VPC/VPCAttachment, served by the shared hyperconverged gateway engine's +masquerade (SNAT/PAT) datapath. Unlike NetworkRule, it carries no +VIP/backend/port: egress is on or off for a (vpcRef, vpcAttachmentRef) +pair, existence-implies-enabled, not a per-flow rule — because the +destination of an egress flow is an arbitrary internet address, not a +pre-configured backend list. + +It is namespaced (deployed to galactic-system) and tenant-writable; like +NetworkRule, vpcRef/vpcAttachmentRef are opaque string identifiers because +the VPC API is owned by a separate companion operator, not this repo. An +admission webhook (implemented by the consuming controller) must verify +the requester is authorized for vpcRef/vpcAttachmentRef before a policy is +accepted — see the Accepted condition. + +Presence of an accepted NetworkEgressPolicy resolves only *enablement* +(should this tenant reach the egress datapath at all) — a routing-layer +decision (does the tenant's VRF have a default route toward the shared +egress_sid locator), not a per-packet datapath lookup. *Isolation* +(preventing two tenants with colliding ULA source addresses from +colliding in the egress connection table) is a separate, datapath-level +concern resolved by tagging each flow with the tenant/VRF identifier +carried in the egress_sid locator's own Argument bits, not by anything in +this spec. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `network.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `NetworkEgressPolicy` | | | +| `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` _[NetworkEgressPolicySpec](#networkegresspolicyspec)_ | | | | +| `status` _[NetworkEgressPolicyStatus](#networkegresspolicystatus)_ | | | | + + +#### NetworkEgressPolicySpec + + + +NetworkEgressPolicySpec defines the desired egress-enablement state for a +tenant VPC/VPCAttachment. + + + +_Appears in:_ +- [NetworkEgressPolicy](#networkegresspolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vpcRef` _string_ | VPCRef is the opaque identifier of the target VPC this policy applies
to. This repo does not own the VPC API and does not validate the
identifier beyond non-emptiness; the admission webhook of the
consuming controller is responsible for verifying the requester is
authorized for this VPC before the policy is accepted. | | MinLength: 1
Required: \{\}
| +| `vpcAttachmentRef` _string_ | VPCAttachmentRef is the opaque identifier of the target
VPCAttachment this policy applies to. Like VPCRef, this is an opaque
string reference validated by the admission webhook, not by this API. | | MinLength: 1
Required: \{\}
| + + +#### NetworkEgressPolicyStatus + + + +NetworkEgressPolicyStatus defines the observed state of a +NetworkEgressPolicy. + + + +_Appears in:_ +- [NetworkEgressPolicy](#networkegresspolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `assignedGatewayNode` _string_ | AssignedGatewayNode is the name of the NetworkGateway-backed gateway
node this policy's tenant should route egress traffic through,
mirroring NetworkRule's own status.primaryNode field and computed
the same way: assigned_node = hash(vpcRef) %
(design plan §4.5 — a tenant's egress node and its primary ingress
node are the same node, by design, so both fields are computed by
the identical AssignPrimaryNode function). The controller consuming
this CRD sets this field exactly once, at creation.
This value must never be silently recomputed by a reconciler once
set, for the exact same reason NetworkRuleStatus.PrimaryNode's own
doc comment gives: recomputing it on a later reconcile can flip
which node a tenant's egress traffic routes through and cause an
avoidable traffic flap; a reconciler that observes a stale or
removed node here must surface that via a condition instead of
overwriting the value. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource,
including Accepted (see AcceptedReasonOwnershipVerified /
AcceptedReasonOwnershipDenied in rule_types.go, reused as-is here). | | | + + #### NetworkGateway @@ -84,6 +166,8 @@ _Appears in:_ | --- | --- | --- | --- | | `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | | `sRv6Address` _string_ | SRv6Address is this gateway node's own SRv6-reachable IPv6 address,
used as the Full-NAT SNAT source for every ingress flow this node
translates. Backend Pods' replies are naturally routed back to it
over the ordinary SRv6 fabric (the same mechanism that routes any
other node's traffic), where this node's XDP program decapsulates
and un-NATs them using its own conn_table — there is no separate
tunnel endpoint or overlay device to publish. Populated by the
engine once it has computed the address (a uFMT 48+16 uSID over this
node's own BGPRouter locator/node-ID, at the reserved Argument 0)
and advertised it into BGP. | | | +| `egressAddress` _string_ | EgressAddress is this gateway node's own publicly-routable IPv6
address, used as the masquerade SNAT source for every egress flow
this node translates on behalf of tenant VPC backends reaching the
internet. Unlike SRv6Address (reachable only within the SRv6 fabric),
this address must additionally be reachable from the public internet
— an eBGP/uplink-peering concern outside this API. Operator-supplied
via GALACTIC_GATEWAY_EGRESS_ADDRESS; there is no in-cluster
derivation mechanism yet, the same gap SRv6Address itself has today.
A gateway node not offering egress leaves this field empty. | | | +| `egressSID` _string_ | EgressSID is this gateway node's own egress_sid uSID *locator*
(design plan §3.1) — the reserved Argument range's Block+Node-ID
portion tenant VRF default routes encapsulate toward. Unlike
EgressAddress (a plain, publicly-routable address, no uSID
structure), this is a real uSID: other nodes need a kernel route to
it before they can install a SEG6 encap route naming it as the
destination (the same reason SRv6Address is advertised into BGP),
so this is published and advertised the same way SRv6Address/
EgressAddress already are. Operator-supplied via
GALACTIC_GATEWAY_EGRESS_SID; a gateway node not offering egress
leaves this field empty, always paired with EgressAddress (both
set, or neither). | | | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | |