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
76 changes: 76 additions & 0 deletions .crd-ref-docs-gateway.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
processor:
docPaths: []
ignoreTypes:
- ".*List$"
- "AFI"
- "SAFI"
- "AddressFamily"
- "SRv6Function"
- "RouterRole"
- "OriginType"
- "RouterRef"
- "RouterSelector"
- "RouterTarget"
- "LocalSecretRef"
- "RouterStatus"
- "ResolvedRouterConfig"
- "BGPPeerBFD"
- "BGPPeerGracefulRestart"
- "BGPPeerAuthentication"
- "Community"
- "Prefix"
- "RedistributeSource"
- "AdvertisementOriginateType"
- "AdvertisementOriginateFrom"
- "AdvertisementPolicyRef"
- "BGPAdvertisement"
- "BGPAdvertisementSpec"
- "BGPAdvertisementStatus"
- "BGPCommunitySet"
- "BGPCommunitySetType"
- "BGPCommunitySetSpec"
- "BGPCommunitySetStatus"
- "BGPPeerState"
- "SendCommunityType"
- "MaxPrefixShutdownAction"
- "BGPMaximumPrefix"
- "BGPPeer"
- "BGPPeerSpec"
- "BGPPeerStatus"
- "BGPRouterPhase"
- "BGPRouter"
- "BGPRouterSpec"
- "BGPRouterStatus"
- "BGPRouterPeerSummary"
- "BGPVRFInstance"
- "BGPVRFInstanceSpec"
- "BGPVRFInstanceStatus"
- "RouteTarget"
- "BGPPolicyDirection"
- "BGPPolicyAction"
- "BGPPolicy"
- "BGPPolicySpec"
- "BGPPolicyTerm"
- "BGPPolicyMatch"
- "BGPPolicySetActions"
- "BGPPolicyStatus"
- "ASPathMatchType"
- "ASPathFilter"
- "EVPNRouteType"
- "BGPOrigin"
- "AsPathSet"
- "NextHopSet"
- "CommunitySet"
- "ExtendedCommunitySet"
- "BGPPrefixList"
- "BGPPrefixListEntry"
- "BGPPrefixListSpec"
- "BGPPrefixListStatus"
ignoreFields: []

render:
kubernetes: {}
markdown:
mediaWidth: 80
anchorLink: true
tableHideEmpty: true
8 changes: 8 additions & 0 deletions .crd-ref-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ processor:
docPaths: []
ignoreTypes:
- ".*List$"
- "NetworkGateway"
- "NetworkGatewaySpec"
- "NetworkGatewayStatus"
- "NetworkRule"
- "NetworkRuleBackend"
- "NetworkRuleProtocol"
- "NetworkRuleSpec"
- "NetworkRuleStatus"
ignoreFields: []

render:
Expand Down
18 changes: 10 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ task lint-fix # Same with auto-fix applied
task generate # Run all generators (methods, manifests, docs)
task generate:methods # Regenerate zz_generated.deepcopy.go files
task generate:manifests # Regenerate CRD YAML in config/crd/ from Go types
task generate:docs # Regenerate docs/api/bgp.md from Go types
task generate:docs # Regenerate docs/api/bgp.md and docs/api/gateway.md from Go types
task ci # Full local pipeline: build → lint → test:unit → test:e2e
task clean # Remove ./bin/ and cover.out
```
Expand All @@ -38,9 +38,9 @@ All dev tools (golangci-lint, controller-gen, chainsaw, yamlfmt) are installed l

### API groups

| Group | Version | Resources |
|--------------------|------------|-----------------------------------------------------------------|
| `network.datumapis.com` | `v1alpha1` | BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance |
| Group | Version | Resources |
|--------------------|------------|---------------------------------------------------------------------------|
| `network.datumapis.com` | `v1alpha1` | BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance, NetworkGateway, NetworkRule |

Source lives in `api/v1alpha1/`. Each resource has its own `*_types.go` file; shared types (RouterTarget, AddressFamily, etc.) live in `shared_types.go`.

Expand All @@ -60,17 +60,17 @@ The `RouterTarget` struct (in `shared_types.go`) is embedded by resources that s
- **Kubernetes 1.28+ required** — CEL functions `isIP()` and `isCIDR()` are used for field validation.
- **Status conditions** follow `metav1.Condition` conventions. Condition type constants (e.g., `ConditionTypeReady`, `ConditionTypeAccepted`) are defined alongside the resource type they belong to.
- **YAML files must use `.yaml` extension**, never `.yml` — the lint task enforces this.
- **Never hand-edit generated files** — `docs/api/bgp.md`, `config/crd/*.yaml`, and `zz_generated.deepcopy.go` are all generated. Always regenerate via `task generate` (or the individual `generate:methods`, `generate:manifests`, `generate:docs` targets). Editing them directly will be overwritten and drifts from source of truth.
- **Never hand-edit generated files** — `docs/api/bgp.md`, `docs/api/gateway.md`, `config/crd/*.yaml`, and `zz_generated.deepcopy.go` are all generated. Always regenerate via `task generate` (or the individual `generate:methods`, `generate:manifests`, `generate:docs` targets). Editing them directly will be overwritten and drifts from source of truth.

### Code generation

After changing kubebuilder markers (`// +kubebuilder:...`) or adding new types:

1. `task generate:methods` — regenerates `zz_generated.deepcopy.go`
2. `task generate:manifests` — regenerates CRDs in `config/crd/`
3. `task generate:docs` — regenerates `docs/api/bgp.md` from Go types (config in `.crd-ref-docs.yaml`)
3. `task generate:docs` — regenerates `docs/api/bgp.md` and `docs/api/gateway.md` from Go types (configs in `.crd-ref-docs.yaml` and `.crd-ref-docs-gateway.yaml`)

Or run `task generate` to execute all three in order. All three are generated; never edit `zz_generated.deepcopy.go`, CRD YAML, or `docs/api/bgp.md` directly.
Or run `task generate` to execute all three in order. All three are generated; never edit `zz_generated.deepcopy.go`, CRD YAML, or `docs/api/*.md` directly.

### Testing

Expand All @@ -86,7 +86,9 @@ See [CONVENTIONS.md](docs/agents/CONVENTIONS.md) for coding standards, naming ru

## Docs

- `docs/api/bgp.md` — full BGP CRD field reference
- `docs/api/index.md` — API docs index (links to bgp.md and gateway.md)
- `docs/api/bgp.md` — BGP CRD field reference (generated)
- `docs/api/gateway.md` — Gateway CRD field reference (generated)
- `docs/getting-started.md` — install and first resources
- `docs/enhancements/` — design proposals
## GitHub PR / Issue / Comment Conventions
Expand Down
2 changes: 2 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ tasks:
cmds:
- rm -rf docs/api && mkdir -p docs/api
- '{{.CRD_REF_DOCS}} --renderer=markdown --source-path=api/v1alpha1 --output-path=docs/api/bgp.md --config=.crd-ref-docs.yaml'
- '{{.CRD_REF_DOCS}} --renderer=markdown --source-path=api/v1alpha1 --output-path=docs/api/gateway.md --config=.crd-ref-docs-gateway.yaml'
- cp templates/api-index.md docs/api/index.md

##
## Testing
Expand Down
86 changes: 86 additions & 0 deletions api/v1alpha1/gateway_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// NetworkGateway defines an XDP ingress NAT+LB gateway engine instance bound
// to a single dedicated gateway-role node. Exactly one NetworkGateway exists
// per gateway node (spec.targetRef.name is the Kubernetes node name),
// mirroring the BGPRouter node-scoped root object pattern. NetworkRule
// resources are assigned to a NetworkGateway via status.primaryNode.
//
// There is no tunnel overlay in this design (an earlier Geneve-based
// approach was superseded before this type shipped): the gateway's XDP
// program does Full-NAT (DNAT the VIP to a backend Pod's address, SNAT the
// client's source to status.sRv6Address) and pushes an SRv6 uSID outer
// header addressed to the backend's worker node directly, so return traffic
// (addressed to status.sRv6Address) arrives back at this same gateway node
// over the ordinary SRv6 fabric — no compute-node encap agent, no tunnel
// endpoint to publish. status.sRv6Address is advertised into BGP the same
// way any workload prefix is (a BGPAdvertisement naming it, /128, Argument
// 0 — the value PR #740 reserves and forbids registering into any tenant
// VRF, guaranteeing it never collides with a real tenant's Argument), so
// every other node learns a real kernel SEG6 route to it for free through
// the existing EVPN pipeline.
//
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +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="AGE",type="date",JSONPath=".metadata.creationTimestamp"
type NetworkGateway struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec NetworkGatewaySpec `json:"spec,omitempty"`
Status NetworkGatewayStatus `json:"status,omitempty"`
}

// NetworkGatewaySpec defines the desired state of a NetworkGateway.
type NetworkGatewaySpec struct {
// TargetRef identifies the Node this gateway engine executes on.
// +kubebuilder:validation:Required
TargetRef TargetRef `json:"targetRef"`
}

// NetworkGatewayStatus defines the observed state of a NetworkGateway.
type NetworkGatewayStatus struct {
// ObservedGeneration is the .metadata.generation this status was computed from.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// 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.
// +optional
// +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="sRv6Address must be a valid IPv6 address"
SRv6Address string `json:"sRv6Address,omitempty"`

// Conditions contains the standard conditions for this resource.
//
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// NetworkGatewayList is a list of NetworkGateway resources.
// +kubebuilder:object:root=true
type NetworkGatewayList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []NetworkGateway `json:"items"`
}

func init() {
SchemeBuilder.Register(&NetworkGateway{}, &NetworkGatewayList{})
}
128 changes: 128 additions & 0 deletions api/v1alpha1/gateway_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package v1alpha1

import (
"encoding/json"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func newTestGateway() *NetworkGateway {
return &NetworkGateway{
TypeMeta: metav1.TypeMeta{
APIVersion: "network.datumapis.com/v1alpha1",
Kind: "NetworkGateway",
},
ObjectMeta: metav1.ObjectMeta{Name: "test-gateway"},
Spec: NetworkGatewaySpec{
TargetRef: TargetRef{Kind: "Node", Name: "gw-node-a"},
},
Status: NetworkGatewayStatus{
SRv6Address: "2001:db8:1::1",
},
}
}

// TestNetworkGatewayDeepCopy verifies that DeepCopy produces an independent
// copy: mutations to the copy must not affect the original.
func TestNetworkGatewayDeepCopy(t *testing.T) {
orig := newTestGateway()
dup := orig.DeepCopy()

dup.Spec.TargetRef.Name = "gw-node-b"
dup.Status.SRv6Address = "2001:db8:1::2"
dup.Status.Conditions = append(dup.Status.Conditions, metav1.Condition{Type: ConditionTypeReady})

if orig.Spec.TargetRef.Name != "gw-node-a" {
t.Errorf("TargetRef.Name mutated: got %q", orig.Spec.TargetRef.Name)
}
if orig.Status.SRv6Address != "2001:db8:1::1" {
t.Errorf("SRv6Address mutated: got %q", orig.Status.SRv6Address)
}
if len(orig.Status.Conditions) != 0 {
t.Errorf("Conditions mutated: got %v", orig.Status.Conditions)
}
}

// TestNetworkGatewayDeepCopyNil verifies DeepCopy on a nil pointer returns nil.
func TestNetworkGatewayDeepCopyNil(t *testing.T) {
var g *NetworkGateway
if g.DeepCopy() != nil {
t.Error("DeepCopy on nil pointer should return nil")
}
}

// TestNetworkGatewayJSONRoundTrip verifies that the struct serialises and
// deserialises through JSON without data loss.
func TestNetworkGatewayJSONRoundTrip(t *testing.T) {
orig := newTestGateway()
orig.Status.Conditions = []metav1.Condition{
{Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: "Ready", Message: "ok"},
}

data, err := json.Marshal(orig)
if err != nil {
t.Fatalf("Marshal: %v", err)
}

var got NetworkGateway
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}

if got.Spec.TargetRef != orig.Spec.TargetRef {
t.Errorf("TargetRef: got %+v, want %+v", got.Spec.TargetRef, orig.Spec.TargetRef)
}
if got.Status.SRv6Address != orig.Status.SRv6Address {
t.Errorf("SRv6Address: got %q, want %q", got.Status.SRv6Address, orig.Status.SRv6Address)
}
if len(got.Status.Conditions) != 1 {
t.Fatalf("Conditions len: got %d, want 1", len(got.Status.Conditions))
}
}

// TestNetworkGatewayListDeepCopy verifies that NetworkGatewayList.DeepCopy
// produces independent copies of each item.
func TestNetworkGatewayListDeepCopy(t *testing.T) {
list := &NetworkGatewayList{
Items: []NetworkGateway{*newTestGateway()},
}
copied := list.DeepCopy()
copied.Items[0].Spec.TargetRef.Name = "other-node"

if list.Items[0].Spec.TargetRef.Name != "gw-node-a" {
t.Errorf("original list item mutated via copy")
}
}

// TestNetworkGatewayFieldNames verifies the JSON keys for spec/status fields
// match the CRD schema.
func TestNetworkGatewayFieldNames(t *testing.T) {
orig := newTestGateway()

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 _, ok := spec["targetRef"]; !ok {
t.Errorf("expected spec.targetRef field, got %v", spec)
}

status, ok := m["status"].(map[string]any)
if !ok {
t.Fatalf("status not found or wrong type: %v", m["status"])
}
if v, ok := status["sRv6Address"]; !ok || v != "2001:db8:1::1" {
t.Errorf("expected status.sRv6Address=%q, got %v", "2001:db8:1::1", status["sRv6Address"])
}
}
Loading
Loading