Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .crd-ref-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ processor:
- "NetworkRuleProtocol"
- "NetworkRuleSpec"
- "NetworkRuleStatus"
- "NetworkEgressPolicy"
- "NetworkEgressPolicySpec"
- "NetworkEgressPolicyStatus"
ignoreFields: []

render:
Expand Down
113 changes: 113 additions & 0 deletions api/v1alpha1/egresspolicy_types.go
Original file line number Diff line number Diff line change
@@ -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) % <gateway node count>
// (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{})
}
231 changes: 231 additions & 0 deletions api/v1alpha1/egresspolicy_types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading