diff --git a/.crd-ref-docs-cloud.yaml b/.crd-ref-docs-cloud.yaml new file mode 100644 index 00000000..3361ffbb --- /dev/null +++ b/.crd-ref-docs-cloud.yaml @@ -0,0 +1,12 @@ +processor: + docPaths: [] + ignoreTypes: + - ".*List$" + ignoreFields: [] + +render: + kubernetes: {} + markdown: + mediaWidth: 80 + anchorLink: true + tableHideEmpty: true diff --git a/.crd-ref-docs-network-bgp.yaml b/.crd-ref-docs-network-bgp.yaml new file mode 100644 index 00000000..fa520025 --- /dev/null +++ b/.crd-ref-docs-network-bgp.yaml @@ -0,0 +1,22 @@ +processor: + docPaths: [] + ignoreTypes: + - ".*List$" + - "NetworkGateway" + - "NetworkGatewaySpec" + - "NetworkGatewayStatus" + - "NetworkService" + - "NetworkServiceProtocol" + - "NetworkServiceSpec" + - "NetworkServiceStatus" + - "NetworkEgressPolicy" + - "NetworkEgressPolicySpec" + - "NetworkEgressPolicyStatus" + ignoreFields: [] + +render: + kubernetes: {} + markdown: + mediaWidth: 80 + anchorLink: true + tableHideEmpty: true diff --git a/.crd-ref-docs-network-gateway.yaml b/.crd-ref-docs-network-gateway.yaml new file mode 100644 index 00000000..25f5625a --- /dev/null +++ b/.crd-ref-docs-network-gateway.yaml @@ -0,0 +1,75 @@ +processor: + docPaths: [] + ignoreTypes: + - ".*List$" + - "AFI" + - "SAFI" + - "AddressFamily" + - "SRv6Function" + - "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 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c91c8109..c15e2151 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -171,4 +171,5 @@ jobs: - { path: config/fabric-api/base, name: ghcr.io/datum-cloud/fabric-api, tag: "${{ needs.publish-fabric-api-image.outputs.tag }}" } # config/galactic-controller/base is opt-in too, for the same reason. - { path: config/galactic-controller/base, name: ghcr.io/datum-cloud/galactic-controller, tag: "${{ needs.publish-galactic-controller-image.outputs.tag }}" } + - { path: config/galactic-controller/fabric, name: ghcr.io/datum-cloud/galactic-controller, tag: "${{ needs.publish-galactic-controller-image.outputs.tag }}" } secrets: inherit diff --git a/.golangci.yaml b/.golangci.yaml index 7f7487a8..8ee60d24 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -26,6 +26,13 @@ linters: - unused exclusions: rules: + # Preserve imported upstream API/controller test fixtures as written. + - path: '^(api/network/v1alpha1|internal/cloud)/.*_test\.go$' + linters: [goconst, lll] + # The imported sources use descriptive schema and error text that must + # remain stable across the consolidation. + - path: '^(api/network/v1alpha1|internal/cloud)/.*\.go$' + linters: [lll] # A kubebuilder marker is one line by definition and cannot be wrapped. - linters: [lll] source: '^\s*// \+kubebuilder:' diff --git a/AGENTS.md b/AGENTS.md index b4d24d91..b1d9f950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ others where the boundary matters: | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [docs/agents/ARCHITECTURE-CNI.md](docs/agents/ARCHITECTURE-CNI.md) | The CNI attach chain: `galactic-cni` (installer), `galactic-veth`, `galactic-tap`, `galactic-ipam`, `galactic-bgp`, `galactic-route` | Pod/VM attach or detach behavior, CNI config fields/conflists, IPAM, the SRv6 uSID TC-BPF datapath's CNI-side registration, or anything under `cmd/galactic-{cni,veth,tap,ipam,bgp,route}`, `internal/cni*`, `internal/installer` | | [docs/agents/ARCHITECTURE-ROUTER.md](docs/agents/ARCHITECTURE-ROUTER.md) | The BGP/EVPN control plane: `galactic-router` (BGPRouter/BGPPeer/BGPAdvertisement/BGPPolicy/BGPVRFInstance reconcilers, embedded GoBGP, GC) | BGP CRD reconciliation, GoBGP runtime behavior, EVPN path construction, orphaned-CRD/VRF garbage collection, or anything under `cmd/galactic-router`, `internal/reconcile`, `internal/runtime`, `internal/gc`, `internal/model`, `internal/hash` | -| [docs/agents/ARCHITECTURE-GATEWAY.md](docs/agents/ARCHITECTURE-GATEWAY.md) | The edge XDP DSR load balancer: `galactic-gateway`, `NetworkGateway`/`NetworkRule` reconcilers, the edge XDP datapath | Ingress L4 load balancing, `NetworkGateway`/`NetworkRule` CRDs, anycast VIP advertisement, or anything under `cmd/galactic-gateway`, `internal/gateway`, `internal/plumbing/ebpf/edge{prog,map,attach}`, the `NetworkGateway`/`NetworkRule`-related code in `internal/controller` | +| [docs/agents/ARCHITECTURE-GATEWAY.md](docs/agents/ARCHITECTURE-GATEWAY.md) | The edge XDP DSR load balancer: `galactic-gateway`, `NetworkGateway`/`NetworkService` reconcilers, the edge XDP datapath | Ingress L4 load balancing, `NetworkGateway`/`NetworkService` CRDs, anycast VIP advertisement, or anything under `cmd/galactic-gateway`, `internal/gateway`, `internal/plumbing/ebpf/edge{prog,map,attach}`, the `NetworkGateway`/`NetworkService`-related code in `internal/controller` | | [docs/agents/ARCHITECTURE-FABRIC-API.md](docs/agents/ARCHITECTURE-FABRIC-API.md) | The fabric looking glass: `fabric-api` (`node` sidecar in each `fabric-router` pod, per-cell `gateway`, `janitor`, `query`), the `FabricQuery` types, FRR vty reads, ICMP probes, mTLS identities | FRR diagnostics over vty or ICMP probes, `FabricQuery` execution and its status, fabric-api mTLS identities or certificates, or anything under `cmd/fabric-api`, `internal/fabric`, `api/fabric`, `config/fabric-api` | The CNI, router and gateway documents supersede the former monolithic @@ -26,23 +26,23 @@ Galactic is the SRv6 data plane for multi-cloud VPC networking. It consists of t - **`galactic-veth`** — CNI plugin that wires containers into VPC networks (VRF, veth, SRv6 ingress route) and writes `BGPAdvertisement` CRDs. - **`galactic-router`** — controller-runtime reconciler that watches BGP CRDs and drives an embedded GoBGP server per node to distribute EVPN paths. -- **`galactic-gateway`** — controller-runtime process that loads an edge XDP DSR (Direct Server Return) L4 load balancer on gateway-role nodes and drives it from `NetworkGateway`/`NetworkRule` CRDs. Every gateway node advertises every VIP identically (anycast) over the same EVPN mesh. +- **`galactic-gateway`** — controller-runtime process that loads an edge XDP DSR (Direct Server Return) L4 load balancer on gateway-role nodes and drives it from `NetworkGateway`/`NetworkService` CRDs. Every gateway node advertises every VIP identically (anycast) over the same EVPN mesh. Alongside them, **`fabric-api`** is the opt-in fabric looking glass: its `node` subcommand runs as a sidecar in each `fabric-router` pod and answers bounded diagnostics about FRR state (BGP lookups and summaries, ICMP ping/traceroute) over mTLS gRPC on the node's own address; its `gateway` subcommand runs once per edge cell, executes `FabricQuery` objects by fanning them out to those sidecars, and serves an operator debug service; its `janitor` subcommand deletes expired `FabricQuery` copies; its `certsync` subcommand runs in a separate per-node pod that obtains the sidecar's certificate. AS-path and community searches run against an in-sidecar index of bgpd's selected routes fed over BMP (`internal/fabric/index`), not FRR scans; see [docs/fabric-api/load-test.md](docs/fabric-api/load-test.md). It is one binary and image (`ghcr.io/datum-cloud/fabric-api`), separate from `fabric-router`'s FRR image. -VPC and VPCAttachment CRD management lives in a separate companion operator; Galactic receives pre-populated identifiers through the CNI config and acts on them. That operator's controllers (datum-cloud/cloud) also write `VPCAttachment` status, including `status.node`, from the `BGPAdvertisement`s `galactic-router` publishes. Galactic never writes it, but `NetworkRule` backend selection and private service routes wait on it (#821). +The cloud and network APIs now live in `api/cloud/v1alpha1` and `api/network/v1alpha1`, both under `galactic.datumapis.com`. The cell role of `galactic-controller` runs the former cloud VPC, attachment, subnet, interface, and BGP status reconcilers alongside the traffic-policy controllers. Its fabric role runs the central network fabric identity allocator in a separate deployment. **Data flow:** CNI invoked with pre-populated VPC/VPCAttachment identifiers → CNI creates kernel SRv6 state and writes a `BGPAdvertisement` CRD → `galactic-router` reconciles the CRD → GoBGP advertises the EVPN path → BGP distributes routes between nodes. ## Tech Stack - **Go 1.26** — every binary -- **controller-runtime** — BGPRouter/BGPPeer/BGPAdvertisement/BGPPolicy/BGPVRFInstance reconcilers (`galactic-router`) and NetworkGateway/NetworkRule reconcilers (`galactic-gateway`); a bare `pkg/client` (no manager) in `galactic-bgp`/`galactic-cni` -- **BGP API** (`go.datum.net/network`) — BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance, NetworkGateway, NetworkRule CRDs +- **controller-runtime** — BGPRouter/BGPPeer/BGPAdvertisement/BGPPolicy/BGPVRFInstance reconcilers (`galactic-router`) and NetworkGateway/NetworkService reconcilers (`galactic-gateway`); a bare `pkg/client` (no manager) in `galactic-bgp`/`galactic-cni` +- **Galactic API** (`api/network/v1alpha1`, `api/cloud/v1alpha1`, `api/v1alpha1`) — BGP, gateway, VPC, attachment, and traffic-policy CRDs in `galactic.datumapis.com` - **GoBGP v4** — embedded BGP server (default role) - **SRv6 + netlink** — kernel-level routing; `github.com/vishvananda/netlink` - **eBPF** (`github.com/cilium/ebpf`) — TC-BPF SRv6 uSID decap datapath (CNI side) and XDP edge DSR load-balancer datapath (`galactic-gateway`) -- **Multus CNI** — multi-network for pods; NAD generation handled by the external operator +- **Multus CNI** — multi-network for pods; NAD generation handled by `galactic-controller --role=cell` ## Development Workflow @@ -100,7 +100,7 @@ See [docs/node-labels.md](docs/node-labels.md) for the full strategy — why `ga - **`config/fabric-router/`** — the FRR underlay eBGP DaemonSet (`fabric-router`; `galactic-router` needs a working underlay before it can start). Unlike `config/galactic-router/`, this is a single flat DaemonSet with no `default`/`rr`-style role split — its affinity matches any node labeled `galactic.datumapis.com/fabric: router` (a dedicated mode-enum label, independent of whatever `galactic.datumapis.com/node` role value or `galactic.datumapis.com/galactic` mode that node also carries) directly, since there's no env/config difference between running on a regular node vs. those roles — fabric-router itself is identical everywhere; only the per-node BGP underlay config it reads from the ConfigMap differs. That affinity can legitimately match more than one node per cluster, and BGP underlay config (hostname, router-id, interface addresses, remote-AS) inherently differs per physical node — so each node's config lives in its own `fabric-router.` ConfigMap (a `frr.conf` key, plus optional `daemons`/`vtysh.conf` overrides of the image defaults) rather than one shared ConfigMap for the whole DaemonSet. `fabric-config-agent` (`cmd/fabric-config-agent`, `internal/fabricconfig`, built into the `fabric-router` image) reads it from the API: as the `frr-init` init container it validates (`vtysh -C`) and installs it, holding the pod in `Init` until a valid one exists; as the `config-agent` sidecar it applies later changes live with `frr-reload.py`, so an edit to one node's ConfigMap neither restarts the pod nor touches other nodes, and reports `Applied`/`Rejected`/`ReloadFailed`/`RestartRequired` events on the ConfigMap. The sidecar also hard-resets any IPv6 session announcing an IPv4-mapped next hop (`internal/fabricconfig/nexthop.go`) and serves that check's metrics on the node's port `9343`. A third container, `frr-exporter` (upstream `tynany/frr_exporter`, pinned by digest), reads the daemons' vty sockets in the same pod-scoped `/run/frr` and serves BGP peer state, message counters and prefix counts for both underlay families on the node's port `9342` (`hostNetwork`); `task verify:fabric-metrics` in `deploy/containerlab/` checks it on every lab node. The image's default `daemons` loads bgpd's BMP module (`-M bmp`), so a node's `frr.conf` can stream to a BMP collector (`config/bmp-collector/`) with a `bmp targets` block; loading it alone streams nothing, and a change to the modules needs a pod restart rather than a reload. Every lab node's `frr.conf` streams its pre-policy Adj-RIB-In and Loc-RIB for both families, sourced from `lo` (`source-interface lo`), since the underlay carries loopbacks but not link subnets. Production `frr.conf`s come from infra's renderer, which has to emit the same block for production nodes to stream. A node with no ConfigMap of its own falls back, with a `DeprecatedConfigSource` warning, to key `frr.conf.` of the legacy shared `fabric-config` ConfigMap. **Not** part of the root `config/kustomization.yaml` and not covered by `kubectl apply -k config/` — unlike every other component here, it has no generic default: the deployer must hand-author one `fabric-router.` ConfigMap per matching node before applying `kubectl apply -k config/fabric-router/` (which also applies the agent's ServiceAccount and namespaced Role). The opt-in Component `config/fabric-router/components/fabric-api` adds the fabric-api sidecar to this DaemonSet's pod (see `config/fabric-api/` below); applying it, and every later fabric-api image change, recreates the pod and restarts FRR with it, so roll it out as a routing change. - **`config/bmp-collector/`** — a single-replica GoBMP Deployment and its `bmp-collector` Service (TCP `5000`), the BMP (RFC 7854) collector `galactic-router` and `fabric-router` stream to; `galactic-router` does so when `GALACTIC_ROUTER_BMP_STATIONS` names it (`bmp-collector.galactic-system.svc:5000`; see [docs/router/configuration.md](docs/router/configuration.md)). It parses every Peer Up/Down, route-monitoring and statistics message to JSON on stdout, so the cluster's log pipeline is its store; an overlay swapping `--dump=console` for `--kafka-server` publishes to Kafka instead. Opt-in, like `config/fabric-router/`: not part of the root `config/kustomization.yaml`, since nothing streams to it until a router is configured to. The containerlab lab runs one on `iad-worker3`'s host network (`deploy/containerlab/resources/bmp-collector/iad/`), because its three clusters cannot reach each other's Services, and points every node's `fabric-router` and the EVPN route reflector at it. Of the `galactic-router`s, only the reflector streams: its pre-policy Adj-RIB-In already holds every node's advertisements. - **`config/fabric-api/`** — the fabric looking glass's cell-wide prerequisites and per-cell gateway. `kubectl apply -k config/fabric-api/` applies only the `fabric-api-gateway` and `fabric-api-janitor` ServiceAccounts and RBAC (the janitor's delete access is deliberately a separate identity from the gateway's, which can only read `FabricQuery` and write its status); safe and idempotent in any cell. `config/fabric-api/base/` (the 2-replica leader-elected gateway Deployment, its ClusterIP debug Service, a PodDisruptionBudget and the `*/15` janitor CronJob) is **not** included in that kustomization and is **not** applied as-is, for the same reason as `config/galactic-gateway/base/`: it needs per-cell values — a `fabric-api` ConfigMap (`site`, `cell`, `cluster-name`) and the cell name in its certificate's URI SAN — supplied by a per-cell overlay; see `deploy/containerlab/resources/fabric-api/gateway/` for a worked example. `config/fabric-api/crd/` is a generated `FabricQuery` CRD for the lab and tests only; infra owns the CRD (and the per-cell `fabric-api` cert-manager `Issuer`) in every cell. The node sidecar is the `config/fabric-router/components/fabric-api` Component, which also adds a `fabric-api-certs` DaemonSet (`fabric-api certsync`) that owns the cert-manager csi-driver volume and copies the certificate to the node's `/run/fabric-api/tls` for the sidecar: a csi volume blocks its pod until the certificate is issued, so keeping it out of the `fabric-router` pod means a broken Issuer stalls only the certs pod, never FRR. The overlay's `CELL_NAME` replacement applies to that DaemonSet's `fabric-api-csi` volume. Not part of the root `config/kustomization.yaml`: fabric-api is opt-in. See [docs/fabric-api/configuration.md](docs/fabric-api/configuration.md) and [docs/fabric-api/api.md](docs/fabric-api/api.md). -- **`config/crd/`** and **`config/galactic-controller/`** — the `galactic.datumapis.com` CRDs (TrafficPolicy, TrafficPolicyBinding) and `galactic-controller --role=cell`, the TrafficPolicy validating webhook (fail closed), default-policy provisioning and binding resolution. `kubectl apply -k config/galactic-controller/` applies only its ServiceAccount and RBAC; `base/` (two-replica Deployment, webhook Service, cert-manager serving certificate, ValidatingWebhookConfiguration) is installed with the CRDs (including the cloud operator's `VPCAttachment`, which it watches) and cert-manager in place. Neither is in the root `config/kustomization.yaml`. The node side is `galactic-router` with `GALACTIC_ROUTER_TRAFFIC_POLICY=true` (off by default; `internal/trafficpolicy/node`), which publishes each attachment's bound policies into the uSID datapath, writes the binding's `status.nodes` and exports `galactic_cni_traffic_policy_*`; galactic-cni's `GALACTIC_CNI_TRAFFIC_POLICY` decides whether the datapath enforces them. See [docs/traffic-policy/controller.md](docs/traffic-policy/controller.md) and [docs/traffic-policy/node.md](docs/traffic-policy/node.md). +- **`config/crd/`** and **`config/galactic-controller/`** — the consolidated `galactic.datumapis.com` CRDs and controller deployments. `galactic-controller --role=cell` serves the traffic-policy and pod-injection webhooks and reconciles the cloud resources; `--role=fabric` runs the central identity allocator with its own hub credentials. Apply the CRDs before either controller and follow [the API group migration guide](docs/migration/galactic-api-consolidation.md) for existing clusters. The node side of traffic policy remains in `galactic-router` with `GALACTIC_ROUTER_TRAFFIC_POLICY=true`. - **`config/monitoring/`** — Prometheus Operator resources for BGP and NAT: a `fabric-router` PodMonitor (frr-exporter, port `frr-metrics`/`9342`, as `job=fabric-router`; fabric-config-agent, port `agent-metrics`/`9343`, as `job=fabric-config-agent`), a `galactic-router` PodMonitor covering both roles (port `metrics`/`9179`), a `galactic-nat` PodMonitor for the egress shards (port `metrics`/`9182`), each adding a `node` label, and the `galactic-bgp` PrometheusRule: underlay session down, administratively down for an hour, flapping, received prefixes halved, exporter collector or scrape failing, a session announcing an IPv4-mapped next hop, BGP routes not installed, the agent's metrics scrape down, a `galactic-router` BMP station or metrics scrape down, `galactic-router` routes failing to install, an egress shard's session table evicting live sessions, an egress shard out of masquerade ports, a stalled or persistently lossy NIC receive queue on a `galactic-nat` uplink (#673), and a `galactic-nat` metrics scrape down. For the opt-in fabric-api it adds the `fabric-router` PodMonitor's `api-metrics` endpoint (the sidecar, port `api-metrics`/`9345`, as `job=fabric-api-node`), a `fabric-api-gateway` PodMonitor (port `metrics`/`9347`) and a `fabric-api` rule group (stalled execution, node diagnostics unavailable, nodes unreachable, oversized results, cleanup backlog, certificate renewal failing, credentials missing, search index unsynced, scrape down — none fires merely because nobody ran a query). Requires the `monitoring.coreos.com` CRDs, which this repo does not install, so it is not part of the root `config/kustomization.yaml`. `task test:alerts` unit-tests the rules with `promtool` (`config/monitoring/tests/`). Every alert carries what infra's alerting standard checks for (`service: galactic`, `team: connect`, `runbook_url` into infra's `docs/runbooks/galactic/README.md`). The PrometheusRule here is the tested source, not where the rules run on Datum's infra: there the PodMonitors are converted and scraped on each edge cluster, but edge vmalert never notifies, so infra evaluates a copy of the rules centrally. Change the rules here first, with their tests, then update infra's copy. - **`deploy/containerlab/`** — ContainerLab topology (`gvpc.clab.yaml`) for three Kind clusters (dfw, iad, sjc) wired over an SRv6 transit mesh whose underlay is dual-stack — every transit link and loopback carries an IPv4 and an IPv6 address, with one BGP session per address family (the SRv6 data plane itself stays IPv6-only; tenant IPv4 rides inside the encapsulation). Each site has one `compute` worker and at least one `edge` worker (dfw has two); every worker has its own eBGP uplink to its site's transit router, with FRR running as a hostNetwork DaemonSet. `galactic-router` (plain mode) distributes EVPN paths over iBGP to one route reflector for the whole lab (`iad-worker3`, `config/galactic-router/overlays/control/`) — compute and edge nodes never peer directly. Every `edge` node runs an egress shard (`galactic-nat`) and a `galactic-gateway`, which share the node's uplinks through its XDP dispatcher (`GALACTIC_NAT_XDP_ATTACH=dispatch`). Each site's compute node egresses only through its own site's edge shards, and each site has its own VIP `/64`, served only through its own edge nodes to one `ns60` backend in that site. A standalone off-cluster host on the fourth transit router gives tenant egress a real destination and is the client for the VIP checks (`verify:gateway-ingress`). The lab enforces interface traffic policy (production defaults to off): every tenant binds each attachment to a grant set in `resources/tenants/policies/`, and `verify:traffic-policy` proves each path's grant and denial. See `deploy/containerlab/README.md` and `deploy/containerlab/Taskfile.yaml` for bring-up commands. diff --git a/NOTICE b/NOTICE index b7d33065..724c2fe9 100644 --- a/NOTICE +++ b/NOTICE @@ -336,9 +336,9 @@ SOFTWARE. ``` -## github.com/containernetworking/plugins/pkg +## github.com/containernetworking/plugins/pkg/ipam -* Name: github.com/containernetworking/plugins/pkg +* Name: github.com/containernetworking/plugins/pkg/ipam * Version: v1.9.1 * License: Apache-2.0 @@ -5005,6 +5005,217 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +## github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io + +* Name: github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io +* Version: v1.7.7 +* License: Apache-2.0 + +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ``` ## github.com/kenshaw/baseconv @@ -5145,25 +5356,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ``` -## github.com/mdlayher/socket - -* Name: github.com/mdlayher/socket -* Version: v0.6.0 -* License: MIT - -``` -# MIT License - -Copyright (C) 2021 Matt Layher - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -``` - ## github.com/modern-go/concurrent * Name: github.com/modern-go/concurrent @@ -5902,39 +6094,6 @@ SOFTWARE. ``` -## github.com/pkg/errors - -* Name: github.com/pkg/errors -* Version: v0.9.1 -* License: BSD-2-Clause - -``` -Copyright (c) 2015, Dave Cheney -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -``` - ## github.com/pmezard/go-difflib/difflib * Name: github.com/pmezard/go-difflib/difflib @@ -6642,221 +6801,10 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## github.com/prometheus/procfs +## github.com/safchain/ethtool -* Name: github.com/prometheus/procfs -* Version: v0.22.0 -* License: Apache-2.0 - -``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -``` - -## github.com/safchain/ethtool - -* Name: github.com/safchain/ethtool -* Version: v0.7.0 +* Name: github.com/safchain/ethtool +* Version: v0.7.0 * License: Apache-2.0 ``` @@ -8068,35 +8016,705 @@ MIT License Copyright (c) 2019 Montgomery Edwards⁴⁴⁸ and Faye Amacker -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +``` + +## go.datum.net/compute/api/v1alpha + +* Name: go.datum.net/compute/api/v1alpha +* Version: v0.8.0 +* License: AGPL-3.0 + +``` + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Also add information on how to contact you by electronic and paper mail. + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. ``` -## go.datum.net/cloud/api/v1alpha1 +## go.datum.net/network-services-operator/api/v1alpha -* Name: go.datum.net/cloud/api/v1alpha1 -* Version: v0.1.2 +* Name: go.datum.net/network-services-operator/api/v1alpha +* Version: v0.27.0 * License: AGPL-3.0 ``` - GNU AFFERO GENERAL PUBLIC LICENSE + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. @@ -8156,7 +8774,7 @@ modification follow. TERMS AND CONDITIONS - 0. Definitions. + 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. @@ -8192,10 +8810,10 @@ feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as -a menu, a prominent item in the list meets this criterion. +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. - 1. Source Code. + 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source @@ -8237,7 +8855,7 @@ Source. The Corresponding Source for a work in source code form is that same work. - 2. Basic Permissions. + 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated @@ -8262,7 +8880,7 @@ your copyrighted material outside their relationship with you. the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article @@ -8278,7 +8896,7 @@ modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - 4. Conveying Verbatim Copies. + 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and @@ -8291,7 +8909,7 @@ recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - 5. Conveying Modified Source Versions. + 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the @@ -8328,7 +8946,7 @@ beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - 6. Conveying Non-Source Forms. + 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the @@ -8415,7 +9033,7 @@ been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified and installed. Access to a +the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. @@ -8426,7 +9044,7 @@ documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - 7. Additional Terms. + 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. @@ -8490,7 +9108,7 @@ where to find the applicable terms. form of a separately written license, or stated as exceptions; the above requirements apply either way. - 8. Termination. + 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or @@ -8518,18 +9136,18 @@ this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - 9. Acceptance Not Required for Having Copies. + 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, -nothing other than this License grants permission to propagate or +nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - 10. Automatic Licensing of Downstream Recipients. + 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and @@ -8550,11 +9168,11 @@ the predecessor has it or can get it with reasonable efforts. rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim or counterclaim) alleging that +(including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - 11. Patents. + 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The @@ -8605,25 +9223,25 @@ you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within -its scope, prohibits the exercise of, or is conditioned on the -non-exercise of one or more of the rights that are specifically granted -under this License. You may not convey a covered work if you are a -party to an arrangement with a third party that is in the business of -distributing software, under which you make payment to the third party -based on the extent of your activity of conveying the work, and under -which the third party grants, to any of the parties who would receive -the covered work from you, a discriminatory patent license (a) in -connection with copies of the covered work conveyed by you (or copies -made from those copies), or (b) primarily for and in connection with -specific products or compilations that contain the covered work, unless -you entered into that arrangement, or that patent license was granted -prior to 28 March 2007. +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - 12. No Surrender of Others' Freedom. + 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not @@ -8635,7 +9253,7 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Remote Network Interaction; Use with the GNU General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users @@ -8656,12 +9274,12 @@ License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. - 14. Revised Versions of this License. + 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail -to address new problems or concerns. +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General @@ -8682,7 +9300,7 @@ permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - 15. Disclaimer of Warranty. + 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT @@ -8693,7 +9311,7 @@ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - 16. Limitation of Liability. + 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS @@ -8705,14 +9323,14 @@ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - 17. Interpretation of Sections 15 and 16. + 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies -a copy of the Program in return for a fee. +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. END OF TERMS AND CONDITIONS @@ -8724,15 +9342,15 @@ free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively -state the exclusion of warranty. Each file should have at least +state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, @@ -8758,31 +9376,46 @@ if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . -Copyright (C) 2026 Datum Technology, Inc. +``` + +## go.miloapis.com/ipam/pkg/apis/ipam + +* Name: go.miloapis.com/ipam/pkg/apis/ipam +* Version: v0.4.0 +* License: Unknown + +``` + +``` + +## go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1 + +* Name: go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1 +* Version: v0.4.0 +* License: Unknown + +``` + +``` -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. +## go.miloapis.com/ipam/pkg/ipamerrors -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. +* Name: go.miloapis.com/ipam/pkg/ipamerrors +* Version: v0.4.0 +* License: Unknown -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . +``` ``` -## go.datum.net/network/api/v1alpha1 +## go.miloapis.com/locations/api/v1alpha1 -* Name: go.datum.net/network/api/v1alpha1 -* Version: v0.4.1 +* Name: go.miloapis.com/locations/api/v1alpha1 +* Version: v0.0.1 * License: AGPL-3.0 ``` -GNU AFFERO GENERAL PUBLIC LICENSE + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. @@ -8791,29 +9424,51 @@ GNU AFFERO GENERAL PUBLIC LICENSE Preamble - The GNU Affero General Public License is designed to make sure -that code based on our programs remains available under the same -copyleft license. Our primary motivation is that software -distributed under a copyleft license should remain free software -for all users, regardless of where the software is run. - - The GNU General Public License (GPL) was designed to ensure -that software distributed under it remains free software for all -its users. However, the GPL does not address the case where -software is distributed as a service, which is a common practice -in the software industry today. The GNU Affero General Public -License addresses this issue by requiring that the source code -of the software be made available to all users of the software, -regardless of whether the software is distributed as a service -or as a traditional software distribution. - - This license is therefore designed to ensure that software -distributed under it remains free software for all its users, -regardless of whether the software is distributed as a service -or as a traditional software distribution. It also ensures that -the source code of the software is made available to all users -of the software, regardless of whether the software is distributed -as a service or as a traditional software distribution. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -8854,10 +9509,10 @@ a computer network, with no transfer of a copy, is not conveying. to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the work -under this License, and how to view a copy of this License. If the -interface presents a list of user commands or options, such as a menu, -a prominent item in the list meets this criterion. +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. 1. Source Code. @@ -8890,7 +9545,7 @@ programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to work with, +linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. @@ -8914,27 +9569,25 @@ rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or assume -responsibilities for propagated copies, provided that you receive all -covered works in source code form. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such measures. +similar laws prohibiting or restricting circumvention of such +measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention @@ -9094,7 +9747,7 @@ unpacking, reading or copying. 7. Additional Terms. - Additional permissions are terms that supplement the terms of this + "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent @@ -9174,7 +9827,7 @@ prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have -received notice of a violation of this License (for any work) from that +received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. @@ -9294,13 +9947,12 @@ otherwise be available to you under applicable patent law. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously all your obligations under -this License and any other pertinent obligations, then as a consequence -you may not convey it at all. For example, if you agree to terms that -obligate you to collect a royalty for further conveying from those to -whom you convey the Program, the only way you could satisfy both those -terms and this License would be to refrain entirely from conveying the -Program. +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. @@ -9334,7 +9986,7 @@ address new problems or concerns. Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered -version, or of any later version published by the Free Software +version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. @@ -9398,8 +10050,8 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, @@ -12209,9 +12861,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## golang.org/x/sys +## golang.org/x/sys/unix -* Name: golang.org/x/sys +* Name: golang.org/x/sys/unix * Version: v0.49.0 * License: BSD-3-Clause @@ -15533,16 +16185,13 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## sigs.k8s.io/json +## sigs.k8s.io/gateway-api/apis/v1 -* Name: sigs.k8s.io/json -* Version: v0.0.0-20250730193827-2d320260d730 +* Name: sigs.k8s.io/gateway-api/apis/v1 +* Version: v1.5.1 * License: Apache-2.0 ``` -Files other than internal/golang/* licensed under: - - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -15731,7 +16380,7 @@ Files other than internal/golang/* licensed under: same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2020 The Kubernetes Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15745,49 +16394,18 @@ Files other than internal/golang/* licensed under: See the License for the specific language governing permissions and limitations under the License. - ------------------- - -internal/golang/* files licensed under: - - -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ``` -## sigs.k8s.io/knftables +## sigs.k8s.io/json -* Name: sigs.k8s.io/knftables -* Version: v0.0.18 +* Name: sigs.k8s.io/json +* Version: v0.0.0-20250730193827-2d320260d730 * License: Apache-2.0 ``` +Files other than internal/golang/* licensed under: + + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -15968,7 +16586,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -15976,7 +16594,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15990,6 +16608,40 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. See the License for the specific language governing permissions and limitations under the License. + +------------------ + +internal/golang/* files licensed under: + + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ``` ## sigs.k8s.io/randfill diff --git a/Taskfile.yaml b/Taskfile.yaml index 8ceaa6d3..0f6ef10d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -96,17 +96,25 @@ tasks: - buf generate generate:crd: - desc: Regenerate the FabricQuery and galactic.datumapis.com deepcopy code and CRDs (requires controller-gen) + desc: Regenerate all Galactic API deepcopy code and CRDs (requires controller-gen) cmds: - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./internal/fabric/api/... - controller-gen crd paths=./internal/fabric/api/... output:crd:artifacts:config=config/fabric-api/crd - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./api/v1alpha1/... - controller-gen crd paths=./api/v1alpha1/... output:crd:artifacts:config=config/crd + - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./api/cloud/v1alpha1/... + - controller-gen crd paths=./api/cloud/v1alpha1/... output:crd:artifacts:config=config/crd + - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./api/network/v1alpha1/... + - controller-gen crd paths=./api/network/v1alpha1/... output:crd:artifacts:config=config/crd generate:api-docs: - desc: Regenerate docs/api/galactic.md, the galactic.datumapis.com API reference + desc: Regenerate Galactic, cloud, BGP and gateway API references cmds: - go run github.com/elastic/crd-ref-docs@v0.2.0 --renderer=markdown --source-path=api/v1alpha1 --output-path=docs/api/galactic.md --config=.crd-ref-docs.yaml + - go run github.com/elastic/crd-ref-docs@v0.2.0 --renderer=markdown --source-path=api/cloud/v1alpha1 --output-path=docs/api/cloud.md --config=.crd-ref-docs-cloud.yaml + - go run github.com/elastic/crd-ref-docs@v0.2.0 --renderer=markdown --source-path=api/network/v1alpha1 --output-path=docs/api/network-bgp.md --config=.crd-ref-docs-network-bgp.yaml + - go run github.com/elastic/crd-ref-docs@v0.2.0 --renderer=markdown --source-path=api/network/v1alpha1 --output-path=docs/api/network-gateway.md --config=.crd-ref-docs-network-gateway.yaml + - python3 -c 'from pathlib import Path; files = ["docs/api/galactic.md", "docs/api/cloud.md", "docs/api/network-bgp.md", "docs/api/network-gateway.md"]; [p.write_text(p.read_text().rstrip() + "\n") for p in map(Path, files)]' build:ebpf: desc: Regenerate the eBPF uSID, edge gateway DSR, NAT egress, and XDP dispatcher datapaths (requires clang) diff --git a/api/cloud/v1alpha1/groupversion_info.go b/api/cloud/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..5ce5d1b9 --- /dev/null +++ b/api/cloud/v1alpha1/groupversion_info.go @@ -0,0 +1,38 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package v1alpha1 contains API Schema definitions for the galactic.datumapis.com/v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=galactic.datumapis.com +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is the group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "galactic.datumapis.com", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + //nolint:staticcheck // scheme.Builder is what gives the generated types a Register(). + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/cloud/v1alpha1/networkfabricidentity_types.go b/api/cloud/v1alpha1/networkfabricidentity_types.go new file mode 100644 index 00000000..1e30882d --- /dev/null +++ b/api/cloud/v1alpha1/networkfabricidentity_types.go @@ -0,0 +1,110 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkFabricIdentitySpec carries the identity the fabric knows one network +// by. +type NetworkFabricIdentitySpec struct { + // Identity is what the fabric knows the network by, the same in every + // location the network reaches. The Route Target is derived from it, which + // is what makes two locations of one network import each other's routes + // rather than behave as two networks that share a name. The VRF device is + // named from it for the same reason. + // + // It is an integer rather than an encoded string because a consumer builds + // `ASN:` from it and encodes it for its own use. It is 32 bits + // wide because that is what survives into the Route Target: the fabric + // truncates, so a wider value would be uniqueness the platform believes it + // has and the fabric does not. + // + // It is never zero and never changes. The fabric embeds it in import policy + // in every location the network reaches, so a network that changed identity + // would be a different network to everything already carrying its traffic. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=4294967295 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="identity is immutable" + Identity int64 `json:"identity"` + + // NetworkRef names the network this identity belongs to. + // + // It carries a name and no UID, deliberately. The identity is a permanent + // property of a name in a namespace, not of one object's lifetime: a + // network deleted and recreated under the same name inherits it. A UID here + // would document the opposite of the rule. + // + // +kubebuilder:validation:Required + NetworkRef NetworkFabricIdentityNetworkRef `json:"networkRef"` +} + +// NetworkFabricIdentityNetworkRef identifies the network an identity was +// allocated for. +type NetworkFabricIdentityNetworkRef struct { + // Name is the network's name. + // + // +kubebuilder:validation:Required + Name string `json:"name"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:printcolumn:name="Identity",type="integer",JSONPath=".spec.identity" +// +kubebuilder:printcolumn:name="Network",type="string",JSONPath=".spec.networkRef.name" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// NetworkFabricIdentity tells a location what identity the fabric knows a +// network by. +// +// There is one per network, not one per location. A VPC is the network's +// realization at a single location and takes its identity from here, which is +// what makes the locations of one network the same network on the fabric +// instead of unrelated ones that happen to share a name. +// +// This is platform-internal. It is written centrally and carried to the cells +// where the network is required; it never appears in a project control plane +// and no consumer reads or writes one. The identity is a value the fabric acts +// on directly, so it is kept to the platform rather than published beside the +// network it belongs to. +// +// This object is managed for you. It follows the Network it was allocated for. +type NetworkFabricIdentity struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec is the whole of this object. There is no status: federation carries + // configuration to a cell and deliberately does not carry status, so + // anything a cell has to read has to be here. + Spec NetworkFabricIdentitySpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// NetworkFabricIdentityList contains a list of NetworkFabricIdentity. +type NetworkFabricIdentityList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NetworkFabricIdentity `json:"items"` +} + +func init() { + SchemeBuilder.Register(&NetworkFabricIdentity{}, &NetworkFabricIdentityList{}) +} diff --git a/api/cloud/v1alpha1/vpc_types.go b/api/cloud/v1alpha1/vpc_types.go new file mode 100644 index 00000000..c84dd219 --- /dev/null +++ b/api/cloud/v1alpha1/vpc_types.go @@ -0,0 +1,92 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// VPCFinalizer is the finalizer applied to VPCs to ensure graceful cleanup. +const VPCFinalizer = "galactic.datumapis.com/finalizer" + +// Network is an IPv4 or IPv6 CIDR block (e.g., "10.0.0.0/24"). +// +kubebuilder:validation:MaxLength=64 +type Network string + +// VPCSpec defines the desired state of a VPC. It specifies the CIDR address space. +// +// +kubebuilder:validation:XValidation:rule="self.networks.all(n, isCIDR(n))",message="each network must be a valid IPv4 or IPv6 CIDR" +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable" +type VPCSpec struct { + // CIDR blocks that form the VPC address space. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Networks []Network `json:"networks"` +} + +// VPCStatus defines the observed state of a VPC, populated by the controller. +type VPCStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Base62-encoded VPC identifier. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=16 + VPC string `json:"vpc"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="VPC",type="string",JSONPath=".status.vpc",description="Base62 VPC identifier" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Ready status" + +// VPC represents a virtual private cloud — an isolated Layer 2 domain backed +// by one or more CIDR blocks. +type VPC struct { + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Desired CIDR address space. + // +required + Spec VPCSpec `json:"spec"` + + // Controller-observed state. + // +optional + Status VPCStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// VPCList is a list of VPC resources. +type VPCList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VPC `json:"items"` +} + +func init() { + SchemeBuilder.Register(&VPC{}, &VPCList{}) +} diff --git a/api/cloud/v1alpha1/vpcattachment_types.go b/api/cloud/v1alpha1/vpcattachment_types.go new file mode 100644 index 00000000..29ee39b5 --- /dev/null +++ b/api/cloud/v1alpha1/vpcattachment_types.go @@ -0,0 +1,217 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const VPCAttachmentAnnotation = "k8s.v1alpha1.galactic.datumapis.com/vpc-attachment" + +const ( + // ConditionTypeReady reports that identifiers are allocated and the + // NetworkAttachmentDefinition is written. + ConditionTypeReady = "Ready" + + // ConditionTypeProgrammed reports that the data plane realized the attachment. + ConditionTypeProgrammed = "Programmed" +) + +// VPCAttachmentSpec defines the desired state of VPCAttachment +type VPCAttachmentSpec struct { + // VPC this attachment belongs to. + // +required + VPC VPCRef `json:"vpc"` + + // NetworkInterface this attachment realizes. + // +optional + InterfaceRef *NetworkInterfaceRef `json:"interfaceRef,omitempty"` + + // Interface defines the network interface configuration. + // +required + Interface VPCAttachmentInterface `json:"interface"` +} + +// VPCRef references a VPC by name within the same namespace. +type VPCRef struct { + // Name is the name of the VPC. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// NetworkInterfaceRef references a networking.datumapis.com NetworkInterface in +// the same namespace. +type NetworkInterfaceRef struct { + // Name of the NetworkInterface. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` +} + +// IPAddress is an IPv4 or IPv6 address with CIDR notation. +// +kubebuilder:validation:MaxLength=64 +type IPAddress string + +// VPCAttachmentInterfaceMode is how the workload consumes the interface. It +// describes the guest, not the data plane, so a change of implementation on the +// data plane side does not move this API. +// +kubebuilder:validation:Enum=Netns;Hypervisor;HypervisorDeclared +type VPCAttachmentInterfaceMode string + +const ( + // VPCAttachmentInterfaceModeNetns moves the interface into the workload's + // network namespace, which is what a container consumes. + VPCAttachmentInterfaceModeNetns VPCAttachmentInterfaceMode = "Netns" + + // VPCAttachmentInterfaceModeHypervisor hands the interface to a hypervisor as + // a device, which is what a virtual machine guest consumes. + VPCAttachmentInterfaceModeHypervisor VPCAttachmentInterfaceMode = "Hypervisor" + + // VPCAttachmentInterfaceModeHypervisorDeclared also hands the interface to a + // hypervisor as a device. It differs from Hypervisor in who tells the + // hypervisor that the device exists. Under Hypervisor the hypervisor finds + // the device from what the node publishes. Under HypervisorDeclared the data + // plane states the device, its addresses, and its MTU to the hypervisor + // directly, which is what a guest whose hypervisor reads no node state + // needs. + VPCAttachmentInterfaceModeHypervisorDeclared VPCAttachmentInterfaceMode = "HypervisorDeclared" +) + +// VPCAttachmentInterface defines the network interface details. +// +// +kubebuilder:validation:XValidation:rule="!has(self.addresses) || self.addresses.all(a, isCIDR(a))",message="each address must be a valid IPv4 or IPv6 CIDR" +type VPCAttachmentInterface struct { + // Name of the interface (e.g., eth0). + // +required + // +default:value="eth0" + Name string `json:"name"` + + // Mode is how the workload consumes the interface, resolved and written by + // the attachment controller rather than by whoever runs the workload. + // +kubebuilder:default=Netns + // +optional + Mode VPCAttachmentInterfaceMode `json:"mode,omitempty"` + + // A list of IPv4 or IPv6 addresses associated with the interface. Empty when + // the guest manages its own addressing. + // +kubebuilder:validation:MaxItems=16 + // +optional + Addresses []IPAddress `json:"addresses,omitempty"` +} + +// VPCAttachmentStatus defines the observed state of VPCAttachment. +// +// Every field but Conditions is optional: an identifier is recorded before a pod +// attaches, and a guest managing its own addressing never reports a subnet. +type VPCAttachmentStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Base62-encoded VPC identifier. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=16 + // +optional + VPC string `json:"vpc,omitempty"` + + // Base62-encoded VPCAttachment identifier. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=16 + // +optional + VPCAttachment string `json:"vpcAttachment,omitempty"` + + // Kubernetes node name where the attachment lives. + // +kubebuilder:validation:MinLength=1 + // +optional + Node string `json:"node,omitempty"` + + // Full container ID (46 hex characters). + // +kubebuilder:validation:MinLength=46 + // +kubebuilder:validation:MaxLength=46 + // +optional + ContainerID string `json:"containerID,omitempty"` + + // Pod name. + // +kubebuilder:validation:MinLength=1 + // +optional + PodName string `json:"podName,omitempty"` + + // Host-side veth or tap device name (e.g., "G000000010013H"). + // +kubebuilder:validation:MinLength=1 + // +optional + HostInterface string `json:"hostInterface,omitempty"` + + // VRF device name, which is per-VPC (e.g., "G000000010V"). + // +kubebuilder:validation:MinLength=1 + // +optional + VRFInterface string `json:"vrfInterface,omitempty"` + + // Guest-side veth device name (e.g., "G000000010013G"). + // +kubebuilder:validation:MinLength=1 + // +optional + GuestInterface string `json:"guestInterface,omitempty"` + + // Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80"). + // +kubebuilder:validation:MinLength=1 + // +optional + // + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="podSubnet must be a valid IPv6 CIDR" + PodSubnet string `json:"podSubnet,omitempty"` + + // NetworkAttachmentDefinition rendered for this attachment. + // +kubebuilder:validation:MinLength=1 + // +optional + NetworkAttachmentDefinition string `json:"networkAttachmentDefinition,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// VPCAttachment is the Schema for the vpcattachments API +type VPCAttachment struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec defines the desired state of VPCAttachment + // +required + Spec VPCAttachmentSpec `json:"spec"` + + // status defines the observed state of VPCAttachment + // +optional + Status VPCAttachmentStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// VPCAttachmentList contains a list of VPCAttachments +type VPCAttachmentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VPCAttachment `json:"items"` +} + +func init() { + SchemeBuilder.Register(&VPCAttachment{}, &VPCAttachmentList{}) +} diff --git a/api/cloud/v1alpha1/zz_generated.deepcopy.go b/api/cloud/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..d4632475 --- /dev/null +++ b/api/cloud/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,357 @@ +//go:build !ignore_autogenerated + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkFabricIdentity) DeepCopyInto(out *NetworkFabricIdentity) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentity. +func (in *NetworkFabricIdentity) DeepCopy() *NetworkFabricIdentity { + if in == nil { + return nil + } + out := new(NetworkFabricIdentity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkFabricIdentity) 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 *NetworkFabricIdentityList) DeepCopyInto(out *NetworkFabricIdentityList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkFabricIdentity, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentityList. +func (in *NetworkFabricIdentityList) DeepCopy() *NetworkFabricIdentityList { + if in == nil { + return nil + } + out := new(NetworkFabricIdentityList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkFabricIdentityList) 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 *NetworkFabricIdentityNetworkRef) DeepCopyInto(out *NetworkFabricIdentityNetworkRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentityNetworkRef. +func (in *NetworkFabricIdentityNetworkRef) DeepCopy() *NetworkFabricIdentityNetworkRef { + if in == nil { + return nil + } + out := new(NetworkFabricIdentityNetworkRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkFabricIdentitySpec) DeepCopyInto(out *NetworkFabricIdentitySpec) { + *out = *in + out.NetworkRef = in.NetworkRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentitySpec. +func (in *NetworkFabricIdentitySpec) DeepCopy() *NetworkFabricIdentitySpec { + if in == nil { + return nil + } + out := new(NetworkFabricIdentitySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkInterfaceRef) DeepCopyInto(out *NetworkInterfaceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceRef. +func (in *NetworkInterfaceRef) DeepCopy() *NetworkInterfaceRef { + if in == nil { + return nil + } + out := new(NetworkInterfaceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPC) DeepCopyInto(out *VPC) { + *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 VPC. +func (in *VPC) DeepCopy() *VPC { + if in == nil { + return nil + } + out := new(VPC) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VPC) 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 *VPCAttachment) DeepCopyInto(out *VPCAttachment) { + *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 VPCAttachment. +func (in *VPCAttachment) DeepCopy() *VPCAttachment { + if in == nil { + return nil + } + out := new(VPCAttachment) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VPCAttachment) 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 *VPCAttachmentInterface) DeepCopyInto(out *VPCAttachmentInterface) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]IPAddress, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCAttachmentInterface. +func (in *VPCAttachmentInterface) DeepCopy() *VPCAttachmentInterface { + if in == nil { + return nil + } + out := new(VPCAttachmentInterface) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCAttachmentList) DeepCopyInto(out *VPCAttachmentList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VPCAttachment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCAttachmentList. +func (in *VPCAttachmentList) DeepCopy() *VPCAttachmentList { + if in == nil { + return nil + } + out := new(VPCAttachmentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VPCAttachmentList) 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 *VPCAttachmentSpec) DeepCopyInto(out *VPCAttachmentSpec) { + *out = *in + out.VPC = in.VPC + if in.InterfaceRef != nil { + in, out := &in.InterfaceRef, &out.InterfaceRef + *out = new(NetworkInterfaceRef) + **out = **in + } + in.Interface.DeepCopyInto(&out.Interface) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCAttachmentSpec. +func (in *VPCAttachmentSpec) DeepCopy() *VPCAttachmentSpec { + if in == nil { + return nil + } + out := new(VPCAttachmentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCAttachmentStatus) DeepCopyInto(out *VPCAttachmentStatus) { + *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 VPCAttachmentStatus. +func (in *VPCAttachmentStatus) DeepCopy() *VPCAttachmentStatus { + if in == nil { + return nil + } + out := new(VPCAttachmentStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCList) DeepCopyInto(out *VPCList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VPC, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCList. +func (in *VPCList) DeepCopy() *VPCList { + if in == nil { + return nil + } + out := new(VPCList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VPCList) 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 *VPCRef) DeepCopyInto(out *VPCRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCRef. +func (in *VPCRef) DeepCopy() *VPCRef { + if in == nil { + return nil + } + out := new(VPCRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCSpec) DeepCopyInto(out *VPCSpec) { + *out = *in + if in.Networks != nil { + in, out := &in.Networks, &out.Networks + *out = make([]Network, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCSpec. +func (in *VPCSpec) DeepCopy() *VPCSpec { + if in == nil { + return nil + } + out := new(VPCSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCStatus) DeepCopyInto(out *VPCStatus) { + *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 VPCStatus. +func (in *VPCStatus) DeepCopy() *VPCStatus { + if in == nil { + return nil + } + out := new(VPCStatus) + in.DeepCopyInto(out) + return out +} diff --git a/api/network/v1alpha1/advertisement_types.go b/api/network/v1alpha1/advertisement_types.go new file mode 100644 index 00000000..5f6d07ac --- /dev/null +++ b/api/network/v1alpha1/advertisement_types.go @@ -0,0 +1,185 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Community is a BGP community in ASN:NN or IP:NN format. +// +kubebuilder:validation:MaxLength=32 +type Community string + +// Prefix is an IPv4 or IPv6 CIDR prefix. +// +kubebuilder:validation:MaxLength=64 +// +kubebuilder:validation:XValidation:rule="self.matches('^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+/[0-9]{1,2}$|^[0-9a-fA-F:]+::?[0-9a-fA-F:]+/[0-9]{1,3}$')",message="must be a valid IPv4 or IPv6 CIDR" +type Prefix string + +// RedistributeSource is a local routing table source to redistribute into BGP. +// +// +kubebuilder:validation:Enum=static;connected;kernel +type RedistributeSource string + +const ( + // RedistributeSourceStatic redistributes statically configured routes. + RedistributeSourceStatic RedistributeSource = "static" + + // RedistributeSourceConnected redistributes directly connected interface routes. + RedistributeSourceConnected RedistributeSource = "connected" + + // RedistributeSourceKernel redistributes routes from the kernel routing table. + RedistributeSourceKernel RedistributeSource = "kernel" +) + +// AdvertisementOriginateType is the source from which routes are originated. +// +// +kubebuilder:validation:Enum=interface;kernel +type AdvertisementOriginateType string + +const ( + // OriginateTypeInterface originates routes from local interface addresses. + OriginateTypeInterface AdvertisementOriginateType = "interface" + + // OriginateTypeKernel originates routes learned from the kernel routing table. + OriginateTypeKernel AdvertisementOriginateType = "kernel" +) + +// AdvertisementOriginateFrom defines how routes are sourced from a local system +// resource rather than from the static Prefixes list. +// +// +kubebuilder:validation:XValidation:rule="self.type == 'interface' ? has(self.interfaceName) : true",message="interfaceName is required when type is interface" +// +kubebuilder:validation:XValidation:rule="self.type == 'kernel' ? !has(self.interfaceName) : true",message="interfaceName must not be set when type is kernel" +type AdvertisementOriginateFrom struct { + // Type is the source from which routes are originated. + // +kubebuilder:validation:Enum=interface;kernel + Type AdvertisementOriginateType `json:"type"` + + // InterfaceName is the name of the interface to originate routes from. + // Required when type is "interface". + // +optional + // +kubebuilder:validation:MinLength=1 + InterfaceName *string `json:"interfaceName,omitempty"` +} + +// AdvertisementPolicyRef references a BGPPolicy by name to apply as a conditional +// filter before advertisement. +type AdvertisementPolicyRef struct { + // Name is the name of the BGPPolicy within the same namespace. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// BGPAdvertisement defines routing information to advertise from a single BGPRouter. +// Routes are originated from static Prefixes, redistributed routing table entries, +// or local interface/kernel routes. Fan-out via routerSelector is intentionally not +// supported to avoid ambiguous prefix attribution across multiple routers. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgpadv +// +kubebuilder:printcolumn:name="ROUTER",type="string",JSONPath=".spec.routerRef.name" +// +kubebuilder:printcolumn:name="ADDRESS-FAMILY",type="string",JSONPath=".spec.addressFamily.afi" +// +kubebuilder:printcolumn:name="PREFIXES",type="integer",JSONPath=".status.advertisedPrefixes" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPAdvertisement struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPAdvertisementSpec `json:"spec,omitempty"` + Status BGPAdvertisementStatus `json:"status,omitempty"` +} + +// BGPAdvertisementSpec defines the desired advertisement state. +// At least one of prefixes, redistribute, or originateFrom must be specified. +// +// +kubebuilder:validation:XValidation:rule="(has(self.prefixes) && self.prefixes.size() > 0) || (has(self.redistribute) && self.redistribute.size() > 0) || has(self.originateFrom)",message="at least one of prefixes, redistribute, or originateFrom must be specified" +type BGPAdvertisementSpec struct { + // RouterRef targets a single BGPRouter by name. + // +kubebuilder:validation:Required + RouterRef RouterRef `json:"routerRef"` + + // AddressFamily defines the AFI/SAFI for this advertisement. + // +kubebuilder:validation:Required + AddressFamily AddressFamily `json:"addressFamily"` + + // Prefixes is the list of CIDR prefixes to advertise. + // At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=set + Prefixes []Prefix `json:"prefixes,omitempty"` + + // Redistribute defines routing table sources to redistribute into BGP. + // Routes matching the source type are originated without requiring explicit CIDR entries. + // At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + // +optional + // +kubebuilder:validation:MaxItems=3 + // +listType=set + Redistribute []RedistributeSource `json:"redistribute,omitempty"` + + // OriginateFrom defines how routes are sourced from a local interface or kernel table. + // When set, routes are originated from the specified source in addition to any static Prefixes. + // At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + // +optional + OriginateFrom *AdvertisementOriginateFrom `json:"originateFrom,omitempty"` + + // PolicyRef references a BGPPolicy to apply as a conditional filter before advertisement. + // Only routes that match the policy are originated. + // +optional + PolicyRef *AdvertisementPolicyRef `json:"policyRef,omitempty"` + + // Communities is the default list of BGP communities to attach to all advertised prefixes. + // Per-prefix communities in Prefixes[n].communities replace this value for individual prefixes. + // +optional + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:XValidation:rule="self.all(c, c.matches('^[0-9]{1,10}:[0-9]{1,10}$') || c.matches('^[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}:[0-9]{1,10}$'))",message="community must be in ASN:NN or IP:NN format" + Communities []Community `json:"communities,omitempty"` + + // LocalPreference sets the default BGP LOCAL_PREF attribute for all advertised prefixes. + // Per-prefix localPreference in Prefixes[n].localPreference overrides this value. + // Only meaningful for iBGP sessions. + // +optional + // +kubebuilder:validation:Minimum=0 + LocalPreference *int32 `json:"localPreference,omitempty"` + + // VRFID is the 16-bit PoP-local VRF identifier this advertisement's SRv6 + // SID resolves into, per RFC 9800 uSID Argument addressing. Required when + // Function is set. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + VRFID *int32 `json:"vrfID,omitempty"` + + // Function is the RFC 8986 SRv6 endpoint behavior applied when this + // advertisement originates a compressed uSID. Required when VRFID is set. + // +optional + Function *SRv6Function `json:"function,omitempty"` +} + +// BGPAdvertisementStatus defines the observed state of BGPAdvertisement. +type BGPAdvertisementStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // AdvertisedPrefixes is the count of prefixes currently being originated. + // +optional + AdvertisedPrefixes int32 `json:"advertisedPrefixes,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPAdvertisementList is a list of BGPAdvertisement resources. +// +kubebuilder:object:root=true +type BGPAdvertisementList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPAdvertisement `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPAdvertisement{}, &BGPAdvertisementList{}) +} diff --git a/api/network/v1alpha1/advertisement_types_test.go b/api/network/v1alpha1/advertisement_types_test.go new file mode 100644 index 00000000..c4dfad88 --- /dev/null +++ b/api/network/v1alpha1/advertisement_types_test.go @@ -0,0 +1,398 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ptr[T any](v T) *T { return &v } + +func newTestAdvertisement() *BGPAdvertisement { + return &BGPAdvertisement{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPAdvertisement", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-adv"}, + Spec: BGPAdvertisementSpec{ + RouterRef: RouterRef{Name: "test-router"}, + AddressFamily: AddressFamily{AFI: AFIIPv6, SAFI: SAFIUnicast}, + Prefixes: []Prefix{"2001:db8::/48"}, + }, + } +} + +// TestBGPAdvertisementDeepCopy verifies DeepCopy produces an independent copy. +func TestBGPAdvertisementDeepCopy(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.Communities = []Community{"65000:100"} + orig.Spec.LocalPreference = ptr(int32(200)) + + dup := orig.DeepCopy() + + dup.Spec.RouterRef.Name = "other-router" + dup.Spec.Communities[0] = "65001:999" + *dup.Spec.LocalPreference = 999 + + if orig.Spec.RouterRef.Name != "test-router" { + t.Errorf("RouterRef mutated: got %q", orig.Spec.RouterRef.Name) + } + if orig.Spec.Communities[0] != "65000:100" { + t.Errorf("Communities[0] mutated: got %q", orig.Spec.Communities[0]) + } + if *orig.Spec.LocalPreference != 200 { + t.Errorf("LocalPreference mutated: got %d", *orig.Spec.LocalPreference) + } +} + +// TestBGPAdvertisementDeepCopyNil verifies DeepCopy on nil returns nil. +func TestBGPAdvertisementDeepCopyNil(t *testing.T) { + var a *BGPAdvertisement + if a.DeepCopy() != nil { + t.Error("DeepCopy on nil should return nil") + } +} + +// TestBGPAdvertisementJSONRoundTrip verifies JSON serialisation round-trips correctly. +func TestBGPAdvertisementJSONRoundTrip(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.Prefixes = []Prefix{"2001:db8::/48", "10.0.0.0/8"} + orig.Spec.Communities = []Community{"65000:100"} + orig.Spec.LocalPreference = ptr(int32(100)) + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPAdvertisement + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.RouterRef.Name != orig.Spec.RouterRef.Name { + t.Errorf("RouterRef: got %q, want %q", got.Spec.RouterRef.Name, orig.Spec.RouterRef.Name) + } + if len(got.Spec.Prefixes) != 2 { + t.Fatalf("Prefixes len: got %d, want 2", len(got.Spec.Prefixes)) + } + if got.Spec.Prefixes[0] != "2001:db8::/48" { + t.Errorf("Prefixes[0]: got %q, want 2001:db8::/48", got.Spec.Prefixes[0]) + } + if got.Spec.Prefixes[1] != "10.0.0.0/8" { + t.Errorf("Prefixes[1]: got %q, want 10.0.0.0/8", got.Spec.Prefixes[1]) + } + if got.Spec.Communities[0] != "65000:100" { + t.Errorf("Communities[0]: got %q, want 65000:100", got.Spec.Communities[0]) + } + if got.Spec.LocalPreference == nil || *got.Spec.LocalPreference != 100 { + t.Errorf("LocalPreference: got %v, want 100", got.Spec.LocalPreference) + } +} + +// TestBGPAdvertisementRedistributeJSONRoundTrip verifies redistribution sources +// serialise and deserialise correctly. +func TestBGPAdvertisementRedistributeJSONRoundTrip(t *testing.T) { + orig := &BGPAdvertisement{ + TypeMeta: metav1.TypeMeta{APIVersion: "galactic.datumapis.com/v1alpha1", Kind: "BGPAdvertisement"}, + ObjectMeta: metav1.ObjectMeta{Name: "test-redistribute"}, + Spec: BGPAdvertisementSpec{ + RouterRef: RouterRef{Name: "test-router"}, + AddressFamily: AddressFamily{AFI: AFIIPv4, SAFI: SAFIUnicast}, + Redistribute: []RedistributeSource{ + RedistributeSourceStatic, + RedistributeSourceConnected, + }, + }, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPAdvertisement + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Spec.Redistribute) != 2 { + t.Fatalf("Redistribute len: got %d, want 2", len(got.Spec.Redistribute)) + } + if got.Spec.Redistribute[0] != RedistributeSourceStatic { + t.Errorf("Redistribute[0]: got %q, want %q", got.Spec.Redistribute[0], RedistributeSourceStatic) + } + if got.Spec.Redistribute[1] != RedistributeSourceConnected { + t.Errorf("Redistribute[1]: got %q, want %q", got.Spec.Redistribute[1], RedistributeSourceConnected) + } +} + +// TestBGPAdvertisementOriginateFromJSONRoundTrip verifies originateFrom serialises correctly. +func TestBGPAdvertisementOriginateFromJSONRoundTrip(t *testing.T) { + orig := &BGPAdvertisement{ + TypeMeta: metav1.TypeMeta{APIVersion: "galactic.datumapis.com/v1alpha1", Kind: "BGPAdvertisement"}, + ObjectMeta: metav1.ObjectMeta{Name: "test-originate"}, + Spec: BGPAdvertisementSpec{ + RouterRef: RouterRef{Name: "test-router"}, + AddressFamily: AddressFamily{AFI: AFIIPv6, SAFI: SAFIUnicast}, + OriginateFrom: &AdvertisementOriginateFrom{ + Type: OriginateTypeInterface, + InterfaceName: ptr("lo"), + }, + }, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPAdvertisement + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.OriginateFrom == nil { + t.Fatal("OriginateFrom is nil after round-trip") + } + if got.Spec.OriginateFrom.Type != OriginateTypeInterface { + t.Errorf("OriginateFrom.Type: got %q, want %q", got.Spec.OriginateFrom.Type, OriginateTypeInterface) + } + if got.Spec.OriginateFrom.InterfaceName == nil || *got.Spec.OriginateFrom.InterfaceName != "lo" { + t.Errorf("OriginateFrom.InterfaceName: got %v, want lo", got.Spec.OriginateFrom.InterfaceName) + } +} + +// TestBGPAdvertisementPolicyRefJSONRoundTrip verifies policyRef serialises correctly. +func TestBGPAdvertisementPolicyRefJSONRoundTrip(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.PolicyRef = &AdvertisementPolicyRef{Name: "export-filter"} + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPAdvertisement + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.PolicyRef == nil { + t.Fatal("PolicyRef is nil after round-trip") + } + if got.Spec.PolicyRef.Name != "export-filter" { + t.Errorf("PolicyRef.Name: got %q, want export-filter", got.Spec.PolicyRef.Name) + } +} + +// TestBGPAdvertisementDeepCopyWithAllFields verifies DeepCopy correctly +// handles all pointer and slice fields. +func TestBGPAdvertisementDeepCopyWithAllFields(t *testing.T) { + orig := &BGPAdvertisement{ + TypeMeta: metav1.TypeMeta{APIVersion: "galactic.datumapis.com/v1alpha1", Kind: "BGPAdvertisement"}, + ObjectMeta: metav1.ObjectMeta{Name: "full-adv"}, + Spec: BGPAdvertisementSpec{ + RouterRef: RouterRef{Name: "test-router"}, + AddressFamily: AddressFamily{AFI: AFIIPv6, SAFI: SAFIUnicast}, + Prefixes: []Prefix{"2001:db8::/48"}, + Redistribute: []RedistributeSource{RedistributeSourceKernel}, + OriginateFrom: &AdvertisementOriginateFrom{ + Type: OriginateTypeInterface, + InterfaceName: ptr("eth0"), + }, + PolicyRef: &AdvertisementPolicyRef{Name: "my-policy"}, + Communities: []Community{"65000:99"}, + LocalPreference: ptr(int32(50)), + }, + } + + dup := orig.DeepCopy() + + // Mutate dup — original must be unaffected. + dup.Spec.Prefixes[0] = "10.0.0.0/8" + dup.Spec.Redistribute[0] = RedistributeSourceStatic + dup.Spec.OriginateFrom.Type = OriginateTypeKernel + *dup.Spec.OriginateFrom.InterfaceName = "eth1" + dup.Spec.PolicyRef.Name = "other-policy" + dup.Spec.Communities[0] = "99999:99" + *dup.Spec.LocalPreference = 999 + + if orig.Spec.Prefixes[0] != "2001:db8::/48" { + t.Errorf("Prefixes[0] mutated: got %q", orig.Spec.Prefixes[0]) + } + if orig.Spec.Redistribute[0] != RedistributeSourceKernel { + t.Errorf("Redistribute[0] mutated: got %q", orig.Spec.Redistribute[0]) + } + if orig.Spec.OriginateFrom.Type != OriginateTypeInterface { + t.Errorf("OriginateFrom.Type mutated: got %q", orig.Spec.OriginateFrom.Type) + } + if *orig.Spec.OriginateFrom.InterfaceName != "eth0" { + t.Errorf("OriginateFrom.InterfaceName mutated: got %q", *orig.Spec.OriginateFrom.InterfaceName) + } + if orig.Spec.PolicyRef.Name != "my-policy" { + t.Errorf("PolicyRef.Name mutated: got %q", orig.Spec.PolicyRef.Name) + } + if orig.Spec.Communities[0] != "65000:99" { + t.Errorf("Communities[0] mutated: got %q", orig.Spec.Communities[0]) + } + if *orig.Spec.LocalPreference != 50 { + t.Errorf("LocalPreference mutated: got %d", *orig.Spec.LocalPreference) + } +} + +// TestBGPAdvertisementListDeepCopy verifies BGPAdvertisementList.DeepCopy +// produces independent copies of each item. +func TestBGPAdvertisementListDeepCopy(t *testing.T) { + list := &BGPAdvertisementList{ + Items: []BGPAdvertisement{*newTestAdvertisement()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.RouterRef.Name = "mutated" + + if list.Items[0].Spec.RouterRef.Name != "test-router" { + t.Errorf("original list item mutated via copy") + } +} + +// TestBGPAdvertisementSRv6JSONRoundTrip verifies VRFID and Function serialise +// and deserialise correctly. +func TestBGPAdvertisementSRv6JSONRoundTrip(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.VRFID = ptr(int32(100)) + orig.Spec.Function = ptr(SRv6FunctionEndDT6) + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPAdvertisement + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.VRFID == nil || *got.Spec.VRFID != 100 { + t.Errorf("VRFID: got %v, want 100", got.Spec.VRFID) + } + if got.Spec.Function == nil || *got.Spec.Function != SRv6FunctionEndDT6 { + t.Errorf("Function: got %v, want %q", got.Spec.Function, SRv6FunctionEndDT6) + } +} + +// TestBGPAdvertisementSRv6FieldNames verifies the JSON keys are "vrfID" and "function". +func TestBGPAdvertisementSRv6FieldNames(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.VRFID = ptr(int32(100)) + orig.Spec.Function = ptr(SRv6FunctionEndDT46) + + data, err := json.Marshal(orig.Spec) + 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) + } + + if raw, ok := m["vrfID"]; !ok || raw != float64(100) { + t.Errorf("unexpected vrfID value: %v", raw) + } + if raw, ok := m["function"]; !ok || raw != "End.DT46" { + t.Errorf("unexpected function value: %v", raw) + } +} + +// TestBGPAdvertisementSRv6OmitEmpty verifies that VRFID and Function are +// omitted from JSON output when unset. +func TestBGPAdvertisementSRv6OmitEmpty(t *testing.T) { + orig := newTestAdvertisement() + + data, err := json.Marshal(orig.Spec) + 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) + } + + if _, ok := m["vrfID"]; ok { + t.Error("expected \"vrfID\" key to be absent when empty") + } + if _, ok := m["function"]; ok { + t.Error("expected \"function\" key to be absent when empty") + } +} + +// TestBGPAdvertisementSRv6DeepCopy verifies DeepCopy isolates VRFID and Function. +func TestBGPAdvertisementSRv6DeepCopy(t *testing.T) { + orig := newTestAdvertisement() + orig.Spec.VRFID = ptr(int32(100)) + orig.Spec.Function = ptr(SRv6FunctionEndDT4) + + dup := orig.DeepCopy() + *dup.Spec.VRFID = 999 + *dup.Spec.Function = SRv6FunctionEndDT6 + + if *orig.Spec.VRFID != 100 { + t.Errorf("VRFID mutated: got %d", *orig.Spec.VRFID) + } + if *orig.Spec.Function != SRv6FunctionEndDT4 { + t.Errorf("Function mutated: got %q", *orig.Spec.Function) + } +} + +// TestSRv6FunctionConstants verifies enum values match expected strings. +func TestSRv6FunctionConstants(t *testing.T) { + cases := []struct { + fn SRv6Function + want string + }{ + {SRv6FunctionEndDT4, "End.DT4"}, + {SRv6FunctionEndDT6, "End.DT6"}, + {SRv6FunctionEndDT46, "End.DT46"}, + } + for _, tt := range cases { + if string(tt.fn) != tt.want { + t.Errorf("SRv6Function %q: got %q, want %q", tt.fn, string(tt.fn), tt.want) + } + } +} + +// TestRedistributeSourceConstants verifies enum values match expected strings. +func TestRedistributeSourceConstants(t *testing.T) { + cases := []struct { + src RedistributeSource + want string + }{ + {RedistributeSourceStatic, "static"}, + {RedistributeSourceConnected, "connected"}, + {RedistributeSourceKernel, "kernel"}, + } + for _, tt := range cases { + if string(tt.src) != tt.want { + t.Errorf("RedistributeSource %q: got %q, want %q", tt.src, string(tt.src), tt.want) + } + } +} + +// TestOriginateTypeConstants verifies originate type enum values. +func TestOriginateTypeConstants(t *testing.T) { + cases := []struct { + typ AdvertisementOriginateType + want string + }{ + {OriginateTypeInterface, "interface"}, + {OriginateTypeKernel, "kernel"}, + } + for _, tt := range cases { + if string(tt.typ) != tt.want { + t.Errorf("AdvertisementOriginateType %q: got %q, want %q", tt.typ, string(tt.typ), tt.want) + } + } +} diff --git a/api/network/v1alpha1/communityset_types.go b/api/network/v1alpha1/communityset_types.go new file mode 100644 index 00000000..93826290 --- /dev/null +++ b/api/network/v1alpha1/communityset_types.go @@ -0,0 +1,83 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// BGPCommunitySetType distinguishes standard from large BGP communities. +// +// +kubebuilder:validation:Enum=standard;large +type BGPCommunitySetType string + +const ( + // BGPCommunitySetTypeStandard covers ASN:NN, IP:NN, and well-known names + // (graceful-shutdown, no-export, no-advertise, blackhole). + BGPCommunitySetTypeStandard BGPCommunitySetType = "standard" + + // BGPCommunitySetTypeLarge covers ASN:NN:NN large communities. + BGPCommunitySetTypeLarge BGPCommunitySetType = "large" +) + +// BGPCommunitySet defines a named list of BGP community values used by BGPPolicy +// terms to match or set communities on routes. It is referenced by name from +// BGPPolicyMatch.communitySetRef. +// +// A single BGPCommunitySet holds either standard or large communities — the type +// is declared upfront and all entries are validated against it via CEL. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgpcs +// +kubebuilder:printcolumn:name="TYPE",type="string",JSONPath=".spec.type" +// +kubebuilder:printcolumn:name="ENTRIES",type="integer",JSONPath=".spec.entries" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPCommunitySet struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPCommunitySetSpec `json:"spec,omitempty"` + Status BGPCommunitySetStatus `json:"status,omitempty"` +} + +// BGPCommunitySetSpec defines the desired state of BGPCommunitySet. +// +// +kubebuilder:validation:XValidation:rule="self.type == 'standard' ? self.entries.all(e, e.matches('^[0-9]{1,10}:[0-9]{1,10}$') || e.matches('^[0-9]{1,3}[.][0-9]{1,3}[.][0-9]{1,3}[.][0-9]{1,3}:[0-9]{1,10}$') || e == 'graceful-shutdown' || e == 'no-export' || e == 'no-advertise' || e == 'blackhole') : true",message="standard entries must be ASN:NN, IP:NN, or a well-known name" +// +kubebuilder:validation:XValidation:rule="self.type == 'large' ? self.entries.all(e, e.matches('^[0-9]{1,10}:[0-9]{1,10}:[0-9]{1,10}$')) : true",message="large entries must be ASN:NN:NN" +type BGPCommunitySetSpec struct { + // Type is the community format for all entries in this set. + // +kubebuilder:validation:Required + Type BGPCommunitySetType `json:"type"` + + // Entries is the list of community values. + // Standard: ASN:NN, IP:NN, or well-known names (graceful-shutdown, no-export, + // no-advertise, blackhole). + // Large: ASN:NN:NN. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=256 + // +kubebuilder:validation:items:MaxLength=32 + Entries []string `json:"entries"` +} + +// BGPCommunitySetStatus defines the observed state of BGPCommunitySet. +type BGPCommunitySetStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPCommunitySetList is a list of BGPCommunitySet resources. +// +kubebuilder:object:root=true +type BGPCommunitySetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPCommunitySet `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPCommunitySet{}, &BGPCommunitySetList{}) +} diff --git a/api/network/v1alpha1/communityset_types_test.go b/api/network/v1alpha1/communityset_types_test.go new file mode 100644 index 00000000..2bfafb38 --- /dev/null +++ b/api/network/v1alpha1/communityset_types_test.go @@ -0,0 +1,229 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestCommunitySet() *BGPCommunitySet { + return &BGPCommunitySet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPCommunitySet", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-comm"}, + Spec: BGPCommunitySetSpec{ + Type: BGPCommunitySetTypeStandard, + Entries: []string{"65000:100", "65000:200"}, + }, + } +} + +// TestBGPCommunitySetDeepCopy verifies that DeepCopy produces an independent copy. +func TestBGPCommunitySetDeepCopy(t *testing.T) { + orig := newTestCommunitySet() + dup := orig.DeepCopy() + + dup.Spec.Entries[0] = "65001:999" + dup.Spec.Type = BGPCommunitySetTypeLarge + + if orig.Spec.Entries[0] != "65000:100" { + t.Errorf("Entry mutated: got %q", orig.Spec.Entries[0]) + } + if orig.Spec.Type != BGPCommunitySetTypeStandard { + t.Errorf("Type mutated: got %q", orig.Spec.Type) + } +} + +// TestBGPCommunitySetDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestBGPCommunitySetDeepCopyNil(t *testing.T) { + var c *BGPCommunitySet + if c.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestBGPCommunitySetJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestBGPCommunitySetJSONRoundTrip(t *testing.T) { + orig := newTestCommunitySet() + orig.Spec.Entries = []string{"65000:100", "no-export", "10.0.0.1:666"} + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPCommunitySet + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.Type != BGPCommunitySetTypeStandard { + t.Errorf("Type: got %q, want %q", got.Spec.Type, BGPCommunitySetTypeStandard) + } + if len(got.Spec.Entries) != 3 { + t.Fatalf("Entries count: got %d, want 3", len(got.Spec.Entries)) + } + if got.Spec.Entries[0] != "65000:100" { + t.Errorf("Entry[0]: got %q, want %q", got.Spec.Entries[0], "65000:100") + } + if got.Spec.Entries[1] != "no-export" { + t.Errorf("Entry[1]: got %q, want %q", got.Spec.Entries[1], "no-export") + } + if got.Spec.Entries[2] != "10.0.0.1:666" { + t.Errorf("Entry[2]: got %q, want %q", got.Spec.Entries[2], "10.0.0.1:666") + } +} + +// TestBGPCommunitySetListDeepCopy verifies that BGPCommunitySetList.DeepCopy produces +// independent copies of each item. +func TestBGPCommunitySetListDeepCopy(t *testing.T) { + list := &BGPCommunitySetList{ + Items: []BGPCommunitySet{*newTestCommunitySet()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.Entries[0] = "99999:999" + + if list.Items[0].Spec.Entries[0] != "65000:100" { + t.Error("original list item mutated via copy") + } +} + +// TestBGPCommunitySetTypeConstants verifies the enum constants. +func TestBGPCommunitySetTypeConstants(t *testing.T) { + if BGPCommunitySetTypeStandard != "standard" { + t.Errorf("BGPCommunitySetTypeStandard = %q, want %q", BGPCommunitySetTypeStandard, "standard") + } + if BGPCommunitySetTypeLarge != "large" { + t.Errorf("BGPCommunitySetTypeLarge = %q, want %q", BGPCommunitySetTypeLarge, "large") + } +} + +// TestBGPCommunitySetLargeCommunities verifies large community entries survive +// JSON round-trip. +func TestBGPCommunitySetLargeCommunities(t *testing.T) { + cs := &BGPCommunitySet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPCommunitySet", + }, + ObjectMeta: metav1.ObjectMeta{Name: "large-comm"}, + Spec: BGPCommunitySetSpec{ + Type: BGPCommunitySetTypeLarge, + Entries: []string{"65000:100:1", "65000:100:2", "65001:200:1"}, + }, + } + + data, err := json.Marshal(cs) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPCommunitySet + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.Type != BGPCommunitySetTypeLarge { + t.Errorf("Type: got %q, want %q", got.Spec.Type, BGPCommunitySetTypeLarge) + } + if len(got.Spec.Entries) != 3 { + t.Fatalf("Entries count: got %d, want 3", len(got.Spec.Entries)) + } + if got.Spec.Entries[0] != "65000:100:1" { + t.Errorf("Entry[0]: got %q, want %q", got.Spec.Entries[0], "65000:100:1") + } +} + +// TestBGPCommunitySetWellKnownNames verifies well-known community names +// survive JSON round-trip. +func TestBGPCommunitySetWellKnownNames(t *testing.T) { + cs := &BGPCommunitySet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPCommunitySet", + }, + ObjectMeta: metav1.ObjectMeta{Name: "wellknown-comm"}, + Spec: BGPCommunitySetSpec{ + Type: BGPCommunitySetTypeStandard, + Entries: []string{"graceful-shutdown", "no-export", "no-advertise", "blackhole"}, + }, + } + + data, err := json.Marshal(cs) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPCommunitySet + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Spec.Entries) != 4 { + t.Fatalf("Entries count: got %d, want 4", len(got.Spec.Entries)) + } + + wantNames := []string{"graceful-shutdown", "no-export", "no-advertise", "blackhole"} + for i, want := range wantNames { + if got.Spec.Entries[i] != want { + t.Errorf("Entry[%d]: got %q, want %q", i, got.Spec.Entries[i], want) + } + } +} + +// TestBGPCommunitySetFieldNames verifies the JSON key names for spec fields. +func TestBGPCommunitySetFieldNames(t *testing.T) { + cs := newTestCommunitySet() + + data, err := json.Marshal(cs.Spec) + 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) + } + + for _, key := range []string{"type", "entries"} { + if _, ok := m[key]; !ok { + t.Fatalf("expected JSON key %q not found", key) + } + } +} + +// TestBGPCommunitySetIPFormat verifies IP-based standard communities (IP:NN). +func TestBGPCommunitySetIPFormat(t *testing.T) { + cs := &BGPCommunitySet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPCommunitySet", + }, + ObjectMeta: metav1.ObjectMeta{Name: "ip-comm"}, + Spec: BGPCommunitySetSpec{ + Type: BGPCommunitySetTypeStandard, + Entries: []string{"192.0.2.1:100", "198.51.100.5:200"}, + }, + } + + data, err := json.Marshal(cs) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPCommunitySet + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Spec.Entries) != 2 { + t.Fatalf("Entries count: got %d, want 2", len(got.Spec.Entries)) + } + if got.Spec.Entries[0] != "192.0.2.1:100" { + t.Errorf("Entry[0]: got %q, want %q", got.Spec.Entries[0], "192.0.2.1:100") + } +} diff --git a/api/network/v1alpha1/egresspolicy_types.go b/api/network/v1alpha1/egresspolicy_types.go new file mode 100644 index 00000000..122c8573 --- /dev/null +++ b/api/network/v1alpha1/egresspolicy_types.go @@ -0,0 +1,95 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkEgressPolicy enables internet egress for a single tenant +// VPC/VPCAttachment, served by the sharded, stateful galactic-nat tier +// (see EgressShard). Unlike NetworkService, 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 +// NetworkService's vpcRef, vpcRef/vpcAttachmentRef are opaque string identifiers +// because this kind carries identifiers from the VPC API in +// api/cloud/v1alpha1 without a Kubernetes object reference. 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's VRF get an egress route toward the shared +// translation tier at all) — unlike this type's original design (superseded), there is +// no single "assigned gateway node" to compute or pin: any EgressShard may +// serve any tenant's flow, chosen by the shard-placement consistent-hash +// ring (internal/maglev, keyed on (tenant VRFID, backend, destination) — +// see EgressShard's doc comment), not by a per-tenant node assignment stored +// here. *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 VRFID +// carried in the tenant's own SRv6 Argument, 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="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"` + + // 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/network/v1alpha1/egresspolicy_types_test.go b/api/network/v1alpha1/egresspolicy_types_test.go new file mode 100644 index 00000000..42e2b518 --- /dev/null +++ b/api/network/v1alpha1/egresspolicy_types_test.go @@ -0,0 +1,157 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestEgressPolicy() *NetworkEgressPolicy { + return &NetworkEgressPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.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) + } + } +} + +// TestNetworkEgressPolicyStatusHasNoAssignedGatewayNode is a regression +// test: the earlier design pinned a policy to a single gateway node's +// masquerade datapath. The sharded galactic-nat tier has no such fixed +// assignment — any EgressShard may serve any tenant's flow, chosen by the +// shard-placement consistent-hash ring, not a per-tenant node stored here. +func TestNetworkEgressPolicyStatusHasNoAssignedGatewayNode(t *testing.T) { + orig := newTestEgressPolicy() + orig.Status.Conditions = []metav1.Condition{{Type: ConditionTypeAccepted}} + + 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 _, ok := status["assignedGatewayNode"]; ok { + t.Errorf("unexpected assignedGatewayNode field present in status: %v", status) + } +} diff --git a/api/network/v1alpha1/egressshard_types.go b/api/network/v1alpha1/egressshard_types.go new file mode 100644 index 00000000..9df8caf5 --- /dev/null +++ b/api/network/v1alpha1/egressshard_types.go @@ -0,0 +1,406 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EgressShard marks a single node as a member of the sharded, stateful egress +// translation tier (galactic-nat) — a component deliberately kept off the +// ingress load-balancer's own consistent-hash ring (see NetworkGateway): +// tenant egress traffic (backend -> arbitrary internet destination) is a +// different traffic pattern from ingress (fixed VIP, fixed backend pool) +// and needs its own placement ring, own per-flow state, and its own +// self-routing return path, entirely independent of any NetworkGateway node. +// +// A shard serves one or both address families. NAT66 (IPv6 -> IPv6) and NAT64 +// (IPv6 -> IPv4, RFC 6146) are the same function — stateful egress PAT with a +// VRF-scoped session table — over different families, so one shard object +// describes both rather than there being a second, near-duplicate kind. +// Spec.ShardAddressIPv6 and Spec.ShardAddressIPv4 are each assigned only for +// the family this shard translates; a shard with no IPv4 address performs no +// NAT64. +// +// Every shard owns a dedicated, publicly-routable address per family it +// serves, and a flow's allocated masquerade port lives within it — so a reply +// is delivered to the correct shard by ordinary unicast routing alone, with +// no hashing or cross-shard lookup on the return path at all (the "any node +// can determine the owning shard from the tuple alone" property, satisfied by +// construction rather than by a replicated hash table). +// +// A shard is told which addresses to translate to; it does not choose them. +// The controller that owns a cell claims one address per family from the +// addressing service and writes it into this spec, which keeps the +// addressing-service credential off every translating node and keeps the +// allocation request out of the path that attaches a workload. Status reports +// what the node's datapath is actually programmed with, so an unclaimed +// address, a stale datapath, and a divergence between the two are each +// distinguishable. +// +// A shard names nothing that selects it. Which shards serve which consumer +// intent is decided entirely by label selectors evaluated on the selecting +// side (see the LabelEgressShard* keys), so no consumer-facing API appears in +// this group and no reference points from a datapath resource back at the +// resource that placed traffic on it. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=egressshard +// +kubebuilder:printcolumn:name="TARGET",type="string",JSONPath=".spec.targetRef.name" +// +kubebuilder:printcolumn:name="SHARD-SID",type="string",JSONPath=".status.shardSID" +// +kubebuilder:printcolumn:name="IPV6",type="string",JSONPath=".status.shardAddressIPv6" +// +kubebuilder:printcolumn:name="IPV4",type="string",JSONPath=".status.shardAddressIPv4" +// +kubebuilder:printcolumn:name="DRAIN",type="boolean",JSONPath=".spec.drain" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type EgressShard struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EgressShardSpec `json:"spec,omitempty"` + Status EgressShardStatus `json:"status,omitempty"` +} + +// Label keys that select the EgressShards serving a given kind of egress. +// They are matched by an ordinary label selector held on the selecting side, +// which is what lets a consumer-facing API in another group place traffic on +// these shards without this group referencing, importing, or depending on +// that API. A shard carrying none of them is selected by nothing and serves +// no traffic. +// +// The pool and cell labels are operator-set placement facts. The family +// labels restate what this spec already assigns, because a selector matches +// labels and cannot read a spec field; whoever writes the addresses writes +// them. +const ( + // LabelEgressShardPool names the operator-defined pool this shard belongs + // to — the unit a selector picks, not an individual shard. Pools exist so + // that adding or draining a node changes no selector. + LabelEgressShardPool string = "galactic.datumapis.com/egress-pool" + + // LabelEgressShardCell names the cell this shard translates in. Egress is + // realized per cell, so a selector that omits it selects shards in every + // cell and sends a consumer's traffic out of an arbitrary one. + LabelEgressShardCell string = "galactic.datumapis.com/egress-cell" + + // LabelEgressShardIPv6 marks a shard that translates to IPv6, set to + // LabelValueEgressFamilyServed whenever Spec.ShardAddressIPv6 is assigned. + LabelEgressShardIPv6 string = "galactic.datumapis.com/egress-ipv6" + + // LabelEgressShardIPv4 marks a shard that translates to IPv4, set to + // LabelValueEgressFamilyServed whenever Spec.ShardAddressIPv4 is assigned. + LabelEgressShardIPv4 string = "galactic.datumapis.com/egress-ipv4" + + // LabelValueEgressFamilyServed is the only value the family labels carry. + // Absence, not a false value, means the family is not served: a selector + // requiring a family must match on presence so that a shard predating + // these labels never reads as serving one it does not. + LabelValueEgressFamilyServed string = "true" +) + +// ConditionTypeProgrammed indicates whether this node's datapath is +// translating with the addresses this spec assigns. It is distinct from +// Ready, which reports only that the datapath is attached: an attached +// datapath holding no assigned address, or a superseded one, drops or +// mis-sources every flow while reporting Ready. +const ConditionTypeProgrammed string = "Programmed" + +// Programmed sub-reasons — used as Programmed.Reason. +const ( + // ProgrammedReasonAddressesProgrammed indicates the datapath is + // translating with every address this spec assigns. + ProgrammedReasonAddressesProgrammed string = "AddressesProgrammed" + + // ProgrammedReasonAddressUnassigned indicates this spec assigns no + // address for any family, so the shard has nothing to translate to and + // claims no packet. + ProgrammedReasonAddressUnassigned string = "AddressUnassigned" + + // ProgrammedReasonProgrammingFailed indicates the shard could not program + // an assigned address into its datapath. + ProgrammedReasonProgrammingFailed string = "ProgrammingFailed" +) + +// AddressClaimRef names the addressing-service claim an assigned address came +// from. It is a record, not a lookup: the addressing service is an aggregated +// API served on a project control-plane path and defines no CustomResourceDefinition, +// so no claim object exists in the cluster a shard runs in and nothing here +// resolves one. Reading it means addressing that project's control plane +// directly, with a credential a translating node is deliberately not given. +// +// Every field is a plain string and this group depends on no addressing-service +// type. The shape matches that service's own opaque cross-API reference, plus +// the project, because a claim is namespaced within a project and a namespace +// name alone does not identify one from outside. +// +// Populate it with the coordinates of the claim whose allocation produced the +// address written alongside it: +// +// shardAddressIPv6: 2001:db8:f00d::100 +// shardAddressIPv6ClaimRef: +// project: datum-network-edge +// namespace: egress +// name: egress-shard-node-a-ipv6 +// +// The claim cannot record this relationship itself. The addressing service +// overwrites a claim's spec.ownerRef on create with the identity that requested +// it, so a claim made by a cell controller is attributed to that controller's +// project rather than to the shard it was made for. Attribution therefore runs +// in this direction only, and an operator asking which claim holds a shard's +// address has this field or nothing. +type AddressClaimRef struct { + // APIGroup is the group of the claim resource. + // +optional + // +kubebuilder:default="ipam.miloapis.com" + // +kubebuilder:validation:MinLength=1 + APIGroup string `json:"apiGroup,omitempty"` + + // Kind is the kind of the claim resource. + // +optional + // +kubebuilder:default="IPClaim" + // +kubebuilder:validation:MinLength=1 + Kind string `json:"kind,omitempty"` + + // Project is the project whose control plane serves the claim. Required: + // a claim is namespaced within a project, and this reference is read from + // outside every project. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Project string `json:"project"` + + // Namespace is the namespace holding the claim within Project. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Namespace string `json:"namespace"` + + // Name is the name of the claim. The addressing service derives it from + // the thing being addressed, so it is stable across a shard's lifetime and + // is what a replacement finds again. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// EgressShardSpec defines the desired state of an EgressShard. +// +// The address fields are written by the controller that owns the cell, not by +// the shard and not by a consumer. Each is optional: an address the addressing +// service has not yet handed out is absent rather than blank-but-required, so +// a shard object exists from the moment its node is labelled and gains its +// identity afterwards. +// +// Each is also write-once, and cannot be unassigned once assigned. The +// datapath claims a reply by exact match against the address it translates to, +// so reassigning one strands the return traffic of every flow already +// established through it, with no drain and no dual-address grace period +// available to cover the change. A shard holding the wrong address is deleted +// and recreated instead, which breaks those flows at a moment someone chose. +// +// A claim reference alongside an address records where that address came from, +// and is write-once on the same terms. It is deliberately not required with an +// address, in either direction. Requiring an address before a claim reference +// would forbid recording a claim whose allocation has not resolved yet, which +// is the window a restarting controller most needs to see so that it finds the +// claim it already made instead of making a second one. Requiring a claim +// reference before an address would forbid an address allocated by hand, and +// whether such an address may be recorded here at all is an open migration +// question this validation would answer by fiat. An address with no reference +// is therefore accepted and is unattributable, which is a fact about the +// allocation rather than something a schema can repair. +// +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.shardSID) && size(oldSelf.shardSID) > 0) || (has(self.shardSID) && size(self.shardSID) > 0)",message="shardSID cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="(has(self.shardAddressIPv4) && size(self.shardAddressIPv4) > 0) == (has(self.nat64Prefix) && size(self.nat64Prefix) > 0)",message="shardAddressIPv4 and nat64Prefix must be set together" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.shardAddressIPv6) && size(oldSelf.shardAddressIPv6) > 0) || (has(self.shardAddressIPv6) && size(self.shardAddressIPv6) > 0)",message="shardAddressIPv6 cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.shardAddressIPv4) && size(oldSelf.shardAddressIPv4) > 0) || (has(self.shardAddressIPv4) && size(self.shardAddressIPv4) > 0)",message="shardAddressIPv4 cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.nat64Prefix) && size(oldSelf.nat64Prefix) > 0) || (has(self.nat64Prefix) && size(self.nat64Prefix) > 0)",message="nat64Prefix cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.shardAddressIPv6ClaimRef) || has(self.shardAddressIPv6ClaimRef)",message="shardAddressIPv6ClaimRef cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.shardAddressIPv4ClaimRef) || has(self.shardAddressIPv4ClaimRef)",message="shardAddressIPv4ClaimRef cannot be unassigned once assigned" +// +kubebuilder:validation:XValidation:rule="!(has(self.translateWellKnownPrefix) && self.translateWellKnownPrefix) || (has(self.nat64Prefix) && size(self.nat64Prefix) > 0)",message="translateWellKnownPrefix requires nat64Prefix" +type EgressShardSpec struct { + // TargetRef identifies the Node this shard executes on. + // +kubebuilder:validation:Required + TargetRef TargetRef `json:"targetRef"` + + // ShardSID is this shard's own SRv6 uSID, the outer destination a tenant + // VRF's egress route encapsulates toward. The shard claims a packet by its + // Block and Node-ID alone, the Argument carrying the tenant, so those 64 + // bits must be reserved for this shard and disjoint from every BGPRouter + // locator: a value another node already uses silently diverts that node's + // traffic. + // + // Write-once for the same reason the addresses are: every established + // flow's reply is re-encapsulated from this SID, so changing it strands + // them. Operator-chosen until the addressing service hands out identifiers + // of this kind. + // + // Empty means no SID is assigned yet, and the shard claims no packet. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 6)",message="shardSID must be a valid IPv6 address" + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == ''",message="shardSID is immutable once assigned" + ShardSID string `json:"shardSID,omitempty"` + + // ShardAddressIPv6 is the dedicated, publicly-routable IPv6 address this + // shard translates to — every NAT66 masquerade port it allocates lives + // within this address, so any node can route a reply to the owning shard + // using ordinary unicast routing on it alone, with no per-flow state + // lookup anywhere but that shard. + // + // Empty means no IPv6 address is assigned to this shard. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 6)",message="shardAddressIPv6 must be a valid IPv6 address" + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == ''",message="shardAddressIPv6 is immutable once assigned" + ShardAddressIPv6 string `json:"shardAddressIPv6,omitempty"` + + // ShardAddressIPv6ClaimRef records the addressing-service claim + // ShardAddressIPv6 came from — see AddressClaimRef. Set it in the same + // write as the address whenever that address came from a claim. + // + // Nothing here resolves it and no component reads it to program anything; + // it is the only trail from a translating address back to the allocation + // accountable for it. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="shardAddressIPv6ClaimRef is immutable once assigned" + ShardAddressIPv6ClaimRef *AddressClaimRef `json:"shardAddressIPv6ClaimRef,omitempty"` + + // ShardAddressIPv4 is the dedicated, publicly-routable IPv4 address this + // shard translates to, and the source an IPv4-only destination sees. + // Unlike ShardAddressIPv6, reachability for it is not established by a + // BGPAdvertisement into the EVPN fabric: an IPv4 reply arrives from the + // internet, so the underlay or an upstream announcement must attract this + // address to this node. + // + // Empty means no IPv4 address is assigned to this shard. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 4)",message="shardAddressIPv4 must be a valid IPv4 address" + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == ''",message="shardAddressIPv4 is immutable once assigned" + ShardAddressIPv4 string `json:"shardAddressIPv4,omitempty"` + + // ShardAddressIPv4ClaimRef records the addressing-service claim + // ShardAddressIPv4 came from — see AddressClaimRef. Set it in the same + // write as the address whenever that address came from a claim. + // + // Nothing here resolves it and no component reads it to program anything; + // it is the only trail from a translating address back to the allocation + // accountable for it. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="shardAddressIPv4ClaimRef is immutable once assigned" + ShardAddressIPv4ClaimRef *AddressClaimRef `json:"shardAddressIPv4ClaimRef,omitempty"` + + // NAT64Prefix is the IPv6 prefix whose synthesized addresses this shard + // translates to IPv4 — a Datum-operated Network-Specific Prefix, shared + // fabric-wide, never per-tenant. TranslateWellKnownPrefix adds the RFC 6052 + // Well-Known Prefix alongside it. It must be the prefix the resolver + // synthesizes into; a shard translating for a different one is a + // blackhole with no symptom on either side. + // + // Set together with ShardAddressIPv4 or not at all: an address with no + // prefix has nothing to translate for, and a prefix with no address has + // nothing to translate into. Write-once for the same reason the addresses + // are: a shard that starts translating a different prefix blackholes every + // destination the resolver already synthesized into the old one. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || isCIDR(self)",message="nat64Prefix must be a valid CIDR" + // +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf == ''",message="nat64Prefix is immutable once assigned" + NAT64Prefix string `json:"nat64Prefix,omitempty"` + + // TranslateWellKnownPrefix additionally translates destinations inside the + // RFC 6052 Well-Known Prefix 64:ff9b::/96 to IPv4 from ShardAddressIPv4, so + // clients using a public DNS64 resolver reach IPv4 destinations. Requires + // NAT64Prefix. + // + // RFC 6052 §3.1 forbids the Well-Known Prefix from representing non-global + // IPv4 addresses; the implementation must refuse such destinations. + // + // Mutable, unlike NAT64Prefix. Disabling it blackholes every destination + // clients already resolved into the Well-Known Prefix until their DNS TTLs + // expire. + // +optional + TranslateWellKnownPrefix bool `json:"translateWellKnownPrefix,omitempty"` + + // Drain stops this shard from taking new tenants while it keeps serving + // the ones it has. Compute nodes that spread egress across a cluster's + // shards give a draining shard no share of new tenant addresses, but keep + // sending it every flow already pinned to it until that flow goes idle, so + // established sessions finish where their translation state lives. + // + // It changes nothing on the shard itself: the shard keeps translating, + // advertising its SID and receiving replies. A shard is safe to remove + // once its session count has fallen to zero. Clearing it puts the shard + // back into rotation. + // + // Compute nodes that send each tenant to a single configured shard ignore + // it. + // +optional + Drain bool `json:"drain,omitempty"` +} + +// EgressShardStatus defines the observed state of an EgressShard. +// +// The address and prefix fields report what this node's datapath is programmed +// with, not what it was asked for. A value here that disagrees with the spec is +// a shard that has not converged; a value here with no counterpart in the spec +// is a shard still translating to an address nothing assigns any more. +type EgressShardStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // ShardSID is this shard's own uSID locator — a real SRv6 uSID (unlike + // the ShardAddress fields, which are plain routable addresses), + // advertised into BGP the same way any other node-reachability route is + // (a /128 BGPAdvertisement, no VRFID/Function) so every other node learns + // a kernel SEG6 route toward it before installing a tenant VRF's egress + // route against it. One SID serves both families: which translation a + // packet gets is decided from the inner destination, not from a second + // SID. + // + // Reports the SID this node's datapath is programmed with, from + // Spec.ShardSID. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 6)",message="shardSID must be a valid IPv6 address" + ShardSID string `json:"shardSID,omitempty"` + + // ShardAddressIPv6 is the IPv6 masquerade source this shard's datapath is + // programmed with. Empty means it translates no IPv6 flow. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 6)",message="shardAddressIPv6 must be a valid IPv6 address" + ShardAddressIPv6 string `json:"shardAddressIPv6,omitempty"` + + // ShardAddressIPv4 is the IPv4 masquerade source this shard's datapath is + // programmed with. Empty means it performs no NAT64. Publishing it is also + // what makes the underlay reachability prerequisite in + // Spec.ShardAddressIPv4 checkable. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || (isIP(self) && ip(self).family() == 4)",message="shardAddressIPv4 must be a valid IPv4 address" + ShardAddressIPv4 string `json:"shardAddressIPv4,omitempty"` + + // NAT64Prefix is the prefix this shard's datapath is programmed to + // translate. Empty whenever ShardAddressIPv4 is empty. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || isCIDR(self)",message="nat64Prefix must be a valid CIDR" + NAT64Prefix string `json:"nat64Prefix,omitempty"` + + // TranslatesWellKnownPrefix reports whether this shard's datapath is + // programmed to translate the RFC 6052 Well-Known Prefix 64:ff9b::/96, from + // Spec.TranslateWellKnownPrefix. False whenever ShardAddressIPv4 is empty. + // +optional + TranslatesWellKnownPrefix bool `json:"translatesWellKnownPrefix,omitempty"` + + // Conditions contains the standard conditions for this resource, + // including Programmed (see ConditionTypeProgrammed). + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// EgressShardList is a list of EgressShard resources. +// +kubebuilder:object:root=true +type EgressShardList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EgressShard `json:"items"` +} + +func init() { + SchemeBuilder.Register(&EgressShard{}, &EgressShardList{}) +} diff --git a/api/network/v1alpha1/egressshard_types_test.go b/api/network/v1alpha1/egressshard_types_test.go new file mode 100644 index 00000000..6c5fa3bf --- /dev/null +++ b/api/network/v1alpha1/egressshard_types_test.go @@ -0,0 +1,242 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestEgressShard() *EgressShard { + return &EgressShard{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "EgressShard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-egress-shard", + Namespace: "galactic-system", + Labels: map[string]string{ + LabelEgressShardPool: "shared-public", + LabelEgressShardCell: "us-east-2-a", + LabelEgressShardIPv6: LabelValueEgressFamilyServed, + }, + }, + Spec: EgressShardSpec{ + TargetRef: TargetRef{Kind: "Node", Name: "node-a"}, + ShardSID: "2001:db8:ff01:2001::", + ShardAddressIPv6: "2001:db8:f00d::100", + ShardAddressIPv6ClaimRef: &AddressClaimRef{ + APIGroup: "ipam.miloapis.com", + Kind: "IPClaim", + Project: "datum-network-edge", + Namespace: "egress", + Name: "egress-shard-node-a-ipv6", + }, + }, + } +} + +// TestEgressShardDeepCopy verifies that DeepCopy produces an independent +// copy: mutations to the copy must not affect the original. +func TestEgressShardDeepCopy(t *testing.T) { + orig := newTestEgressShard() + dup := orig.DeepCopy() + + dup.Spec.ShardSID = "2001:db8:ff01:2002::" + dup.Spec.ShardAddressIPv6 = "2001:db8:f00d::200" + dup.Spec.ShardAddressIPv4 = "198.51.100.7" + dup.Spec.NAT64Prefix = "64:ff9b::/96" + dup.Labels[LabelEgressShardPool] = "dedicated-public" + dup.Spec.ShardAddressIPv6ClaimRef.Name = "some-other-claim" + dup.Status.Conditions = append(dup.Status.Conditions, metav1.Condition{Type: ConditionTypeProgrammed}) + + if orig.Spec.ShardSID != "2001:db8:ff01:2001::" { + t.Errorf("ShardSID mutated: got %q", orig.Spec.ShardSID) + } + if orig.Spec.ShardAddressIPv6 != "2001:db8:f00d::100" { + t.Errorf("ShardAddressIPv6 mutated: got %q", orig.Spec.ShardAddressIPv6) + } + if orig.Spec.ShardAddressIPv4 != "" { + t.Errorf("ShardAddressIPv4 mutated: got %q", orig.Spec.ShardAddressIPv4) + } + if orig.Spec.NAT64Prefix != "" { + t.Errorf("NAT64Prefix mutated: got %q", orig.Spec.NAT64Prefix) + } + if orig.Labels[LabelEgressShardPool] != "shared-public" { + t.Errorf("pool label mutated: got %q", orig.Labels[LabelEgressShardPool]) + } + if orig.Spec.ShardAddressIPv6ClaimRef.Name != "egress-shard-node-a-ipv6" { + t.Errorf("claim ref aliased by DeepCopy: got %q", orig.Spec.ShardAddressIPv6ClaimRef.Name) + } + if len(orig.Status.Conditions) != 0 { + t.Errorf("Conditions mutated: got %v", orig.Status.Conditions) + } +} + +// TestEgressShardDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestEgressShardDeepCopyNil(t *testing.T) { + var s *EgressShard + if s.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestEgressShardJSONRoundTrip verifies that the spec-side addresses and the +// status-side programmed values survive JSON marshal/unmarshal independently +// of each other. +func TestEgressShardJSONRoundTrip(t *testing.T) { + orig := newTestEgressShard() + orig.Spec.ShardAddressIPv4 = "198.51.100.7" + orig.Spec.NAT64Prefix = "64:ff9b::/96" + orig.Status = EgressShardStatus{ + ObservedGeneration: 3, + ShardSID: "2001:db8:ff01::", + ShardAddressIPv6: "2001:db8:f00d::100", + Conditions: []metav1.Condition{ + { + Type: ConditionTypeProgrammed, + Status: metav1.ConditionFalse, + Reason: ProgrammedReasonProgrammingFailed, + }, + }, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got EgressShard + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.ShardSID != orig.Spec.ShardSID || + got.Spec.ShardAddressIPv6 != orig.Spec.ShardAddressIPv6 || + got.Spec.ShardAddressIPv4 != orig.Spec.ShardAddressIPv4 || + got.Spec.NAT64Prefix != orig.Spec.NAT64Prefix || + got.Spec.TargetRef != orig.Spec.TargetRef { + t.Errorf("Spec: got %+v, want %+v", got.Spec, orig.Spec) + } + if got.Spec.ShardAddressIPv6ClaimRef == nil || *got.Spec.ShardAddressIPv6ClaimRef != *orig.Spec.ShardAddressIPv6ClaimRef { + t.Errorf("ShardAddressIPv6ClaimRef: got %+v, want %+v", got.Spec.ShardAddressIPv6ClaimRef, orig.Spec.ShardAddressIPv6ClaimRef) + } + if got.Status.ShardSID != orig.Status.ShardSID { + t.Errorf("ShardSID: got %q, want %q", got.Status.ShardSID, orig.Status.ShardSID) + } + if got.Status.ShardAddressIPv6 != orig.Status.ShardAddressIPv6 { + t.Errorf("status ShardAddressIPv6: got %q, want %q", got.Status.ShardAddressIPv6, orig.Status.ShardAddressIPv6) + } + if got.Status.ShardAddressIPv4 != "" { + t.Errorf("status ShardAddressIPv4: got %q, want empty", got.Status.ShardAddressIPv4) + } + if len(got.Status.Conditions) != 1 || got.Status.Conditions[0].Reason != ProgrammedReasonProgrammingFailed { + t.Errorf("Conditions: got %+v", got.Status.Conditions) + } +} + +// TestEgressShardSpecFieldNames verifies the spec JSON keys, which the cell +// controller writes and the CRD's CEL rules name. +func TestEgressShardSpecFieldNames(t *testing.T) { + orig := newTestEgressShard() + orig.Spec.ShardAddressIPv4 = "198.51.100.7" + orig.Spec.NAT64Prefix = "64:ff9b::/96" + orig.Spec.TranslateWellKnownPrefix = true + orig.Spec.Drain = true + + data, err := json.Marshal(orig.Spec) + 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) + } + + want := map[string]string{ + "shardSID": "2001:db8:ff01:2001::", + "shardAddressIPv6": "2001:db8:f00d::100", + "shardAddressIPv4": "198.51.100.7", + "nat64Prefix": "64:ff9b::/96", + } + for key, value := range want { + if raw, ok := m[key]; !ok || raw != value { + t.Errorf("%s: got %v, want %q", key, raw, value) + } + } + if raw, ok := m["translateWellKnownPrefix"]; !ok || raw != true { + t.Errorf("translateWellKnownPrefix: got %v, want true", raw) + } + if raw, ok := m["drain"]; !ok || raw != true { + t.Errorf("drain: got %v, want true", raw) + } + if _, ok := m["targetRef"]; !ok { + t.Error("expected \"targetRef\" key to be present") + } + + claimRef, ok := m["shardAddressIPv6ClaimRef"].(map[string]any) + if !ok { + t.Fatalf("shardAddressIPv6ClaimRef: got %v, want an object", m["shardAddressIPv6ClaimRef"]) + } + wantRef := map[string]string{ + "apiGroup": "ipam.miloapis.com", + "kind": "IPClaim", + "project": "datum-network-edge", + "namespace": "egress", + "name": "egress-shard-node-a-ipv6", + } + for key, value := range wantRef { + if raw, ok := claimRef[key]; !ok || raw != value { + t.Errorf("shardAddressIPv6ClaimRef.%s: got %v, want %q", key, raw, value) + } + } +} + +// TestEgressShardSpecOmitEmpty verifies that a shard with no address assigned +// yet omits every address key, which is what distinguishes an unclaimed +// address from a blank one. +func TestEgressShardSpecOmitEmpty(t *testing.T) { + spec := EgressShardSpec{TargetRef: TargetRef{Kind: "Node", Name: "node-a"}} + + data, err := json.Marshal(spec) + 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) + } + + for _, key := range []string{ + "shardSID", "shardAddressIPv6", "shardAddressIPv4", "nat64Prefix", + "shardAddressIPv6ClaimRef", "shardAddressIPv4ClaimRef", + "translateWellKnownPrefix", "drain", + } { + if _, ok := m[key]; ok { + t.Errorf("expected %q key to be absent when empty", key) + } + } +} + +// TestEgressShardLabelKeys verifies the selection label keys stay in this +// API group's own namespace. A key naming another group would make a data +// plane resource carry a reference to the API that selects it. +func TestEgressShardLabelKeys(t *testing.T) { + cases := map[string]string{ + "pool": LabelEgressShardPool, + "cell": LabelEgressShardCell, + "ipv6": LabelEgressShardIPv6, + "ipv4": LabelEgressShardIPv4, + } + for name, key := range cases { + t.Run(name, func(t *testing.T) { + const prefix = "galactic.datumapis.com/" + if len(key) <= len(prefix) || key[:len(prefix)] != prefix { + t.Errorf("label key %q: want prefix %q", key, prefix) + } + }) + } +} diff --git a/api/network/v1alpha1/gateway_types.go b/api/network/v1alpha1/gateway_types.go new file mode 100644 index 00000000..3729095c --- /dev/null +++ b/api/network/v1alpha1/gateway_types.go @@ -0,0 +1,70 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkGateway marks a single dedicated gateway-role node as running the +// Maglev/DSR consistent-hash L4 load-balancer engine. Exactly one +// NetworkGateway exists per gateway node (spec.targetRef.name is the +// Kubernetes node name), mirroring the BGPRouter node-scoped root object +// pattern. NetworkService resources are served by every NetworkGateway in the +// namespace equally (anycast — see NetworkServiceStatus's doc comment); this +// object's only job is to identify which nodes participate at all and +// surface each node's engine health via Conditions. +// +// This design does no address rewriting on the load-balancing path at all +// (DSR — Direct Server Return): the gateway's XDP program picks a backend +// via consistent hashing on the flow's 5-tuple and pushes an SRv6 uSID outer +// header addressed to the backend's worker node directly, untouched +// otherwise. The backend node answers the client directly (see +// ServiceVIPBinding) — reply traffic never re-enters this gateway node, so +// unlike the Full-NAT design this type originally described, a gateway node +// has no SNAT source address of its own to publish and nothing analogous to +// sRv6Address/egressAddress/egressSID belongs on this status anymore. +// +// +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="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"` + + // 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{}) +} diff --git a/api/network/v1alpha1/gateway_types_test.go b/api/network/v1alpha1/gateway_types_test.go new file mode 100644 index 00000000..fa9c3ff5 --- /dev/null +++ b/api/network/v1alpha1/gateway_types_test.go @@ -0,0 +1,120 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestGateway() *NetworkGateway { + return &NetworkGateway{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "NetworkGateway", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-gateway"}, + Spec: NetworkGatewaySpec{ + TargetRef: TargetRef{Kind: "Node", Name: "gw-node-a"}, + }, + } +} + +// 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.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 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 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 fields match +// the CRD schema, and that no per-node SNAT-source fields survive from the +// superseded Full-NAT design — a DSR gateway node rewrites nothing, so it +// has no address of its own to publish here. +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) + } + + if status, ok := m["status"].(map[string]any); ok { + for _, removed := range []string{"sRv6Address", "egressAddress", "egressSID"} { + if _, ok := status[removed]; ok { + t.Errorf("unexpected superseded field %q present in status: %v", removed, status) + } + } + } +} diff --git a/api/network/v1alpha1/groupversion_info.go b/api/network/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..6a2b494a --- /dev/null +++ b/api/network/v1alpha1/groupversion_info.go @@ -0,0 +1,37 @@ +/* +Copyright © 2026 Datum. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package v1alpha1 contains API Schema definitions for the galactic.datumapis.com/v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=galactic.datumapis.com +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is the group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "galactic.datumapis.com", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} //nolint:staticcheck // Imported upstream API registration pattern. + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/network/v1alpha1/networkservice_types.go b/api/network/v1alpha1/networkservice_types.go new file mode 100644 index 00000000..dd93013d --- /dev/null +++ b/api/network/v1alpha1/networkservice_types.go @@ -0,0 +1,159 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkServiceProtocol is the transport protocol matched by a NetworkService's +// ingress VIP. +// +// +kubebuilder:validation:Enum=tcp;udp +type NetworkServiceProtocol string + +const ( + // NetworkServiceProtocolTCP matches TCP traffic. + NetworkServiceProtocolTCP NetworkServiceProtocol = "tcp" + + // NetworkServiceProtocolUDP matches UDP traffic. + NetworkServiceProtocolUDP NetworkServiceProtocol = "udp" +) + +// Accepted reasons — used as Accepted.Reason on NetworkService (and reused by +// NetworkEgressPolicy), set by the admission webhook that verifies the +// requester is authorized for the VPC resources the object references. +const ( + // AcceptedReasonOwnershipVerified indicates admission verified the + // requester is authorized for the VPC resources the object references. + AcceptedReasonOwnershipVerified string = "OwnershipVerified" + + // AcceptedReasonOwnershipDenied indicates admission rejected the object + // because the requester is not authorized for the VPC resources it + // references. + AcceptedReasonOwnershipDenied string = "OwnershipDenied" +) + +// NetworkService defines ingress load-balancing for a single tenant VPC, served +// by every NetworkGateway node identically (anycast Direct Server Return — +// see NetworkGateway's doc comment). It is namespaced (deployed to +// galactic-system) and tenant-writable; vpcRef is an opaque string identifier +// because this kind carries identifiers from the VPC API in +// api/cloud/v1alpha1 without a Kubernetes object reference. An admission webhook (implemented by the consuming controller) must +// verify the requester is authorized for vpcRef before a service is accepted — +// see the Accepted condition. +// +// Unlike the earlier Full-NAT design this type originally described, there +// is no primary/secondary gateway node for a service: every NetworkGateway +// advertises every accepted service's vipAddresses at equal BGP preference, +// consistent-hashes the same backend list to the same backend for the same +// flow (internal/maglev), and forwards without rewriting anything — +// backend selection never needs a single "owning" node the way Full-NAT's +// SNAT-source model did. +// +// Backends are not listed. BackendSelector picks the VPCAttachments that +// serve the service, so the backend set follows attachments as they are +// created, deleted or moved to another node, and galactic-router on each +// node generates the ServiceVIPBinding its backends need from the same +// selection. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=netsvc +// +kubebuilder:printcolumn:name="VPC",type="string",JSONPath=".spec.vpcRef" +// +kubebuilder:printcolumn:name="PROTOCOL",type="string",JSONPath=".spec.protocol" +// +kubebuilder:printcolumn:name="PORT",type="integer",JSONPath=".spec.port" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type NetworkService struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NetworkServiceSpec `json:"spec,omitempty"` + Status NetworkServiceStatus `json:"status,omitempty"` +} + +// NetworkServiceSpec defines the desired ingress load-balancing state for a +// tenant VPC. +type NetworkServiceSpec struct { + // VPCRef is the opaque identifier of the target VPC this service 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 service is accepted. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VPCRef string `json:"vpcRef"` + + // VIPAddresses is the list of ingress VIP addresses this service provisions + // on the assigned gateway node(s). + // + // Only IPv6 addresses are admitted until the gateway datapath serves IPv4 + // VIPs (datum-cloud/galactic#705). A service created with an IPv4 VIP before + // this restriction keeps it while the list is unchanged, since the API + // server ratchets validation of an unchanged field on Kubernetes 1.30 and + // later; any edit to the list must drop it, and on an older API server any + // update to such a service is refused. The gateways do not load or advertise + // an IPv4 VIP. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:validation:items:MaxLength=45 + // +kubebuilder:validation:XValidation:rule="self.all(v, isIP(v))",message="vipAddresses must all be valid IPv4 or IPv6 addresses" + // +kubebuilder:validation:XValidation:rule="self.all(v, !isIP(v) || ip(v).family() == 6)",message="vipAddresses must be IPv6: IPv4 VIPs are not served yet (datum-cloud/galactic#705)" + // +listType=set + VIPAddresses []string `json:"vipAddresses"` + + // Protocol is the transport protocol matched by VIPAddresses/Port. + // +kubebuilder:validation:Required + Protocol NetworkServiceProtocol `json:"protocol"` + + // Port is the ingress port on VIPAddresses that this service load-balances. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port int32 `json:"port"` + + // BackendSelector selects the VPCAttachments (galactic.datumapis.com/v1alpha1, + // in any namespace) that serve this service, matched against each + // VPCAttachment object's own labels. Only attachments whose status.vpc + // equals VPCRef are candidates, whatever their labels, so a selector cannot + // reach into another tenant's VPC. Each selected attachment contributes the + // IPv6 addresses in its spec.interface.addresses as backends, on + // BackendPort; the DSR datapath carries IPv6 backends only. An attachment + // with no status.node or no IPv6 address is not a backend until it has + // both. An empty selector is rejected rather than read as "every attachment + // in the VPC". + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="(has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions) && size(self.matchExpressions) > 0)",message="backendSelector must not be empty" + BackendSelector metav1.LabelSelector `json:"backendSelector"` + + // BackendPort is the destination port on every selected backend. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + BackendPort int32 `json:"backendPort"` +} + +// NetworkServiceStatus defines the observed state of a NetworkService. +type NetworkServiceStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// NetworkServiceList is a list of NetworkService resources. +// +kubebuilder:object:root=true +type NetworkServiceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NetworkService `json:"items"` +} + +func init() { + SchemeBuilder.Register(&NetworkService{}, &NetworkServiceList{}) +} diff --git a/api/network/v1alpha1/networkservice_types_test.go b/api/network/v1alpha1/networkservice_types_test.go new file mode 100644 index 00000000..15084db8 --- /dev/null +++ b/api/network/v1alpha1/networkservice_types_test.go @@ -0,0 +1,159 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestService() *NetworkService { + return &NetworkService{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "NetworkService", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-service", Namespace: "galactic-system"}, + Spec: NetworkServiceSpec{ + VPCRef: "vpc-a", + VIPAddresses: []string{"2001:db8:1::10"}, + Protocol: NetworkServiceProtocolTCP, + Port: 443, + BackendSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + }, + BackendPort: 8443, + }, + } +} + +// TestNetworkServiceDeepCopy verifies that DeepCopy produces an independent +// copy: mutations to slices in the copy must not affect the original. +func TestNetworkServiceDeepCopy(t *testing.T) { + orig := newTestService() + dup := orig.DeepCopy() + + dup.Spec.VIPAddresses[0] = "2001:db8:1::20" + dup.Spec.BackendSelector.MatchLabels["app"] = "api" + + if orig.Spec.VIPAddresses[0] != "2001:db8:1::10" { + t.Errorf("VIPAddresses[0] mutated: got %q", orig.Spec.VIPAddresses[0]) + } + if orig.Spec.BackendSelector.MatchLabels["app"] != "web" { + t.Errorf("BackendSelector.MatchLabels mutated: got %v", orig.Spec.BackendSelector.MatchLabels) + } +} + +// TestNetworkServiceDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestNetworkServiceDeepCopyNil(t *testing.T) { + var r *NetworkService + if r.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestNetworkServiceJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestNetworkServiceJSONRoundTrip(t *testing.T) { + orig := newTestService() + orig.Spec.BackendSelector.MatchExpressions = []metav1.LabelSelectorRequirement{ + {Key: "tier", Operator: metav1.LabelSelectorOpIn, Values: []string{"frontend"}}, + } + 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 NetworkService + 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.BackendSelector.MatchLabels["app"] != "web" || len(got.Spec.BackendSelector.MatchExpressions) != 1 { + t.Errorf("BackendSelector: got %+v", got.Spec.BackendSelector) + } + if got.Spec.BackendPort != orig.Spec.BackendPort { + t.Errorf("BackendPort: got %d, want %d", got.Spec.BackendPort, orig.Spec.BackendPort) + } + if len(got.Status.Conditions) != 1 || got.Status.Conditions[0].Reason != AcceptedReasonOwnershipVerified { + t.Errorf("Conditions: got %v", got.Status.Conditions) + } +} + +// TestNetworkServiceListDeepCopy verifies that NetworkServiceList.DeepCopy +// produces independent copies of each item. +func TestNetworkServiceListDeepCopy(t *testing.T) { + list := &NetworkServiceList{ + Items: []NetworkService{*newTestService()}, + } + 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") + } +} + +// TestNetworkServiceStatusHasNoPrimaryNode is a regression test: the earlier +// active-passive Full-NAT design assigned a single primaryNode per service. +// This design's anycast/DSR model has every NetworkGateway serve every service +// identically, so no such field should exist any more. +func TestNetworkServiceStatusHasNoPrimaryNode(t *testing.T) { + orig := newTestService() + orig.Status.Conditions = []metav1.Condition{{Type: ConditionTypeAccepted}} + + data, err := json.Marshal(orig.Status) + 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) + } + + if _, ok := m["primaryNode"]; ok { + t.Errorf("unexpected primaryNode field present in status: %v", m) + } +} + +// TestNetworkServiceBackendFieldNames verifies the JSON keys for the backend +// selection fields match the CRD schema, and that the static backend list +// they replaced is gone. +func TestNetworkServiceBackendFieldNames(t *testing.T) { + data, err := json.Marshal(newTestService().Spec) + 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) + } + if _, ok := m["backendSelector"]; !ok { + t.Errorf("expected JSON key \"backendSelector\", got %v", m) + } + if v, ok := m["backendPort"]; !ok || v != float64(8443) { + t.Errorf("expected JSON key \"backendPort\"=8443, got %v", m) + } + if _, ok := m["backends"]; ok { + t.Errorf("unexpected \"backends\" key: %v", m) + } +} + +// TestNetworkServiceProtocolValues is a regression test pinning the accepted +// NetworkServiceProtocol enum values. +func TestNetworkServiceProtocolValues(t *testing.T) { + if NetworkServiceProtocolTCP != "tcp" { + t.Errorf("NetworkServiceProtocolTCP: got %q, want %q", NetworkServiceProtocolTCP, "tcp") + } + if NetworkServiceProtocolUDP != "udp" { + t.Errorf("NetworkServiceProtocolUDP: got %q, want %q", NetworkServiceProtocolUDP, "udp") + } +} diff --git a/api/network/v1alpha1/networkservice_vipfamily_envtest_test.go b/api/network/v1alpha1/networkservice_vipfamily_envtest_test.go new file mode 100644 index 00000000..0b29b970 --- /dev/null +++ b/api/network/v1alpha1/networkservice_vipfamily_envtest_test.go @@ -0,0 +1,204 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package v1alpha1_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/yaml" + + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" +) + +// ipv4VIPRuleMarker identifies the vipAddresses rule that refuses IPv4 VIPs +// until galactic serves them. +const ipv4VIPRuleMarker = "ip(v).family() == 6" + +// TestNetworkServiceVIPFamilyAdmission covers the IPv4 VIP stopgap against a +// real API server: a new IPv4 VIP is refused and an IPv6 one admitted, and a +// rule that already held an IPv4 VIP before the rule was installed survives +// edits that leave vipAddresses alone, but not an edit to the list. +func TestNetworkServiceVIPFamilyAdmission(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("set KUBEBUILDER_ASSETS for isolated API admission tests") + } + data, err := os.ReadFile(filepath.Join("..", "..", "..", "config", "crd", "galactic.datumapis.com_networkservices.yaml")) + if err != nil { + t.Fatal(err) + } + full := &apiextensionsv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(data, full); err != nil { + t.Fatal(err) + } + // The CRD as it was before the rule, to create an object the current + // schema would refuse. + legacy := full.DeepCopy() + if !dropVIPFamilyRule(legacy) { + t.Fatalf("CRD has no vipAddresses rule containing %q", ipv4VIPRuleMarker) + } + + environment := &envtest.Environment{CRDs: []*apiextensionsv1.CustomResourceDefinition{legacy}} + cfg, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Errorf("stop API: %v", err) + } + }) + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = apiextensionsv1.AddToScheme(scheme) + _ = networkv1alpha1.AddToScheme(scheme) + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + const ns = "vip-family" + if err := c.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}); err != nil { + t.Fatal(err) + } + rule := func(name string, vips ...string) *networkv1alpha1.NetworkService { + return &networkv1alpha1.NetworkService{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: networkv1alpha1.NetworkServiceSpec{ + VPCRef: "vpc-a", + VIPAddresses: vips, + Protocol: networkv1alpha1.NetworkServiceProtocolTCP, + Port: 443, + BackendSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}, + BackendPort: 8443, + }, + } + } + + existing := rule("existing", "2001:db8:1::10", "203.0.113.10") + if err := c.Create(ctx, existing); err != nil { + t.Fatalf("create IPv4 rule under the legacy schema: %v", err) + } + installSchema(ctx, t, c, full) + + for _, tt := range []struct { + name string + vips []string + wantError string + }{ + {"ipv6", []string{"2001:db8:1::11"}, ""}, + {"ipv4", []string{"203.0.113.11"}, "galactic#705"}, + {"dual-stack", []string{"2001:db8:1::12", "203.0.113.12"}, "galactic#705"}, + // isIP refuses an IPv4-mapped IPv6 address, so the first rule does. + {"ipv4-mapped", []string{"::ffff:203.0.113.13"}, "valid IPv4 or IPv6"}, + } { + t.Run(tt.name, func(t *testing.T) { + err := c.Create(ctx, rule(tt.name, tt.vips...)) + if tt.wantError == "" { + if err != nil { + t.Fatalf("create: %v", err) + } + return + } + if !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("create error = %v, want a schema rejection naming %q", err, tt.wantError) + } + }) + } + + t.Run("existing rule keeps an unchanged IPv4 VIP", func(t *testing.T) { + current := &networkv1alpha1.NetworkService{} + if err := c.Get(ctx, client.ObjectKeyFromObject(existing), current); err != nil { + t.Fatal(err) + } + current.Labels = map[string]string{"edited": "true"} + current.Spec.Port = 8443 + if err := c.Update(ctx, current); err != nil { + t.Fatalf("update leaving vipAddresses unchanged: %v", err) + } + }) + + t.Run("existing rule cannot edit a list holding an IPv4 VIP", func(t *testing.T) { + current := &networkv1alpha1.NetworkService{} + if err := c.Get(ctx, client.ObjectKeyFromObject(existing), current); err != nil { + t.Fatal(err) + } + current.Spec.VIPAddresses = append(current.Spec.VIPAddresses, "2001:db8:1::13") + err := c.Update(ctx, current) + if !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "galactic#705") { + t.Fatalf("update adding a VIP beside an IPv4 VIP: err = %v, want the IPv4 VIP rule's rejection", err) + } + current.Spec.VIPAddresses = []string{"2001:db8:1::10"} + if err := c.Update(ctx, current); err != nil { + t.Fatalf("update dropping the IPv4 VIP: %v", err) + } + }) +} + +// dropVIPFamilyRule removes the IPv4 VIP rule from crd's vipAddresses schema, +// reporting whether it was there. +func dropVIPFamilyRule(crd *apiextensionsv1.CustomResourceDefinition) bool { + field := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["vipAddresses"] + kept := field.XValidations[:0] + for _, v := range field.XValidations { + if !strings.Contains(v.Rule, ipv4VIPRuleMarker) { + kept = append(kept, v) + } + } + dropped := len(kept) != len(field.XValidations) + field.XValidations = kept + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["vipAddresses"] = field + return dropped +} + +// installSchema replaces the served NetworkService schema with want's and waits +// until the API server enforces it, which a fresh create of an IPv4 VIP +// shows. +func installSchema(ctx context.Context, t *testing.T, c client.Client, want *apiextensionsv1.CustomResourceDefinition) { + t.Helper() + current := &apiextensionsv1.CustomResourceDefinition{} + if err := c.Get(ctx, client.ObjectKey{Name: want.Name}, current); err != nil { + t.Fatal(err) + } + current.Spec.Versions = want.Spec.Versions + if err := c.Update(ctx, current); err != nil { + t.Fatalf("install the current NetworkService schema: %v", err) + } + probe := &networkv1alpha1.NetworkService{ + ObjectMeta: metav1.ObjectMeta{Name: "schema-probe", Namespace: "vip-family"}, + Spec: networkv1alpha1.NetworkServiceSpec{ + VPCRef: "vpc-a", + VIPAddresses: []string{"198.51.100.1"}, + Protocol: networkv1alpha1.NetworkServiceProtocolTCP, + Port: 443, + BackendSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}, + BackendPort: 8443, + }, + } + var lastErr error + err := wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 30*time.Second, true, + func(ctx context.Context) (bool, error) { + p := probe.DeepCopy() + lastErr = c.Create(ctx, p) + if lastErr == nil { + return false, c.Delete(ctx, p) + } + return apierrors.IsInvalid(lastErr) && strings.Contains(lastErr.Error(), "galactic#705"), nil + }) + if err != nil { + t.Fatalf("API server never enforced the current NetworkService schema: %v (last create: %v)", err, lastErr) + } +} diff --git a/api/network/v1alpha1/peer_types.go b/api/network/v1alpha1/peer_types.go new file mode 100644 index 00000000..480c2660 --- /dev/null +++ b/api/network/v1alpha1/peer_types.go @@ -0,0 +1,368 @@ +package v1alpha1 + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BGPPeerState represents the BGP Finite State Machine state of a session. +type BGPPeerState string + +const ( + BGPPeerStateIdle BGPPeerState = "Idle" + BGPPeerStateConnect BGPPeerState = "Connect" + BGPPeerStateActive BGPPeerState = "Active" + BGPPeerStateOpenSent BGPPeerState = "OpenSent" + BGPPeerStateOpenConfirm BGPPeerState = "OpenConfirm" + BGPPeerStateEstablished BGPPeerState = "Established" +) + +// SendCommunityType controls which BGP community attributes are propagated +// to a peer. +// +// +kubebuilder:validation:Enum=standard;extended;large;both +type SendCommunityType string + +const ( + // SendCommunityTypeStandard sends only standard communities (ASN:NN). + SendCommunityTypeStandard SendCommunityType = "standard" + + // SendCommunityTypeExtended sends only extended communities (Type:Length:Value). + SendCommunityTypeExtended SendCommunityType = "extended" + + // SendCommunityTypeLarge sends only large communities (ASN:NN:NN). + SendCommunityTypeLarge SendCommunityType = "large" + + // SendCommunityTypeBoth sends both standard and extended communities. + SendCommunityTypeBoth SendCommunityType = "both" +) + +// MaxPrefixShutdownAction defines the action taken when a peer exceeds its +// maximum-prefix limit. +// +// +kubebuilder:validation:Enum=warning-only;restart;shutdown +type MaxPrefixShutdownAction string + +const ( + // MaxPrefixShutdownActionWarningOnly logs a warning but keeps the session up. + MaxPrefixShutdownActionWarningOnly MaxPrefixShutdownAction = "warning-only" + + // MaxPrefixShutdownActionRestart resets the BGP session when the limit is exceeded. + MaxPrefixShutdownActionRestart MaxPrefixShutdownAction = "restart" + + // MaxPrefixShutdownActionShutdown tears down the BGP session when the limit is exceeded. + MaxPrefixShutdownActionShutdown MaxPrefixShutdownAction = "shutdown" +) + +// BGPMaximumPrefix defines the maximum number of routes accepted from a peer +// before triggering a shutdown action. +type BGPMaximumPrefix struct { + // Limit is the maximum number of routes accepted from this peer. + // +kubebuilder:validation:Minimum=1 + Limit int32 `json:"limit"` + + // ThresholdPercent is the percentage threshold at which a warning is + // logged (e.g., 75 means warn at 75% of Limit). Range: 1-100. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=100 + ThresholdPercent *int32 `json:"thresholdPercent,omitempty"` + + // ShutdownAction is the action taken when the Limit is exceeded. + // +kubebuilder:validation:Required + ShutdownAction MaxPrefixShutdownAction `json:"shutdownAction"` +} + +// Condition type constants for BGPPeer. +// +// These follow Kubernetes condition conventions: a small set of stable, +// semantically-meaningful types whose Status/Reason/Message fields carry +// the dynamic detail (via Reason = FSM state, Message = explanation). +const ( + // ConditionTypeReady indicates whether the BGP session is fully up. + // True when sessionState == Established; False otherwise with Reason + // set to the current FSM state (e.g. "OpenSent", "Active"). + ConditionTypeReady string = "Ready" + + // ConditionTypeAccepted indicates whether the peer configuration has + // been accepted by the BGP runtime (FRR/GoBGP, etc.). + ConditionTypeAccepted string = "Accepted" +) + +// Idle sub-reasons — used as Ready.Reason when sessionState == Idle. +const ( + // IdleReasonBackOff indicates exponential back-off before next + // connection attempt. + IdleReasonBackOff string = "BackOff" + + // IdleReasonConnectionRefused indicates the TCP connection was + // actively refused by the peer. + IdleReasonConnectionRefused string = "ConnectionRefused" + + // IdleReasonHoldTimerExpired indicates the peer failed to send + // KEEPALIVE within the hold timer. + IdleReasonHoldTimerExpired string = "HoldTimerExpired" + + // IdleReasonIdle is the default when the session is simply not + // started (no recent failure). + IdleReasonIdle string = "Idle" +) + +// BGPPeer defines a BGP session to a remote peer. It binds to one or more +// BGPRouter instances via routerRef or routerSelector. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgppr +// +kubebuilder:printcolumn:name="ROUTER",type="string",JSONPath=".spec.routerRef.name" +// +kubebuilder:printcolumn:name="PEER-ADDRESS",type="string",JSONPath=".spec.address" +// +kubebuilder:printcolumn:name="PEER-ASN",type="integer",JSONPath=".spec.peerASN" +// +kubebuilder:printcolumn:name="STATE",type="string",JSONPath=".status.sessionState" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPPeer struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPPeerSpec `json:"spec,omitempty"` + Status BGPPeerStatus `json:"status,omitempty"` +} + +// BGPPeerSpec defines the desired state of BGPPeer. +type BGPPeerSpec struct { + RouterTarget `json:",inline"` + + // PeerASN is the remote AS number. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + PeerASN int64 `json:"peerASN"` + + // Address is the remote peer's IPv4 or IPv6 address. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="address must be a valid IPv4 or IPv6 address" + Address string `json:"address"` + + // RemotePort is the TCP port used to establish the BGP session with + // this peer. Defaults to 179 (the IANA-assigned BGP port) if unset. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + RemotePort *int32 `json:"remotePort,omitempty"` + + // Description is a human-readable label for this peer (e.g., "spine-1"). + // +optional + Description string `json:"description,omitempty"` + + // AuthSecretRef references a Secret in the same namespace containing the + // MD5 TCP authentication password under the key "password". + // +optional + AuthSecretRef *LocalSecretRef `json:"authSecretRef,omitempty"` + + // AddressFamilies defines the address families negotiated on this session. + // +kubebuilder:validation:MinItems=1 + AddressFamilies []AddressFamily `json:"addressFamilies"` + + // HoldTime is the BGP hold timer. Must be 0 (disabled) or >= 3s. + // Defaults to 90s if unset. + // +optional + // +kubebuilder:validation:XValidation:rule="duration(self) == duration('0s') || duration(self) >= duration('3s')",message="holdTime must be 0 or >= 3s" + HoldTime *metav1.Duration `json:"holdTime,omitempty"` + + // KeepaliveTime is the BGP keepalive interval. Must be <= HoldTime / 3. + // Defaults to 30s if unset. Maximum is 30s; the controller enforces + // the keepalive <= holdTime / 3 constraint with an Accepted=False condition. + // +optional + // +kubebuilder:validation:XValidation:rule="duration(self) == duration('0s') || duration(self) <= duration('30s')",message="keepaliveTime must be 0 or <= 30s" + KeepaliveTime *metav1.Duration `json:"keepaliveTime,omitempty"` + + // EbgpMultiHop enables eBGP multihop when the peer is not directly connected. + // When true, the TTL for BGP packets is set to 64 (vs. 1 for direct). + // +optional + EbgpMultiHop bool `json:"ebgpMultiHop,omitempty"` + + // BFD configures Bidirectional Forwarding Detection for fast failure + // detection (sub-500ms). Without BFD, convergence relies on BGP hold + // timers (default 90s). + // +optional + BFD *BGPPeerBFD `json:"bfd,omitempty"` + + // GracefulRestart configures BGP graceful restart for this peer. + // Prevents route flapping during control-plane restarts. + // +optional + GracefulRestart *BGPPeerGracefulRestart `json:"gracefulRestart,omitempty"` + + // MultiSession enables BFD over MP-BGP (RFC 8935). When true, each + // AFI/SAFI negotiates its own BFD session independently. When false + // (default), a single BFD session covers all AFI/SAFIs. + // +optional + MultiSession bool `json:"multiSession,omitempty"` + + // RouteMapIn is the name of a BGPPolicy term set applied to routes + // received from this peer (import direction). The name must match + // a BGPPolicy resource in the same namespace. + // +optional + RouteMapIn string `json:"routeMapIn,omitempty"` + + // RouteMapOut is the name of a BGPPolicy term set applied to routes + // advertised to this peer (export direction). The name must match + // a BGPPolicy resource in the same namespace. + // +optional + RouteMapOut string `json:"routeMapOut,omitempty"` + + // NextHopSelf controls next-hop behavior for iBGP sessions. + // When true, the local router's IP is used as next-hop for all + // advertised routes. Common in EVPN iBGP peering. + // +optional + NextHopSelf bool `json:"nextHopSelf,omitempty"` + + // RemovePrivateAS strips private AS numbers (64512-65535, 4200000000-4294967294) + // from the AS path on eBGP export. If set to a non-zero value, private ASNs + // are replaced with the specified value before export. + // +kubebuilder:validation:Minimum=0 + // +optional + RemovePrivateAS *int32 `json:"removePrivateAS,omitempty"` + + // UpdateSource is the local IP address or interface name used as the + // source for the BGP TCP session. Useful when the local router has + // multiple addresses or when pinning to a specific loopback. + // +optional + UpdateSource *string `json:"updateSource,omitempty"` + + // SendCommunity controls which BGP community attributes are propagated + // to this peer. When unset, communities are not sent. + // +optional + SendCommunity *SendCommunityType `json:"sendCommunity,omitempty"` + + // AllowASIn allows routes that contain the local ASN in the AS_PATH + // to be accepted from this peer. The value specifies the maximum number + // of times the local ASN may appear. Range: 1-10. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=10 + AllowASIn *int32 `json:"allowASIn,omitempty"` + + // MaximumPrefix defines the maximum number of routes accepted from this + // peer before triggering a shutdown action. + // +optional + MaximumPrefix *BGPMaximumPrefix `json:"maximumPrefix,omitempty"` + + // DefaultOriginRoute controls default route origination for this peer. + // "igp" originates a default route with IGP origin. + // "egp" originates a default route with EGP origin. + // "incomplete" originates a default route with incomplete origin. + // Empty or unset means no default route origination. + // +kubebuilder:validation:Enum=igp;egp;incomplete + // +optional + DefaultOriginRoute *OriginType `json:"defaultOriginRoute,omitempty"` + + // Authentication configures peer authentication. + // AuthSecretRef takes precedence when both are set. + // +optional + Authentication *BGPPeerAuthentication `json:"authentication,omitempty"` +} + +// BGPPeerStatus defines the observed state of BGPPeer. +type BGPPeerStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // SessionState is the current BGP FSM state of this session. + // +optional + SessionState BGPPeerState `json:"sessionState,omitempty"` + + // LastEstablishedTime is the timestamp of the most recent Established transition. + // +optional + LastEstablishedTime *metav1.Time `json:"lastEstablishedTime,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPPeerList is a list of BGPPeer resources. +// +kubebuilder:object:root=true +type BGPPeerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPPeer `json:"items"` +} + +// updatePeerConditions updates the Ready condition based on the current +// BGP FSM state. It is a reference implementation for controller authors. +// +// The method mutates and returns the conditions slice in-place via +// metav1.SetStatusCondition, following Kubernetes condition conventions: +// - Ready.Status = True only when session is Established +// - Ready.Status = False for all intermediate states, with Reason set +// to the FSM state string (e.g. "OpenSent", "Active") +// - For Idle, Reason is delegated to the caller via the idleReason arg +// +// The ObservedGeneration is set to gen so consumers can correlate this +// status with the spec generation that produced it. +func (s *BGPPeerStatus) updatePeerConditions(state BGPPeerState, gen int64, idleReason string) { + ready := metav1.Condition{ + Type: ConditionTypeReady, + ObservedGeneration: gen, + } + + switch state { + case BGPPeerStateEstablished: + ready.Status = metav1.ConditionTrue + ready.Reason = "Established" + ready.Message = "BGP session is Established; address families negotiated." + case BGPPeerStateOpenConfirm: + ready.Status = metav1.ConditionFalse + ready.Reason = "OpenConfirm" + ready.Message = "BGP session in OpenConfirm state, awaiting KEEPALIVE." + case BGPPeerStateOpenSent: + ready.Status = metav1.ConditionFalse + ready.Reason = "OpenSent" + ready.Message = "BGP OPEN message sent, awaiting peer OPEN." + case BGPPeerStateActive: + ready.Status = metav1.ConditionFalse + ready.Reason = "Active" + ready.Message = "BGP session Active, attempting to establish TCP connection." + case BGPPeerStateConnect: + ready.Status = metav1.ConditionFalse + ready.Reason = "Connect" + ready.Message = "BGP session in Connect state, waiting for TCP connection." + case BGPPeerStateIdle: + ready.Status = metav1.ConditionFalse + ready.Reason = idleReason + ready.Message = "BGP session is Idle." + default: + // Unknown state — treat as idle with a generic reason. + ready.Status = metav1.ConditionFalse + ready.Reason = "Unknown" + ready.Message = fmt.Sprintf("BGP session is in unknown state %q.", state) + } + + meta.SetStatusCondition(&s.Conditions, ready) +} + +// SetAcceptedCondition updates (or creates) the Accepted condition. +// Call this after validating and accepting the peer spec into the runtime. +func (s *BGPPeerStatus) SetAcceptedCondition(accepted bool, gen int64, reason, message string) { + status := metav1.ConditionFalse + if accepted { + status = metav1.ConditionTrue + } + cond := metav1.Condition{ + Type: ConditionTypeAccepted, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: gen, + } + meta.SetStatusCondition(&s.Conditions, cond) +} + +func init() { + SchemeBuilder.Register(&BGPPeer{}, &BGPPeerList{}) +} diff --git a/api/network/v1alpha1/peer_types_test.go b/api/network/v1alpha1/peer_types_test.go new file mode 100644 index 00000000..ef6c7ca2 --- /dev/null +++ b/api/network/v1alpha1/peer_types_test.go @@ -0,0 +1,867 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestPeer() *BGPPeer { + return &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: 65000, + Address: "10.0.0.2", + AddressFamilies: []AddressFamily{ + { + AFI: AFIIPv4, + SAFI: SAFIUnicast, + }, + }, + }, + } +} + +// TestBGPPeerDeepCopy verifies that DeepCopy produces an independent copy. +func TestBGPPeerDeepCopy(t *testing.T) { + orig := newTestPeer() + dup := orig.DeepCopy() + + dup.Spec.PeerASN = 65001 + dup.Spec.Address = "10.0.0.3" + dup.Spec.Description = "mutated" + + if orig.Spec.PeerASN != 65000 { + t.Errorf("PeerASN mutated: got %d, want 65000", orig.Spec.PeerASN) + } + if orig.Spec.Address != "10.0.0.2" { + t.Errorf("Address mutated: got %q", orig.Spec.Address) + } + if orig.Spec.Description != "" { + t.Errorf("Description mutated: got %q", orig.Spec.Description) + } +} + +// TestBGPPeerDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestBGPPeerDeepCopyNil(t *testing.T) { + var p *BGPPeer + if p.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestBGPPeerJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestBGPPeerJSONRoundTrip(t *testing.T) { + orig := newTestPeer() + orig.Spec.Description = "spine-1" + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.PeerASN != orig.Spec.PeerASN { + t.Errorf("PeerASN: got %d, want %d", got.Spec.PeerASN, orig.Spec.PeerASN) + } + if got.Spec.Address != orig.Spec.Address { + t.Errorf("Address: got %q, want %q", got.Spec.Address, orig.Spec.Address) + } + if got.Spec.Description != orig.Spec.Description { + t.Errorf("Description: got %q, want %q", got.Spec.Description, orig.Spec.Description) + } +} + +// TestBGPPeerListDeepCopy verifies that BGPPeerList.DeepCopy produces +// independent copies of each item. +func TestBGPPeerListDeepCopy(t *testing.T) { + list := &BGPPeerList{ + Items: []BGPPeer{*newTestPeer()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.PeerASN = 99999 + + if list.Items[0].Spec.PeerASN != 65000 { + t.Errorf("original list item mutated via copy") + } +} + +// TestBGPPeerPeerASNFieldName verifies the JSON key is "peerASN". +func TestBGPPeerPeerASNFieldName(t *testing.T) { + orig := newTestPeer() + data, err := json.Marshal(orig.Spec) + 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) + } + + raw, ok := m["peerASN"] + if !ok { + t.Fatal("expected JSON key \"peerASN\" not found") + } + val, ok := raw.(float64) + if !ok { + t.Fatalf("expected peerASN to be a number, got %T", raw) + } + if int64(val) != orig.Spec.PeerASN { + t.Errorf("peerASN value: got %v, want %d", val, orig.Spec.PeerASN) + } +} + +// TestBGPPeerLargePeerASN verifies that 4-byte ASNs (values above signed int32 max) +// survive JSON round-trip correctly. +func TestBGPPeerLargePeerASN(t *testing.T) { + // Max 4-byte ASN — the boundary of the uint32 range. + const maxASN = int64(4294967295) + + peer := &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "large-asn-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: maxASN, + Address: "10.0.0.2", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.PeerASN != maxASN { + t.Errorf("PeerASN after round-trip: got %d, want %d", got.Spec.PeerASN, maxASN) + } +} + +// TestBGPPeerPeerASNAboveSignedInt32Max verifies that values > 2^31-1 +// (the signed int32 ceiling) are handled correctly. +func TestBGPPeerPeerASNAboveSignedInt32Max(t *testing.T) { + // 2^31 = 2147483648 — the first value beyond signed int32 range. + const aboveSignedMax int64 = 2147483648 + + peer := &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "above-int32-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: aboveSignedMax, + Address: "10.0.0.2", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.PeerASN != aboveSignedMax { + t.Errorf("PeerASN after round-trip: got %d, want %d", got.Spec.PeerASN, aboveSignedMax) + } +} + +// TestConditionConstants verifies the condition type and idle reason constants. +func TestConditionConstants(t *testing.T) { + if ConditionTypeReady != "Ready" { + t.Errorf("ConditionTypeReady = %q, want %q", ConditionTypeReady, "Ready") + } + if ConditionTypeAccepted != "Accepted" { + t.Errorf("ConditionTypeAccepted = %q, want %q", ConditionTypeAccepted, "Accepted") + } + if IdleReasonBackOff != "BackOff" { + t.Errorf("IdleReasonBackOff = %q, want %q", IdleReasonBackOff, "BackOff") + } + if IdleReasonConnectionRefused != "ConnectionRefused" { + t.Errorf("IdleReasonConnectionRefused = %q, want %q", IdleReasonConnectionRefused, "ConnectionRefused") + } + if IdleReasonHoldTimerExpired != "HoldTimerExpired" { + t.Errorf("IdleReasonHoldTimerExpired = %q, want %q", IdleReasonHoldTimerExpired, "HoldTimerExpired") + } + if IdleReasonIdle != "Idle" { + t.Errorf("IdleReasonIdle = %q, want %q", IdleReasonIdle, "Idle") + } +} + +// TestUpdatePeerConditions_Established verifies Ready=True for Established. +func TestUpdatePeerConditions_Established(t *testing.T) { + var status BGPPeerStatus + status.updatePeerConditions(BGPPeerStateEstablished, 42, "") + + c := findCondition(status.Conditions, ConditionTypeReady) + if c == nil { + t.Fatal("Ready condition not found") + } + if c.Status != metav1.ConditionTrue { + t.Errorf("Ready.Status = %s, want True", c.Status) + } + if c.Reason != "Established" { + t.Errorf("Ready.Reason = %q, want %q", c.Reason, "Established") + } + if c.ObservedGeneration != 42 { + t.Errorf("ObservedGeneration = %d, want 42", c.ObservedGeneration) + } +} + +// TestUpdatePeerConditions_Intermediate verifies Ready=False with FSM Reason. +func TestUpdatePeerConditions_Intermediate(t *testing.T) { + tests := []struct { + state BGPPeerState + reason string + }{ + {BGPPeerStateOpenConfirm, "OpenConfirm"}, + {BGPPeerStateOpenSent, "OpenSent"}, + {BGPPeerStateActive, "Active"}, + {BGPPeerStateConnect, "Connect"}, + } + for _, tt := range tests { + t.Run(string(tt.state), func(t *testing.T) { + var status BGPPeerStatus + status.updatePeerConditions(tt.state, 1, "") + + c := findCondition(status.Conditions, ConditionTypeReady) + if c == nil { + t.Fatal("Ready condition not found") + } + if c.Status != metav1.ConditionFalse { + t.Errorf("Ready.Status = %s, want False", c.Status) + } + if c.Reason != tt.reason { + t.Errorf("Ready.Reason = %q, want %q", c.Reason, tt.reason) + } + }) + } +} + +// TestUpdatePeerConditions_Idle verifies Ready=False with caller-supplied idle reason. +func TestUpdatePeerConditions_Idle(t *testing.T) { + tests := []struct { + reason string + }{ + {IdleReasonBackOff}, + {IdleReasonConnectionRefused}, + {IdleReasonHoldTimerExpired}, + {IdleReasonIdle}, + {"CustomReason"}, + } + for _, tt := range tests { + t.Run(tt.reason, func(t *testing.T) { + var status BGPPeerStatus + status.updatePeerConditions(BGPPeerStateIdle, 7, tt.reason) + + c := findCondition(status.Conditions, ConditionTypeReady) + if c == nil { + t.Fatal("Ready condition not found") + } + if c.Status != metav1.ConditionFalse { + t.Errorf("Ready.Status = %s, want False", c.Status) + } + if c.Reason != tt.reason { + t.Errorf("Ready.Reason = %q, want %q", c.Reason, tt.reason) + } + }) + } +} + +// TestUpdatePeerConditions_Unknown verifies the default branch. +func TestUpdatePeerConditions_Unknown(t *testing.T) { + var status BGPPeerStatus + status.updatePeerConditions("NonExistent", 0, "") + + c := findCondition(status.Conditions, ConditionTypeReady) + if c == nil { + t.Fatal("Ready condition not found") + } + if c.Status != metav1.ConditionFalse { + t.Errorf("Ready.Status = %s, want False", c.Status) + } + if c.Reason != "Unknown" { + t.Errorf("Ready.Reason = %q, want %q", c.Reason, "Unknown") + } +} + +// TestUpdatePeerConditions_Idempotent verifies SetStatusCondition replaces previous Ready. +func TestUpdatePeerConditions_Idempotent(t *testing.T) { + var status BGPPeerStatus + + // First update: Idle. + status.updatePeerConditions(BGPPeerStateIdle, 1, IdleReasonIdle) + if len(status.Conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(status.Conditions)) + } + + // Second update: Established — should replace, not append. + status.updatePeerConditions(BGPPeerStateEstablished, 2, "") + if len(status.Conditions) != 1 { + t.Fatalf("expected 1 condition after update, got %d", len(status.Conditions)) + } + c := findCondition(status.Conditions, ConditionTypeReady) + if c == nil || c.Status != metav1.ConditionTrue { + t.Error("Ready should be True after Established update") + } + if c.ObservedGeneration != 2 { + t.Errorf("ObservedGeneration = %d, want 2", c.ObservedGeneration) + } +} + +// TestSetAcceptedCondition verifies the Accepted condition helper. +func TestSetAcceptedCondition(t *testing.T) { + t.Run("accepted", func(t *testing.T) { + var status BGPPeerStatus + status.SetAcceptedCondition(true, 5, "ConfigAccepted", "Peer config accepted by runtime.") + + c := findCondition(status.Conditions, ConditionTypeAccepted) + if c == nil { + t.Fatal("Accepted condition not found") + } + if c.Status != metav1.ConditionTrue { + t.Errorf("Accepted.Status = %s, want True", c.Status) + } + if c.Reason != "ConfigAccepted" { + t.Errorf("Accepted.Reason = %q, want %q", c.Reason, "ConfigAccepted") + } + }) + + t.Run("rejected", func(t *testing.T) { + var status BGPPeerStatus + status.SetAcceptedCondition(false, 3, "InvalidPeerAddress", "address must be a valid IP") + + c := findCondition(status.Conditions, ConditionTypeAccepted) + if c == nil { + t.Fatal("Accepted condition not found") + } + if c.Status != metav1.ConditionFalse { + t.Errorf("Accepted.Status = %s, want False", c.Status) + } + }) + + t.Run("toggles", func(t *testing.T) { + var status BGPPeerStatus + // Accept first. + status.SetAcceptedCondition(true, 1, "Accepted", "ok") + c := findCondition(status.Conditions, ConditionTypeAccepted) + if c == nil || c.Status != metav1.ConditionTrue { + t.Error("expected True after first call") + } + // Then reject — should flip. + status.SetAcceptedCondition(false, 2, "Rejected", "bad") + c = findCondition(status.Conditions, ConditionTypeAccepted) + if c == nil || c.Status != metav1.ConditionFalse { + t.Error("expected False after second call") + } + if c.ObservedGeneration != 2 { + t.Errorf("ObservedGeneration = %d, want 2", c.ObservedGeneration) + } + }) +} + +// TestUpdatePeerConditions_CoexistWithAccepted verifies Ready and Accepted +// conditions can coexist in the same Conditions slice. +func TestUpdatePeerConditions_CoexistWithAccepted(t *testing.T) { + var status BGPPeerStatus + status.SetAcceptedCondition(true, 1, "Accepted", "config accepted") + status.updatePeerConditions(BGPPeerStateEstablished, 1, "") + + if len(status.Conditions) != 2 { + t.Fatalf("expected 2 conditions, got %d", len(status.Conditions)) + } + + ready := findCondition(status.Conditions, ConditionTypeReady) + accepted := findCondition(status.Conditions, ConditionTypeAccepted) + if ready == nil || accepted == nil { + t.Error("both Ready and Accepted conditions should be present") + } +} + +// findCondition returns the condition of the given type, or nil if not found. +func findCondition(conds []metav1.Condition, typ string) *metav1.Condition { + for i := range conds { + if conds[i].Type == typ { + return &conds[i] + } + } + return nil +} + +// TestBGPPeerNewFieldsJSONRoundTrip verifies that multiSession, routeMapIn, +// and routeMapOut survive JSON serialization and deserialization. +func TestBGPPeerNewFieldsJSONRoundTrip(t *testing.T) { + peer := &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "new-fields-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: 65000, + Address: "10.0.0.2", + MultiSession: true, + RouteMapIn: "evpn-import", + RouteMapOut: "evpn-export", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.MultiSession != peer.Spec.MultiSession { + t.Errorf("MultiSession: got %v, want %v", got.Spec.MultiSession, peer.Spec.MultiSession) + } + if got.Spec.RouteMapIn != peer.Spec.RouteMapIn { + t.Errorf("RouteMapIn: got %q, want %q", got.Spec.RouteMapIn, peer.Spec.RouteMapIn) + } + if got.Spec.RouteMapOut != peer.Spec.RouteMapOut { + t.Errorf("RouteMapOut: got %q, want %q", got.Spec.RouteMapOut, peer.Spec.RouteMapOut) + } +} + +// TestBGPPeerNewFieldsJSONKeys verifies the JSON key names for the new fields +// are present when set to non-zero values. +func TestBGPPeerNewFieldsJSONKeys(t *testing.T) { + peer := &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "keys-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: 65000, + Address: "10.0.0.2", + MultiSession: true, + RouteMapIn: "evpn-import", + RouteMapOut: "evpn-export", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(peer.Spec) + 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) + } + + for _, key := range []string{"multiSession", "routeMapIn", "routeMapOut"} { + raw, ok := m[key] + if !ok { + t.Fatalf("expected JSON key %q not found", key) + } + _ = raw + } +} + +// TestBGPPeerRemotePortJSONRoundTrip verifies that RemotePort survives JSON +// serialization and deserialization, including the unset (nil) case. +func TestBGPPeerRemotePortJSONRoundTrip(t *testing.T) { + peer := newTestPeer() + port := int32(1179) + peer.Spec.RemotePort = &port + + data, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.RemotePort == nil || *got.Spec.RemotePort != port { + t.Errorf("RemotePort: got %v, want %d", got.Spec.RemotePort, port) + } +} + +// TestBGPPeerRemotePortOmittedWhenNil verifies that an unset RemotePort is +// omitted from the marshaled JSON rather than serialized as 0. +func TestBGPPeerRemotePortOmittedWhenNil(t *testing.T) { + peer := newTestPeer() + + data, err := json.Marshal(peer.Spec) + 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) + } + + if _, ok := m["remotePort"]; ok { + t.Error("expected \"remotePort\" to be omitted when unset") + } +} + +// TestBGPPeerRemotePortDeepCopy verifies DeepCopy produces an independent +// pointer for RemotePort. +func TestBGPPeerRemotePortDeepCopy(t *testing.T) { + peer := newTestPeer() + port := int32(179) + peer.Spec.RemotePort = &port + + copied := peer.DeepCopy() + *copied.Spec.RemotePort = 1179 + + if *peer.Spec.RemotePort != 179 { + t.Errorf("RemotePort mutated via copy: got %d, want 179", *peer.Spec.RemotePort) + } +} + +// TestBGPPeerNewFieldsDeepCopy verifies DeepCopy handles the new fields correctly. +func TestBGPPeerNewFieldsDeepCopy(t *testing.T) { + peer := &BGPPeer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPeer", + }, + ObjectMeta: metav1.ObjectMeta{Name: "copy-new-fields-peer"}, + Spec: BGPPeerSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + PeerASN: 65000, + Address: "10.0.0.2", + MultiSession: true, + RouteMapIn: "evpn-import", + RouteMapOut: "evpn-export", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + copied := peer.DeepCopy() + + copied.Spec.MultiSession = false + copied.Spec.RouteMapIn = "mutated" + copied.Spec.RouteMapOut = "mutated" + + if peer.Spec.MultiSession != true { + t.Errorf("MultiSession mutated via copy: got %v, want true", peer.Spec.MultiSession) + } + if peer.Spec.RouteMapIn != "evpn-import" { + t.Errorf("RouteMapIn mutated via copy: got %q, want %q", peer.Spec.RouteMapIn, "evpn-import") + } + if peer.Spec.RouteMapOut != "evpn-export" { + t.Errorf("RouteMapOut mutated via copy: got %q, want %q", peer.Spec.RouteMapOut, "evpn-export") + } +} + +// TestSendCommunityTypeConstants verifies the enum constants. +func TestSendCommunityTypeConstants(t *testing.T) { + if SendCommunityTypeStandard != "standard" { + t.Errorf("SendCommunityTypeStandard = %q, want %q", SendCommunityTypeStandard, "standard") + } + if SendCommunityTypeExtended != "extended" { + t.Errorf("SendCommunityTypeExtended = %q, want %q", SendCommunityTypeExtended, "extended") + } + if SendCommunityTypeLarge != "large" { + t.Errorf("SendCommunityTypeLarge = %q, want %q", SendCommunityTypeLarge, "large") + } + if SendCommunityTypeBoth != "both" { + t.Errorf("SendCommunityTypeBoth = %q, want %q", SendCommunityTypeBoth, "both") + } +} + +// TestMaxPrefixShutdownActionConstants verifies the enum constants. +func TestMaxPrefixShutdownActionConstants(t *testing.T) { + if MaxPrefixShutdownActionWarningOnly != "warning-only" { + t.Errorf("MaxPrefixShutdownActionWarningOnly = %q, want %q", MaxPrefixShutdownActionWarningOnly, "warning-only") + } + if MaxPrefixShutdownActionRestart != "restart" { + t.Errorf("MaxPrefixShutdownActionRestart = %q, want %q", MaxPrefixShutdownActionRestart, "restart") + } + if MaxPrefixShutdownActionShutdown != "shutdown" { + t.Errorf("MaxPrefixShutdownActionShutdown = %q, want %q", MaxPrefixShutdownActionShutdown, "shutdown") + } +} + +// TestBGPPeerExtendedFieldsJSONRoundTrip verifies that updateSource, +// sendCommunity, allowASIn, and maximumPrefix survive JSON round-trip. +func TestBGPPeerExtendedFieldsJSONRoundTrip(t *testing.T) { + peer := newTestPeer() + src := "lo" + comm := SendCommunityTypeBoth + allowAS := int32(3) + threshold := int32(75) + peer.Spec.UpdateSource = &src + peer.Spec.SendCommunity = &comm + peer.Spec.AllowASIn = &allowAS + peer.Spec.MaximumPrefix = &BGPMaximumPrefix{ + Limit: 1000, + ThresholdPercent: &threshold, + ShutdownAction: MaxPrefixShutdownActionShutdown, + } + + data, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPeer + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.UpdateSource == nil || *got.Spec.UpdateSource != src { + t.Errorf("UpdateSource: got %v, want %q", got.Spec.UpdateSource, src) + } + if got.Spec.SendCommunity == nil || *got.Spec.SendCommunity != comm { + t.Errorf("SendCommunity: got %v, want %q", got.Spec.SendCommunity, comm) + } + if got.Spec.AllowASIn == nil || *got.Spec.AllowASIn != allowAS { + t.Errorf("AllowASIn: got %v, want %d", got.Spec.AllowASIn, allowAS) + } + if got.Spec.MaximumPrefix == nil { + t.Fatal("MaximumPrefix is nil") + } else { + mp := got.Spec.MaximumPrefix + if mp.Limit != 1000 { + t.Errorf("MaximumPrefix.Limit: got %d, want 1000", mp.Limit) + } + if mp.ThresholdPercent == nil || *mp.ThresholdPercent != threshold { + t.Errorf("MaximumPrefix.ThresholdPercent: got %v, want %d", mp.ThresholdPercent, threshold) + } + if mp.ShutdownAction != MaxPrefixShutdownActionShutdown { + t.Errorf("MaximumPrefix.ShutdownAction: got %q, want %q", mp.ShutdownAction, MaxPrefixShutdownActionShutdown) + } + } +} + +// TestBGPPeerExtendedFieldsJSONKeys verifies the JSON key names for the +// extended fields are present when set. +func TestBGPPeerExtendedFieldsJSONKeys(t *testing.T) { + peer := newTestPeer() + src := "lo" + comm := SendCommunityTypeStandard + allowAS := int32(1) + peer.Spec.UpdateSource = &src + peer.Spec.SendCommunity = &comm + peer.Spec.AllowASIn = &allowAS + peer.Spec.MaximumPrefix = &BGPMaximumPrefix{ + Limit: 500, + ShutdownAction: MaxPrefixShutdownActionWarningOnly, + } + + data, err := json.Marshal(peer.Spec) + 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) + } + + for _, key := range []string{"updateSource", "sendCommunity", "allowASIn", "maximumPrefix"} { + if _, ok := m[key]; !ok { + t.Fatalf("expected JSON key %q not found", key) + } + } +} + +// TestBGPPeerExtendedFieldsOmittedWhenNil verifies that the optional extended +// fields are omitted from JSON when unset. +func TestBGPPeerExtendedFieldsOmittedWhenNil(t *testing.T) { + peer := newTestPeer() + + data, err := json.Marshal(peer.Spec) + 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) + } + + for _, key := range []string{"updateSource", "sendCommunity", "allowASIn", "maximumPrefix"} { + if _, ok := m[key]; ok { + t.Errorf("expected %q to be omitted when unset", key) + } + } +} + +// TestBGPPeerExtendedFieldsDeepCopy verifies DeepCopy produces independent +// copies of the extended pointer fields. +func TestBGPPeerExtendedFieldsDeepCopy(t *testing.T) { + peer := newTestPeer() + src := "lo" + comm := SendCommunityTypeBoth + allowAS := int32(5) + threshold := int32(80) + peer.Spec.UpdateSource = &src + peer.Spec.SendCommunity = &comm + peer.Spec.AllowASIn = &allowAS + peer.Spec.MaximumPrefix = &BGPMaximumPrefix{ + Limit: 2000, + ThresholdPercent: &threshold, + ShutdownAction: MaxPrefixShutdownActionRestart, + } + + copied := peer.DeepCopy() + + // Mutate the copy. + newSrc := "eth0" + newComm := SendCommunityTypeLarge + newAllowAS := int32(10) + newThreshold := int32(50) + copied.Spec.UpdateSource = &newSrc + copied.Spec.SendCommunity = &newComm + copied.Spec.AllowASIn = &newAllowAS + copied.Spec.MaximumPrefix.Limit = 9999 + *copied.Spec.MaximumPrefix.ThresholdPercent = newThreshold + copied.Spec.MaximumPrefix.ShutdownAction = MaxPrefixShutdownActionWarningOnly + + // Verify original is unchanged. + if *peer.Spec.UpdateSource != src { + t.Errorf("UpdateSource mutated: got %q, want %q", *peer.Spec.UpdateSource, src) + } + if *peer.Spec.SendCommunity != comm { + t.Errorf("SendCommunity mutated: got %q, want %q", *peer.Spec.SendCommunity, comm) + } + if *peer.Spec.AllowASIn != allowAS { + t.Errorf("AllowASIn mutated: got %d, want %d", *peer.Spec.AllowASIn, allowAS) + } + if peer.Spec.MaximumPrefix.Limit != 2000 { + t.Errorf("MaximumPrefix.Limit mutated: got %d, want 2000", peer.Spec.MaximumPrefix.Limit) + } + if *peer.Spec.MaximumPrefix.ThresholdPercent != threshold { + t.Errorf("ThresholdPercent mutated: got %d, want %d", *peer.Spec.MaximumPrefix.ThresholdPercent, threshold) + } + if peer.Spec.MaximumPrefix.ShutdownAction != MaxPrefixShutdownActionRestart { + t.Errorf("ShutdownAction mutated: got %q, want %q", peer.Spec.MaximumPrefix.ShutdownAction, MaxPrefixShutdownActionRestart) + } +} + +// TestBGPMaximumPrefixJSONRoundTrip verifies the BGPMaximumPrefix struct +// serialises and deserialises correctly, including when ThresholdPercent is nil. +func TestBGPMaximumPrefixJSONRoundTrip(t *testing.T) { + t.Run("with threshold", func(t *testing.T) { + threshold := int32(75) + orig := BGPMaximumPrefix{ + Limit: 1000, + ThresholdPercent: &threshold, + ShutdownAction: MaxPrefixShutdownActionShutdown, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPMaximumPrefix + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Limit != orig.Limit { + t.Errorf("Limit: got %d, want %d", got.Limit, orig.Limit) + } + if got.ThresholdPercent == nil || *got.ThresholdPercent != *orig.ThresholdPercent { + t.Errorf("ThresholdPercent: got %v, want %d", got.ThresholdPercent, *orig.ThresholdPercent) + } + if got.ShutdownAction != orig.ShutdownAction { + t.Errorf("ShutdownAction: got %q, want %q", got.ShutdownAction, orig.ShutdownAction) + } + }) + + t.Run("without threshold", func(t *testing.T) { + orig := BGPMaximumPrefix{ + Limit: 500, + ShutdownAction: MaxPrefixShutdownActionWarningOnly, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPMaximumPrefix + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Limit != 500 { + t.Errorf("Limit: got %d, want 500", got.Limit) + } + if got.ThresholdPercent != nil { + t.Errorf("ThresholdPercent: got %v, want nil", got.ThresholdPercent) + } + if got.ShutdownAction != MaxPrefixShutdownActionWarningOnly { + t.Errorf("ShutdownAction: got %q, want %q", got.ShutdownAction, MaxPrefixShutdownActionWarningOnly) + } + + // Verify thresholdPercent is omitted from JSON. + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal to map: %v", err) + } + if _, ok := m["thresholdPercent"]; ok { + t.Error("expected thresholdPercent to be omitted when nil") + } + }) +} diff --git a/api/network/v1alpha1/policy_types.go b/api/network/v1alpha1/policy_types.go new file mode 100644 index 00000000..25e3d747 --- /dev/null +++ b/api/network/v1alpha1/policy_types.go @@ -0,0 +1,372 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BGPPolicyDirection is the direction in which a BGPPolicy is applied. +// +// +kubebuilder:validation:Enum=import;export +type BGPPolicyDirection string + +const ( + // BGPPolicyDirectionImport applies the policy to routes received from peers. + BGPPolicyDirectionImport BGPPolicyDirection = "import" + + // BGPPolicyDirectionExport applies the policy to routes advertised to peers. + BGPPolicyDirectionExport BGPPolicyDirection = "export" +) + +// BGPPolicyAction is the disposition applied when a policy term matches. +// +// +kubebuilder:validation:Enum=permit;deny +type BGPPolicyAction string + +const ( + // BGPPolicyActionPermit allows the route and optionally applies set actions. + BGPPolicyActionPermit BGPPolicyAction = "permit" + + // BGPPolicyActionDeny drops the route. Set actions must not be specified. + BGPPolicyActionDeny BGPPolicyAction = "deny" +) + +// BGPPolicy defines composable, ordered routing policy statements applied to a +// BGPRouter in a specific direction (import or export). It binds to one or more +// BGPRouter instances via routerRef or routerSelector. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgpp +// +kubebuilder:printcolumn:name="DIRECTION",type="string",JSONPath=".spec.direction" +// +kubebuilder:printcolumn:name="TERMS",type="integer",JSONPath=".spec.terms" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPPolicySpec `json:"spec,omitempty"` + Status BGPPolicyStatus `json:"status,omitempty"` +} + +// BGPPolicySpec defines the desired route policy state. +// +// +kubebuilder:validation:XValidation:rule="self.terms.all(t1, self.terms.filter(t2, t2.sequence == t1.sequence).size() == 1)",message="Term sequence numbers must be unique" +type BGPPolicySpec struct { + RouterTarget `json:",inline"` + + // Direction is the policy direction: import or export. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=import;export + Direction BGPPolicyDirection `json:"direction"` + + // Terms is the ordered list of policy statements. + // Evaluated from lowest to highest sequence number. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=32 + Terms []BGPPolicyTerm `json:"terms"` +} + +// BGPPolicyTerm is a single ordered policy statement with match conditions and an action. +// +// +kubebuilder:validation:XValidation:rule="self.action == 'deny' ? !has(self.set) : true",message="set actions are not permitted on deny terms" +type BGPPolicyTerm struct { + // Sequence is the evaluation order. Lower values are evaluated first. + // Must be unique within the policy. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Sequence int32 `json:"sequence"` + + // Match defines the conditions under which this term fires. + Match BGPPolicyMatch `json:"match"` + + // Action is the disposition when this term matches. + // +kubebuilder:validation:Enum=permit;deny + Action BGPPolicyAction `json:"action"` + + // Set defines mutations applied when action is "permit". + // Must not be set when action is "deny". + // +optional + Set *BGPPolicySetActions `json:"set,omitempty"` +} + +// BGPPolicyMatch defines the conditions under which a policy term fires. +// +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.prefixList) || self.prefixList.size() == 0",message="prefixList must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.asPathFilter) || self.asPathFilter.pattern.size() == 0",message="asPathFilter must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.communityMatch) || self.communityMatch.size() == 0",message="communityMatch must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.evpnRouteType) || self.evpnRouteType.size() == 0",message="evpnRouteType must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.vni) || self.vni == 0",message="vni must be unset when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.macAddress) || self.macAddress.size() == 0",message="macAddress must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.ipPrefix) || self.ipPrefix.size() == 0",message="ipPrefix must be empty when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.localPreference) || self.localPreference == 0",message="localPreference must be unset when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.med) || self.med == 0",message="med must be unset when any is true" +// +kubebuilder:validation:XValidation:rule="!has(self.any) || !self.any || !has(self.addressFamilies) || self.addressFamilies.size() == 0",message="addressFamilies must be empty when any is true" +type BGPPolicyMatch struct { + // Any matches all routes. When true, all other match fields are ignored. + // +optional + Any bool `json:"any,omitempty"` + + // AddressFamilies constrains the match to specific AFI/SAFI combinations. + // If empty, all address families are matched. + // +optional + // +kubebuilder:validation:MaxItems=8 + AddressFamilies []AddressFamily `json:"addressFamilies,omitempty"` + + // PrefixList constrains the match to routes whose prefix matches one of + // the given CIDR blocks. Each entry must be a valid IPv4 or IPv6 CIDR. + // +optional + // +kubebuilder:validation:MaxItems=256 + // +kubebuilder:validation:items:MaxLength=43 + // +kubebuilder:validation:XValidation:rule="self.all(p, isCIDR(p))",message="each prefixList entry must be a valid IPv4 or IPv6 CIDR" + PrefixList []string `json:"prefixList,omitempty"` + + // ASPathFilter matches routes by AS path using a regex pattern. + // The pattern is matched against the full AS path string (space-separated ASNs). + // +optional + ASPathFilter *ASPathFilter `json:"asPathFilter,omitempty"` + + // CommunityMatch matches routes by BGP community. + // Each entry is a community string in ASN:NN or IP:NN format. + // +optional + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:MaxLength=32 + // +kubebuilder:validation:XValidation:rule="self.all(c, c.matches('^[0-9]{1,10}:[0-9]{1,10}$') || c.matches('^[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}:[0-9]{1,10}$'))",message="each communityMatch entry must be in ASN:NN or IP:NN format" + CommunityMatch []string `json:"communityMatch,omitempty"` + + // EVPNRouteType matches specific EVPN route types. + // Only meaningful when l2vpn/evpn address family is configured. + // +optional + // +kubebuilder:validation:MaxItems=5 + EVPNRouteType []EVPNRouteType `json:"evpnRouteType,omitempty"` + + // VNI matches routes by VNI (VXLAN Network Identifier). + // Range: 0–16777215 (24-bit VNI). + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=16777215 + VNI *uint32 `json:"vni,omitempty"` + + // MACAddress matches MAC-IP routes (EVPN Type-2) by MAC address. + // Format: colon-separated hex bytes (e.g., "aa:bb:cc:dd:ee:ff"). + // +optional + // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$` + MACAddress *string `json:"macAddress,omitempty"` + + // IPPrefix matches routes by exact IP prefix (CIDR notation). + // +optional + // +kubebuilder:validation:MaxLength=43 + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="ipPrefix must be a valid IPv4 or IPv6 CIDR" + IPPrefix *string `json:"ipPrefix,omitempty"` + + // LocalPreference matches routes by BGP LOCAL_PREF value. + // +optional + // +kubebuilder:validation:Minimum=0 + LocalPreference *int32 `json:"localPreference,omitempty"` + + // MED matches routes by Multi-Exit Discriminator value. + // +optional + // +kubebuilder:validation:Minimum=0 + MED *int32 `json:"med,omitempty"` +} + +// ASPathFilter matches BGP routes by AS path using a regex pattern. +type ASPathFilter struct { + // Pattern is a regular expression matched against the AS path. + // The AS path is represented as a space-separated string of ASNs. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Pattern string `json:"pattern"` + + // MatchType determines whether the pattern must match the full path + // or can match a substring. Default: "contains". + // +kubebuilder:validation:Enum=full;contains + // +kubebuilder:default=contains + MatchType ASPathMatchType `json:"matchType,omitempty"` +} + +// ASPathMatchType determines how an AS path filter pattern is applied. +// +// +kubebuilder:validation:Enum=full;contains +type ASPathMatchType string + +const ( + // ASPathMatchFull requires the pattern to match the entire AS path. + ASPathMatchFull ASPathMatchType = "full" + + // ASPathMatchContains requires the pattern to match a substring of the AS path. + ASPathMatchContains ASPathMatchType = "contains" +) + +// EVPNRouteType is an EVPN route type per RFC 7432. +// +// +kubebuilder:validation:Enum=inclusiveMulticastEthernetTag;macIPAdvertisement;iPPrefixAdvertisement;stickyMACAddress;iPv6PrefixAdvertisement +type EVPNRouteType string + +const ( + // EVPNRouteTypeInclusiveMulticastEthernetTag is Type-1: Inclusive Multicast Ethernet Tag route. + EVPNRouteTypeInclusiveMulticastEthernetTag EVPNRouteType = "inclusiveMulticastEthernetTag" + + // EVPNRouteTypeMACIPAdvertisement is Type-2: MAC-IP Advertisement route. + EVPNRouteTypeMACIPAdvertisement EVPNRouteType = "macIPAdvertisement" + + // EVPNRouteTypeIPPrefixAdvertisement is Type-3: IP Prefix Advertisement route. + EVPNRouteTypeIPPrefixAdvertisement EVPNRouteType = "iPPrefixAdvertisement" + + // EVPNRouteTypeStickyMACAddress is Type-4: Sticky MAC Address route. + EVPNRouteTypeStickyMACAddress EVPNRouteType = "stickyMACAddress" + + // EVPNRouteTypeIPv6PrefixAdvertisement is Type-5: IPv6 Prefix Advertisement route. + EVPNRouteTypeIPv6PrefixAdvertisement EVPNRouteType = "iPv6PrefixAdvertisement" +) + +// BGPPolicySetActions defines mutations applied when a term matches with action "permit". +type BGPPolicySetActions struct { + // Communities defines community add/remove operations. + // +optional + Communities *CommunitySet `json:"communities,omitempty"` + + // LocalPreference sets the LOCAL_PREF attribute. + // Only meaningful on import (iBGP) or export to iBGP peers. + // +optional + // +kubebuilder:validation:Minimum=0 + LocalPreference *int32 `json:"localPreference,omitempty"` + + // Origin sets the BGP origin attribute. + // +optional + Origin *BGPOrigin `json:"origin,omitempty"` + + // AsPath manipulates the AS path (prepend or replace). + // +optional + AsPath *AsPathSet `json:"asPath,omitempty"` + + // NextHop overrides the next-hop attribute. + // +optional + NextHop *NextHopSet `json:"nextHop,omitempty"` + + // ExtCommunities defines extended community add/remove operations. + // Each entry must be in a valid extended community format (ASN:NN, IP:NN, + // or type-specific like "rt:65000:100"). + // +optional + ExtCommunities *ExtendedCommunitySet `json:"extCommunities,omitempty"` + + // Metric sets the MED (Multi-Exit Discriminator) attribute. + // +optional + // +kubebuilder:validation:Minimum=0 + Metric *int32 `json:"metric,omitempty"` + + // Color sets the SRv6 policy color for path selection. + // +optional + // +kubebuilder:validation:Minimum=0 + Color *int32 `json:"color,omitempty"` +} + +// BGPOrigin is the BGP origin attribute per RFC 4271. +// +// +kubebuilder:validation:Enum=igp;egp;incomplete +type BGPOrigin string + +const ( + // BGPOriginIGP indicates the route was learned via an IGP. + BGPOriginIGP BGPOrigin = "igp" + + // BGPOriginEGP indicates the route was learned via the EGP protocol. + BGPOriginEGP BGPOrigin = "egp" + + // BGPOriginIncomplete indicates the route origin is unknown. + BGPOriginIncomplete BGPOrigin = "incomplete" +) + +// AsPathSet defines AS path manipulation operations. +// +// +kubebuilder:validation:XValidation:rule="!has(self.prepend) || !has(self.replace) || self.replace.size() == 0",message="prepend and replace are mutually exclusive" +type AsPathSet struct { + // Prepend adds an ASN to the AS path N times. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=10 + Prepend *uint32 `json:"prepend,omitempty"` + + // ASN is the AS number to prepend (used when prepend is set). + // Defaults to the local ASN if not specified. + // +optional + ASN *int64 `json:"asn,omitempty"` + + // Replace replaces the entire AS path with the given list. + // Mutually exclusive with prepend. + // +optional + // +kubebuilder:validation:MaxItems=32 + Replace []int64 `json:"replace,omitempty"` +} + +// NextHopSet defines next-hop attribute overrides. +// +// +kubebuilder:validation:XValidation:rule="!has(self.self) || !self.self || !has(self.address) || self.address.size() == 0",message="self and address are mutually exclusive" +type NextHopSet struct { + // Self sets the next-hop to the local router's BGP peer address. + // +optional + Self *bool `json:"self,omitempty"` + + // Address sets the next-hop to a specific IP address. + // Mutually exclusive with self. + // +optional + // +kubebuilder:validation:MaxLength=45 + Address *string `json:"address,omitempty"` +} + +// CommunitySet defines community add and remove operations. +type CommunitySet struct { + // Add is a list of communities to attach. + // +optional + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:MaxLength=24 + Add []string `json:"add,omitempty"` + + // Remove is a list of communities to strip. + // +optional + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:MaxLength=24 + Remove []string `json:"remove,omitempty"` +} + +// ExtendedCommunitySet defines extended community add and remove operations. +type ExtendedCommunitySet struct { + // Add is a list of extended communities to attach. + // +optional + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:MaxLength=64 + // +kubebuilder:validation:XValidation:rule="self.all(c, c.matches('^[0-9a-fA-F:.]+$'))",message="each extCommunity entry must contain only valid characters (digits, letters, colons, dots)" + Add []string `json:"add,omitempty"` + + // Remove is a list of extended communities to strip. + // +optional + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:MaxLength=64 + // +kubebuilder:validation:XValidation:rule="self.all(c, c.matches('^[0-9a-fA-F:.]+$'))",message="each extCommunity entry must contain only valid characters (digits, letters, colons, dots)" + Remove []string `json:"remove,omitempty"` +} + +// BGPPolicyStatus defines the observed state of BGPPolicy. +type BGPPolicyStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPPolicyList is a list of BGPPolicy resources. +// +kubebuilder:object:root=true +type BGPPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPPolicy{}, &BGPPolicyList{}) +} diff --git a/api/network/v1alpha1/policy_types_test.go b/api/network/v1alpha1/policy_types_test.go new file mode 100644 index 00000000..cd994b5c --- /dev/null +++ b/api/network/v1alpha1/policy_types_test.go @@ -0,0 +1,548 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestPolicy() *BGPPolicy { + lp := int32(100) + return &BGPPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPolicy", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-policy"}, + Spec: BGPPolicySpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "test-router"}, + }, + Direction: BGPPolicyDirectionImport, + Terms: []BGPPolicyTerm{ + { + Sequence: 10, + Match: BGPPolicyMatch{ + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv6, SAFI: SAFIUnicast}, + }, + }, + Action: BGPPolicyActionPermit, + Set: &BGPPolicySetActions{ + LocalPreference: &lp, + }, + }, + { + Sequence: 9999, + Match: BGPPolicyMatch{Any: true}, + Action: BGPPolicyActionDeny, + }, + }, + }, + } +} + +// TestBGPPolicyDirectionConstants verifies the direction enum values. +func TestBGPPolicyDirectionConstants(t *testing.T) { + if BGPPolicyDirectionImport != "import" { + t.Errorf("BGPPolicyDirectionImport = %q, want %q", BGPPolicyDirectionImport, "import") + } + if BGPPolicyDirectionExport != "export" { + t.Errorf("BGPPolicyDirectionExport = %q, want %q", BGPPolicyDirectionExport, "export") + } +} + +// TestBGPPolicyActionConstants verifies the action enum values. +func TestBGPPolicyActionConstants(t *testing.T) { + if BGPPolicyActionPermit != "permit" { + t.Errorf("BGPPolicyActionPermit = %q, want %q", BGPPolicyActionPermit, "permit") + } + if BGPPolicyActionDeny != "deny" { + t.Errorf("BGPPolicyActionDeny = %q, want %q", BGPPolicyActionDeny, "deny") + } +} + +// TestBGPOriginConstants verifies the BGP origin enum values. +func TestBGPOriginConstants(t *testing.T) { + if BGPOriginIGP != "igp" { + t.Errorf("BGPOriginIGP = %q, want %q", BGPOriginIGP, "igp") + } + if BGPOriginEGP != "egp" { + t.Errorf("BGPOriginEGP = %q, want %q", BGPOriginEGP, "egp") + } + if BGPOriginIncomplete != "incomplete" { + t.Errorf("BGPOriginIncomplete = %q, want %q", BGPOriginIncomplete, "incomplete") + } +} + +// TestASPathMatchTypeConstants verifies the match type enum values. +func TestASPathMatchTypeConstants(t *testing.T) { + if ASPathMatchFull != "full" { + t.Errorf("ASPathMatchFull = %q, want %q", ASPathMatchFull, "full") + } + if ASPathMatchContains != "contains" { + t.Errorf("ASPathMatchContains = %q, want %q", ASPathMatchContains, "contains") + } +} + +// TestEVPNRouteTypeConstants verifies the EVPN route type enum values. +func TestEVPNRouteTypeConstants(t *testing.T) { + tests := []struct { + val EVPNRouteType + want string + }{ + {EVPNRouteTypeInclusiveMulticastEthernetTag, "inclusiveMulticastEthernetTag"}, + {EVPNRouteTypeMACIPAdvertisement, "macIPAdvertisement"}, + {EVPNRouteTypeIPPrefixAdvertisement, "iPPrefixAdvertisement"}, + {EVPNRouteTypeStickyMACAddress, "stickyMACAddress"}, + {EVPNRouteTypeIPv6PrefixAdvertisement, "iPv6PrefixAdvertisement"}, + } + for _, tt := range tests { + if string(tt.val) != tt.want { + t.Errorf("EVPNRouteType %q = %q, want %q", tt.val, string(tt.val), tt.want) + } + } +} + +// TestBGPPolicyDeepCopy verifies that DeepCopy produces an independent copy. +func TestBGPPolicyDeepCopy(t *testing.T) { + orig := newTestPolicy() + dup := orig.DeepCopy() + + dup.Spec.Direction = BGPPolicyDirectionExport + dup.Spec.Terms[0].Sequence = 99 + + if orig.Spec.Direction != BGPPolicyDirectionImport { + t.Errorf("Direction mutated: got %q, want %q", orig.Spec.Direction, BGPPolicyDirectionImport) + } + if orig.Spec.Terms[0].Sequence != 10 { + t.Errorf("Term sequence mutated: got %d, want 10", orig.Spec.Terms[0].Sequence) + } +} + +// TestBGPPolicyDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestBGPPolicyDeepCopyNil(t *testing.T) { + var p *BGPPolicy + if p.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestBGPPolicyListDeepCopy verifies that BGPPolicyList.DeepCopy produces +// independent copies of each item. +func TestBGPPolicyListDeepCopy(t *testing.T) { + list := &BGPPolicyList{ + Items: []BGPPolicy{*newTestPolicy()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.Direction = BGPPolicyDirectionExport + + if list.Items[0].Spec.Direction != BGPPolicyDirectionImport { + t.Errorf("original list item direction mutated via copy") + } +} + +// TestBGPPolicyJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestBGPPolicyJSONRoundTrip(t *testing.T) { + orig := newTestPolicy() + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPolicy + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.Direction != orig.Spec.Direction { + t.Errorf("Direction: got %q, want %q", got.Spec.Direction, orig.Spec.Direction) + } + if len(got.Spec.Terms) != len(orig.Spec.Terms) { + t.Errorf("Terms count: got %d, want %d", len(got.Spec.Terms), len(orig.Spec.Terms)) + } + if got.Spec.Terms[0].Sequence != 10 { + t.Errorf("Term[0].Sequence: got %d, want 10", got.Spec.Terms[0].Sequence) + } + if got.Spec.Terms[0].Action != BGPPolicyActionPermit { + t.Errorf("Term[0].Action: got %q, want %q", got.Spec.Terms[0].Action, BGPPolicyActionPermit) + } +} + +// TestBGPPolicyMatchFieldNamesJSON verifies that match field JSON keys are correct. +func TestBGPPolicyMatchFieldNamesJSON(t *testing.T) { + vni := uint32(10100) + lp := int32(150) + med := int32(50) + mac := "aa:bb:cc:dd:ee:ff" + prefix := "10.0.0.0/24" + + match := BGPPolicyMatch{ + PrefixList: []string{"10.0.0.0/8"}, + ASPathFilter: &ASPathFilter{ + Pattern: "^65000", + MatchType: ASPathMatchContains, + }, + CommunityMatch: []string{"65000:100"}, + EVPNRouteType: []EVPNRouteType{EVPNRouteTypeMACIPAdvertisement}, + VNI: &vni, + MACAddress: &mac, + IPPrefix: &prefix, + LocalPreference: &lp, + MED: &med, + } + + data, err := json.Marshal(match) + 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) + } + + expectedKeys := []string{ + "prefixList", "asPathFilter", "communityMatch", "evpnRouteType", + "vni", "macAddress", "ipPrefix", "localPreference", "med", + } + for _, key := range expectedKeys { + if _, ok := m[key]; !ok { + t.Errorf("expected JSON key %q not found in match", key) + } + } +} + +// TestBGPPolicySetActionsFieldNamesJSON verifies that set action field JSON keys are correct. +func TestBGPPolicySetActionsFieldNamesJSON(t *testing.T) { + self := true + metric := int32(100) + color := int32(42) + origin := BGPOriginIGP + prepend := uint32(2) + asn := int64(65000) + + set := BGPPolicySetActions{ + Origin: &origin, + AsPath: &AsPathSet{ + Prepend: &prepend, + ASN: &asn, + }, + NextHop: &NextHopSet{ + Self: &self, + }, + ExtCommunities: &ExtendedCommunitySet{ + Add: []string{"65000:100"}, + Remove: []string{"65000:200"}, + }, + Metric: &metric, + Color: &color, + } + + data, err := json.Marshal(set) + 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) + } + + expectedKeys := []string{ + "origin", "asPath", "nextHop", "extCommunities", + "metric", "color", + } + for _, key := range expectedKeys { + if _, ok := m[key]; !ok { + t.Errorf("expected JSON key %q not found in set actions", key) + } + } +} + +// TestBGPPolicyMatchDeepCopy verifies deep copy isolation for all new match fields. +func TestBGPPolicyMatchDeepCopy(t *testing.T) { + vni := uint32(10100) + lp := int32(150) + med := int32(50) + mac := "aa:bb:cc:dd:ee:ff" + prefix := "10.0.0.0/24" + + orig := &BGPPolicyTerm{ + Sequence: 10, + Match: BGPPolicyMatch{ + PrefixList: []string{"10.0.0.0/8", "192.168.0.0/16"}, + ASPathFilter: &ASPathFilter{ + Pattern: "^65000", + MatchType: ASPathMatchContains, + }, + CommunityMatch: []string{"65000:100"}, + EVPNRouteType: []EVPNRouteType{EVPNRouteTypeMACIPAdvertisement}, + VNI: &vni, + MACAddress: &mac, + IPPrefix: &prefix, + LocalPreference: &lp, + MED: &med, + }, + Action: BGPPolicyActionPermit, + } + + dup := orig.DeepCopy() + + // Mutate the duplicate and ensure original is unaffected. + dup.Match.PrefixList[0] = "172.16.0.0/12" + dup.Match.CommunityMatch[0] = "65001:999" + dup.Match.EVPNRouteType[0] = EVPNRouteTypeStickyMACAddress + newVNI := uint32(9999) + dup.Match.VNI = &newVNI + + if orig.Match.PrefixList[0] != "10.0.0.0/8" { + t.Errorf("PrefixList[0] mutated: got %q", orig.Match.PrefixList[0]) + } + if orig.Match.CommunityMatch[0] != "65000:100" { + t.Errorf("CommunityMatch[0] mutated: got %q", orig.Match.CommunityMatch[0]) + } + if orig.Match.EVPNRouteType[0] != EVPNRouteTypeMACIPAdvertisement { + t.Errorf("EVPNRouteType[0] mutated: got %q", orig.Match.EVPNRouteType[0]) + } + if *orig.Match.VNI != 10100 { + t.Errorf("VNI mutated: got %d", *orig.Match.VNI) + } +} + +// TestBGPPolicySetActionsDeepCopy verifies deep copy isolation for all new set action fields. +func TestBGPPolicySetActionsDeepCopy(t *testing.T) { + self := true + metric := int32(100) + color := int32(42) + origin := BGPOriginIGP + prepend := uint32(2) + asn := int64(65000) + + orig := &BGPPolicySetActions{ + Origin: &origin, + AsPath: &AsPathSet{ + Prepend: &prepend, + ASN: &asn, + }, + NextHop: &NextHopSet{ + Self: &self, + }, + ExtCommunities: &ExtendedCommunitySet{ + Add: []string{"65000:100"}, + Remove: []string{"65000:200"}, + }, + Metric: &metric, + Color: &color, + } + + dup := orig.DeepCopy() + + // Mutate duplicate and verify original unchanged. + *dup.Metric = 999 + *dup.Color = 999 + dup.ExtCommunities.Add[0] = "99999:99999" + + if *orig.Metric != 100 { + t.Errorf("Metric mutated: got %d", *orig.Metric) + } + if *orig.Color != 42 { + t.Errorf("Color mutated: got %d", *orig.Color) + } + if orig.ExtCommunities.Add[0] != "65000:100" { + t.Errorf("ExtCommunities.Add[0] mutated: got %q", orig.ExtCommunities.Add[0]) + } +} + +// TestASPathSetDeepCopy verifies deep copy for AsPathSet, including the Replace list. +func TestASPathSetDeepCopy(t *testing.T) { + orig := &AsPathSet{ + Replace: []int64{65000, 65001, 65002}, + } + dup := orig.DeepCopy() + dup.Replace[0] = 99999 + + if orig.Replace[0] != 65000 { + t.Errorf("Replace[0] mutated via copy: got %d", orig.Replace[0]) + } +} + +// TestBGPPolicyMatchJSONRoundTrip verifies the full match struct round-trips cleanly. +func TestBGPPolicyMatchJSONRoundTrip(t *testing.T) { + vni := uint32(10100) + mac := "aa:bb:cc:dd:ee:ff" + prefix := "2001:db8::/32" + + orig := BGPPolicyMatch{ + AddressFamilies: []AddressFamily{{AFI: AFIL2VPN, SAFI: SAFIEVPN}}, + PrefixList: []string{"10.0.0.0/8"}, + EVPNRouteType: []EVPNRouteType{EVPNRouteTypeMACIPAdvertisement, EVPNRouteTypeIPPrefixAdvertisement}, + VNI: &vni, + MACAddress: &mac, + IPPrefix: &prefix, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPolicyMatch + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.EVPNRouteType) != 2 { + t.Errorf("EVPNRouteType count: got %d, want 2", len(got.EVPNRouteType)) + } + if got.EVPNRouteType[0] != EVPNRouteTypeMACIPAdvertisement { + t.Errorf("EVPNRouteType[0]: got %q, want %q", got.EVPNRouteType[0], EVPNRouteTypeMACIPAdvertisement) + } + if *got.VNI != vni { + t.Errorf("VNI: got %d, want %d", *got.VNI, vni) + } + if *got.MACAddress != mac { + t.Errorf("MACAddress: got %q, want %q", *got.MACAddress, mac) + } + if *got.IPPrefix != prefix { + t.Errorf("IPPrefix: got %q, want %q", *got.IPPrefix, prefix) + } +} + +// TestBGPPolicySetActionsJSONRoundTrip verifies the full set actions struct round-trips cleanly. +func TestBGPPolicySetActionsJSONRoundTrip(t *testing.T) { + self := false + addr := "2001:db8::1" + metric := int32(200) + color := int32(10) + origin := BGPOriginEGP + prepend := uint32(3) + asn := int64(65000) + + orig := BGPPolicySetActions{ + Origin: &origin, + AsPath: &AsPathSet{ + Prepend: &prepend, + ASN: &asn, + }, + NextHop: &NextHopSet{ + Self: &self, + Address: &addr, + }, + ExtCommunities: &ExtendedCommunitySet{ + Add: []string{"65000:100", "65000:200"}, + Remove: []string{"65000:999"}, + }, + Metric: &metric, + Color: &color, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPolicySetActions + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if *got.Origin != origin { + t.Errorf("Origin: got %q, want %q", *got.Origin, origin) + } + if *got.AsPath.Prepend != prepend { + t.Errorf("AsPath.Prepend: got %d, want %d", *got.AsPath.Prepend, prepend) + } + if *got.AsPath.ASN != asn { + t.Errorf("AsPath.ASN: got %d, want %d", *got.AsPath.ASN, asn) + } + if *got.Metric != metric { + t.Errorf("Metric: got %d, want %d", *got.Metric, metric) + } + if *got.Color != color { + t.Errorf("Color: got %d, want %d", *got.Color, color) + } + if len(got.ExtCommunities.Add) != 2 { + t.Errorf("ExtCommunities.Add count: got %d, want 2", len(got.ExtCommunities.Add)) + } + if len(got.ExtCommunities.Remove) != 1 { + t.Errorf("ExtCommunities.Remove count: got %d, want 1", len(got.ExtCommunities.Remove)) + } +} + +// TestAsPathSetReplaceJSONRoundTrip verifies that Replace (not Prepend) round-trips. +func TestAsPathSetReplaceJSONRoundTrip(t *testing.T) { + orig := AsPathSet{ + Replace: []int64{65000, 65001, 65002}, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got AsPathSet + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Replace) != 3 { + t.Errorf("Replace count: got %d, want 3", len(got.Replace)) + } + if got.Replace[2] != 65002 { + t.Errorf("Replace[2]: got %d, want 65002", got.Replace[2]) + } +} + +// TestASPathFilterJSONRoundTrip verifies ASPathFilter round-trips cleanly. +func TestASPathFilterJSONRoundTrip(t *testing.T) { + orig := ASPathFilter{ + Pattern: "^(65000|65001)$", + MatchType: ASPathMatchFull, + } + + 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) + } + + if _, ok := m["pattern"]; !ok { + t.Error("expected JSON key \"pattern\" not found") + } + if _, ok := m["matchType"]; !ok { + t.Error("expected JSON key \"matchType\" not found") + } + + var got ASPathFilter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal into struct: %v", err) + } + if got.Pattern != orig.Pattern { + t.Errorf("Pattern: got %q, want %q", got.Pattern, orig.Pattern) + } + if got.MatchType != ASPathMatchFull { + t.Errorf("MatchType: got %q, want %q", got.MatchType, ASPathMatchFull) + } +} + +// TestExtendedCommunitySetDeepCopy verifies deep copy isolation. +func TestExtendedCommunitySetDeepCopy(t *testing.T) { + orig := &ExtendedCommunitySet{ + Add: []string{"65000:100", "65000:200"}, + Remove: []string{"65000:999"}, + } + dup := orig.DeepCopy() + dup.Add[0] = "mutated" + + if orig.Add[0] != "65000:100" { + t.Errorf("Add[0] mutated via copy: got %q", orig.Add[0]) + } +} diff --git a/api/network/v1alpha1/prefixlist_types.go b/api/network/v1alpha1/prefixlist_types.go new file mode 100644 index 00000000..27701f83 --- /dev/null +++ b/api/network/v1alpha1/prefixlist_types.go @@ -0,0 +1,97 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// BGPPrefixListEntry defines a single entry in a named BGP prefix-list. +// Entries are evaluated in ascending sequence order. +type BGPPrefixListEntry struct { + // Sequence is the evaluation order for this entry. Entries with lower + // sequence numbers are evaluated first. Must be unique within the list. + // +kubebuilder:validation:Minimum=5 + // +kubebuilder:validation:Maximum=4294967295 + Sequence int32 `json:"sequence"` + + // Action is the disposition when a route's prefix matches this entry. + // +kubebuilder:validation:Required + Action BGPPolicyAction `json:"action"` + + // Prefix is the IPv4 or IPv6 CIDR to match against. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=43 + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="prefix must be a valid IPv4 or IPv6 CIDR" + Prefix string `json:"prefix"` + + // GE is the minimum prefix-length to match (greater-than-or-equal). + // For example, "10.0.0.0/8 ge 16" matches 10.0.0.0/16 through 10.0.0.0/32. + // When unset, the prefix-length of Prefix is the lower bound. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=32 + GE *int32 `json:"ge,omitempty"` + + // LE is the maximum prefix-length to match (less-than-or-equal). + // For example, "10.0.0.0/8 le 24" matches 10.0.0.0/8 through 10.0.0.0/24. + // When unset, the prefix-length of Prefix is the upper bound. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=32 + LE *int32 `json:"le,omitempty"` +} + +// BGPPrefixList defines a named list of prefix-match entries used by BGPPolicy +// terms to filter routes by destination prefix. It is referenced by name from +// BGPPolicyMatch.prefixListRef. +// +// A single BGPPrefixList holds entries for either IPv4 or IPv6 — the address +// family is inferred from the CIDR family of the first entry and validated +// consistent across all entries via CEL. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgppfx +// +kubebuilder:printcolumn:name="ENTRIES",type="integer",JSONPath=".spec.entries" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPPrefixList struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPPrefixListSpec `json:"spec,omitempty"` + Status BGPPrefixListStatus `json:"status,omitempty"` +} + +// BGPPrefixListSpec defines the desired state of BGPPrefixList. +// +// +kubebuilder:validation:XValidation:rule="self.entries.all(e, e.prefix.contains(':') == self.entries[0].prefix.contains(':'))",message="all entries must be the same address family (IPv4 or IPv6)" +type BGPPrefixListSpec struct { + // Entries is the ordered list of prefix-match entries. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=1024 + Entries []BGPPrefixListEntry `json:"entries"` +} + +// BGPPrefixListStatus defines the observed state of BGPPrefixList. +type BGPPrefixListStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPPrefixListList is a list of BGPPrefixList resources. +// +kubebuilder:object:root=true +type BGPPrefixListList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPPrefixList `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPPrefixList{}, &BGPPrefixListList{}) +} diff --git a/api/network/v1alpha1/prefixlist_types_test.go b/api/network/v1alpha1/prefixlist_types_test.go new file mode 100644 index 00000000..c6a96192 --- /dev/null +++ b/api/network/v1alpha1/prefixlist_types_test.go @@ -0,0 +1,246 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestPrefixList() *BGPPrefixList { + return &BGPPrefixList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPrefixList", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-pfx"}, + Spec: BGPPrefixListSpec{ + Entries: []BGPPrefixListEntry{ + {Sequence: 5, Action: BGPPolicyActionPermit, Prefix: "10.0.0.0/8"}, + }, + }, + } +} + +// TestBGPPrefixListDeepCopy verifies that DeepCopy produces an independent copy. +func TestBGPPrefixListDeepCopy(t *testing.T) { + orig := newTestPrefixList() + dup := orig.DeepCopy() + + dup.Spec.Entries[0].Prefix = "192.168.0.0/16" + dup.Spec.Entries[0].Action = BGPPolicyActionDeny + + if orig.Spec.Entries[0].Prefix != "10.0.0.0/8" { + t.Errorf("Prefix mutated: got %q", orig.Spec.Entries[0].Prefix) + } + if orig.Spec.Entries[0].Action != BGPPolicyActionPermit { + t.Errorf("Action mutated: got %q", orig.Spec.Entries[0].Action) + } +} + +// TestBGPPrefixListDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestBGPPrefixListDeepCopyNil(t *testing.T) { + var p *BGPPrefixList + if p.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestBGPPrefixListJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestBGPPrefixListJSONRoundTrip(t *testing.T) { + orig := newTestPrefixList() + ge := int32(16) + le := int32(24) + orig.Spec.Entries = []BGPPrefixListEntry{ + {Sequence: 5, Action: BGPPolicyActionPermit, Prefix: "10.0.0.0/8", GE: &ge, LE: &le}, + {Sequence: 10, Action: BGPPolicyActionDeny, Prefix: "0.0.0.0/0"}, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPrefixList + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Spec.Entries) != 2 { + t.Fatalf("Entries count: got %d, want 2", len(got.Spec.Entries)) + } + + e0 := got.Spec.Entries[0] + if e0.Sequence != 5 { + t.Errorf("Entry[0].Sequence: got %d, want 5", e0.Sequence) + } + if e0.Action != BGPPolicyActionPermit { + t.Errorf("Entry[0].Action: got %q, want %q", e0.Action, BGPPolicyActionPermit) + } + if e0.Prefix != "10.0.0.0/8" { + t.Errorf("Entry[0].Prefix: got %q, want %q", e0.Prefix, "10.0.0.0/8") + } + if e0.GE == nil || *e0.GE != ge { + t.Errorf("Entry[0].GE: got %v, want %d", e0.GE, ge) + } + if e0.LE == nil || *e0.LE != le { + t.Errorf("Entry[0].LE: got %v, want %d", e0.LE, le) + } + + e1 := got.Spec.Entries[1] + if e1.Action != BGPPolicyActionDeny { + t.Errorf("Entry[1].Action: got %q, want %q", e1.Action, BGPPolicyActionDeny) + } + if e1.Prefix != "0.0.0.0/0" { + t.Errorf("Entry[1].Prefix: got %q, want %q", e1.Prefix, "0.0.0.0/0") + } +} + +// TestBGPPrefixListListDeepCopy verifies that BGPPrefixListList.DeepCopy produces +// independent copies of each item. +func TestBGPPrefixListListDeepCopy(t *testing.T) { + list := &BGPPrefixListList{ + Items: []BGPPrefixList{*newTestPrefixList()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.Entries[0].Prefix = "172.16.0.0/12" + + if list.Items[0].Spec.Entries[0].Prefix != "10.0.0.0/8" { + t.Error("original list item mutated via copy") + } +} + +// TestBGPPrefixListEntryFieldNames verifies the JSON key names for entry fields. +func TestBGPPrefixListEntryFieldNames(t *testing.T) { + ge := int32(16) + le := int32(24) + entry := BGPPrefixListEntry{ + Sequence: 5, + Action: BGPPolicyActionPermit, + Prefix: "10.0.0.0/8", + GE: &ge, + LE: &le, + } + + data, err := json.Marshal(entry) + 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) + } + + for _, key := range []string{"sequence", "action", "prefix", "ge", "le"} { + if _, ok := m[key]; !ok { + t.Fatalf("expected JSON key %q not found", key) + } + } +} + +// TestBGPPrefixListEntryOptionalFieldsOmitted verifies that GE and LE are +// omitted from JSON when nil. +func TestBGPPrefixListEntryOptionalFieldsOmitted(t *testing.T) { + entry := BGPPrefixListEntry{ + Sequence: 5, + Action: BGPPolicyActionDeny, + Prefix: "0.0.0.0/0", + } + + data, err := json.Marshal(entry) + 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) + } + + for _, key := range []string{"ge", "le"} { + if _, ok := m[key]; ok { + t.Errorf("expected %q to be omitted when nil", key) + } + } +} + +// TestBGPPrefixListIPv6Entries verifies IPv6 prefix-list entries survive +// JSON round-trip. +func TestBGPPrefixListIPv6Entries(t *testing.T) { + pl := &BGPPrefixList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPPrefixList", + }, + ObjectMeta: metav1.ObjectMeta{Name: "ipv6-pfx"}, + Spec: BGPPrefixListSpec{ + Entries: []BGPPrefixListEntry{ + {Sequence: 5, Action: BGPPolicyActionPermit, Prefix: "2001:db8::/32", GE: ptrInt32(48), LE: ptrInt32(64)}, + {Sequence: 10, Action: BGPPolicyActionDeny, Prefix: "::/0"}, + }, + }, + } + + data, err := json.Marshal(pl) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPPrefixList + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(got.Spec.Entries) != 2 { + t.Fatalf("Entries count: got %d, want 2", len(got.Spec.Entries)) + } + if got.Spec.Entries[0].Prefix != "2001:db8::/32" { + t.Errorf("Entry[0].Prefix: got %q, want %q", got.Spec.Entries[0].Prefix, "2001:db8::/32") + } + if got.Spec.Entries[1].Prefix != "::/0" { + t.Errorf("Entry[1].Prefix: got %q, want %q", got.Spec.Entries[1].Prefix, "::/0") + } +} + +// TestBGPPrefixListEntryDeepCopy verifies DeepCopy handles pointer fields. +func TestBGPPrefixListEntryDeepCopy(t *testing.T) { + ge := int32(16) + le := int32(24) + orig := BGPPrefixListEntry{ + Sequence: 5, + Action: BGPPolicyActionPermit, + Prefix: "10.0.0.0/8", + GE: &ge, + LE: &le, + } + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var copied BGPPrefixListEntry + if err := json.Unmarshal(data, &copied); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + // Mutate the copy's pointers. + newGE := int32(20) + newLE := int32(28) + *copied.GE = newGE + *copied.LE = newLE + + // Original should be unchanged (json round-trip creates independent pointers). + if *orig.GE != ge { + t.Errorf("GE mutated: got %d, want %d", *orig.GE, ge) + } + if *orig.LE != le { + t.Errorf("LE mutated: got %d, want %d", *orig.LE, le) + } +} + +func ptrInt32(v int32) *int32 { + return &v +} diff --git a/api/network/v1alpha1/router_types.go b/api/network/v1alpha1/router_types.go new file mode 100644 index 00000000..8c22761f --- /dev/null +++ b/api/network/v1alpha1/router_types.go @@ -0,0 +1,125 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BGPRouterPhase describes the lifecycle phase of a BGPRouter. +type BGPRouterPhase string + +const ( + BGPRouterPhasePending BGPRouterPhase = "Pending" + BGPRouterPhaseReady BGPRouterPhase = "Ready" + BGPRouterPhaseFailed BGPRouterPhase = "Failed" +) + +// BGPRouter defines a logical BGP routing context. It abstracts a processing +// instance bound to a specific execution context (e.g., a VRF or network +// namespace) on a target node and acts as the primary ownership boundary for +// BGPPeer, BGPAdvertisement, and BGPPolicy resources. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgpr +// +kubebuilder:printcolumn:name="TARGET",type="string",JSONPath=".spec.targetRef.name" +// +kubebuilder:printcolumn:name="ASN",type="integer",JSONPath=".spec.localASN" +// +kubebuilder:printcolumn:name="ROUTER-ID",type="string",JSONPath=".spec.routerID" +// +kubebuilder:printcolumn:name="PHASE",type="string",JSONPath=".status.phase" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type BGPRouter struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPRouterSpec `json:"spec,omitempty"` + Status BGPRouterStatus `json:"status,omitempty"` +} + +// BGPRouterSpec defines the desired state of a BGPRouter. +type BGPRouterSpec struct { + // TargetRef identifies the Node this router executes on. + // +kubebuilder:validation:Required + TargetRef TargetRef `json:"targetRef"` + + // LocalASN is the BGP Autonomous System Number for this router. + // Must be a valid 2-byte or 4-byte ASN per RFC 6793. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + LocalASN int64 `json:"localASN"` + + // RouterID is a unique 32-bit identifier expressed in IPv4 dotted-decimal notation. + // In an IPv6-only underlay this is a logical identifier only. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Format=ipv4 + RouterID string `json:"routerID"` + + // AddressFamilies defines the address families this router activates. + // +kubebuilder:validation:MinItems=1 + AddressFamilies []AddressFamily `json:"addressFamilies"` + + // SRv6Locator is the SRv6 locator block this router owns, expressed as an + // IPv6 CIDR (e.g. "2001:db8:ff01::/48"). Individual SRv6 endpoint SIDs are + // host addresses within this block. + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="srv6Locator must be a valid CIDR" + // +kubebuilder:validation:XValidation:rule="self.contains(':')",message="srv6Locator must be an IPv6 CIDR" + // +optional + SRv6Locator string `json:"srv6Locator,omitempty"` + + // NodeID is this router's 16-bit slot within its PoP's SRv6Locator block, + // used for RFC 9800 NEXT-CSID compression. Unique within the PoP. + // The uSID carrier format reserves 0x0000 and 0xE000-0xFFFF (the latter is + // Function space, not Node-ID space), leaving 0x0001-0xDFFF assignable. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=57343 + // +optional + NodeID int32 `json:"nodeID,omitempty"` + + // ListenPort is the TCP port this router listens on for incoming BGP + // peer connections. Defaults to 179 (the IANA-assigned BGP port) if unset. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + ListenPort *int32 `json:"listenPort,omitempty"` +} + +// BGPRouterStatus defines the observed state of a BGPRouter. +type BGPRouterStatus struct { + // Phase is the high-level lifecycle state of this router. + // +optional + Phase BGPRouterPhase `json:"phase,omitempty"` + + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Peers summarizes peer session counts. + // +optional + Peers BGPRouterPeerSummary `json:"peers,omitempty"` + + // Conditions contains the standard conditions for this resource. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// BGPRouterPeerSummary summarizes BGP peer session counts. +type BGPRouterPeerSummary struct { + // Total is the total number of configured peers. + Total int32 `json:"total"` + + // Established is the count of peers currently in the Established state. + Established int32 `json:"established"` +} + +// BGPRouterList is a list of BGPRouter resources. +// +kubebuilder:object:root=true +type BGPRouterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPRouter `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPRouter{}, &BGPRouterList{}) +} diff --git a/api/network/v1alpha1/router_types_test.go b/api/network/v1alpha1/router_types_test.go new file mode 100644 index 00000000..be810981 --- /dev/null +++ b/api/network/v1alpha1/router_types_test.go @@ -0,0 +1,332 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestRouter() *BGPRouter { + return &BGPRouter{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPRouter", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-router"}, + Spec: BGPRouterSpec{ + TargetRef: TargetRef{Kind: "Node", Name: "node-1"}, + LocalASN: 65000, + RouterID: "10.0.0.1", + }, + } +} + +// TestBGPRouterLargeLocalASN verifies that 4-byte ASNs (values above signed int32 max) +// survive JSON round-trip correctly. +func TestBGPRouterLargeLocalASN(t *testing.T) { + // Max 4-byte ASN — the boundary of the uint32 range. + const maxASN = int64(4294967295) + + router := &BGPRouter{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPRouter", + }, + ObjectMeta: metav1.ObjectMeta{Name: "large-asn-router"}, + Spec: BGPRouterSpec{ + TargetRef: TargetRef{Kind: "Node", Name: "node-1"}, + LocalASN: maxASN, + RouterID: "10.0.0.1", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(router) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.LocalASN != maxASN { + t.Errorf("LocalASN after round-trip: got %d, want %d", got.Spec.LocalASN, maxASN) + } +} + +// TestBGPRouterLocalASNAboveSignedInt32Max verifies that values > 2^31-1 +// (the signed int32 ceiling) are handled correctly. +func TestBGPRouterLocalASNAboveSignedInt32Max(t *testing.T) { + // 2^31 = 2147483648 — the first value beyond signed int32 range. + const aboveSignedMax int64 = 2147483648 + + router := &BGPRouter{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPRouter", + }, + ObjectMeta: metav1.ObjectMeta{Name: "above-int32-router"}, + Spec: BGPRouterSpec{ + TargetRef: TargetRef{Kind: "Node", Name: "node-1"}, + LocalASN: aboveSignedMax, + RouterID: "10.0.0.1", + AddressFamilies: []AddressFamily{ + {AFI: AFIIPv4, SAFI: SAFIUnicast}, + }, + }, + } + + data, err := json.Marshal(router) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.LocalASN != aboveSignedMax { + t.Errorf("LocalASN after round-trip: got %d, want %d", got.Spec.LocalASN, aboveSignedMax) + } +} + +// TestBGPRouterDeepCopyLocalASN verifies that mutating LocalASN on the copy +// does not affect the original. +func TestBGPRouterDeepCopyLocalASN(t *testing.T) { + orig := newTestRouter() + orig.Spec.LocalASN = 654321 + dup := orig.DeepCopy() + + dup.Spec.LocalASN = 999999 + if orig.Spec.LocalASN != 654321 { + t.Errorf("LocalASN mutated: got %d, want 654321", orig.Spec.LocalASN) + } +} + +// TestBGPRouterJSONRoundTripSRv6Locator verifies that SRv6Locator survives +// JSON marshal/unmarshal. +func TestBGPRouterJSONRoundTripSRv6Locator(t *testing.T) { + orig := newTestRouter() + orig.Spec.SRv6Locator = "2001:db8:ff01::/48" + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.SRv6Locator != orig.Spec.SRv6Locator { + t.Errorf("SRv6Locator: got %q, want %q", got.Spec.SRv6Locator, orig.Spec.SRv6Locator) + } +} + +// TestBGPRouterSRv6LocatorFieldName verifies the JSON key is "srv6Locator". +func TestBGPRouterSRv6LocatorFieldName(t *testing.T) { + orig := newTestRouter() + orig.Spec.SRv6Locator = "2001:db8:ff01::/48" + + data, err := json.Marshal(orig.Spec) + 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) + } + + if raw, ok := m["srv6Locator"]; !ok || raw != "2001:db8:ff01::/48" { + t.Errorf("unexpected srv6Locator value: %v", raw) + } +} + +// TestBGPRouterSRv6LocatorOmitEmpty verifies that a router without a +// SRv6Locator omits the "srv6Locator" key from JSON output. +func TestBGPRouterSRv6LocatorOmitEmpty(t *testing.T) { + orig := newTestRouter() + + data, err := json.Marshal(orig.Spec) + 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) + } + + if _, ok := m["srv6Locator"]; ok { + t.Error("expected \"srv6Locator\" key to be absent when empty") + } +} + +// TestBGPRouterJSONRoundTripNodeID verifies that NodeID survives JSON +// marshal/unmarshal. +func TestBGPRouterJSONRoundTripNodeID(t *testing.T) { + orig := newTestRouter() + orig.Spec.NodeID = 42 + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.NodeID != orig.Spec.NodeID { + t.Errorf("NodeID: got %d, want %d", got.Spec.NodeID, orig.Spec.NodeID) + } +} + +// TestBGPRouterNodeIDFieldName verifies the JSON key is "nodeID". +func TestBGPRouterNodeIDFieldName(t *testing.T) { + orig := newTestRouter() + orig.Spec.NodeID = 42 + + data, err := json.Marshal(orig.Spec) + 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) + } + + if raw, ok := m["nodeID"]; !ok || raw != float64(42) { + t.Errorf("unexpected nodeID value: %v", raw) + } +} + +// TestBGPRouterNodeIDOmitEmpty verifies that a router without a NodeID omits +// the "nodeID" key from JSON output. +func TestBGPRouterNodeIDOmitEmpty(t *testing.T) { + orig := newTestRouter() + + data, err := json.Marshal(orig.Spec) + 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) + } + + if _, ok := m["nodeID"]; ok { + t.Error("expected \"nodeID\" key to be absent when empty") + } +} + +// TestBGPRouterJSONRoundTripListenPort verifies that ListenPort survives +// JSON marshal/unmarshal. +func TestBGPRouterJSONRoundTripListenPort(t *testing.T) { + orig := newTestRouter() + port := int32(179) + orig.Spec.ListenPort = &port + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouter + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.ListenPort == nil { + t.Fatal("expected ListenPort to be set") + } else if *got.Spec.ListenPort != port { + t.Errorf("ListenPort: got %d, want %d", *got.Spec.ListenPort, port) + } +} + +// TestBGPRouterListenPortFieldName verifies the JSON key is "listenPort". +func TestBGPRouterListenPortFieldName(t *testing.T) { + orig := newTestRouter() + port := int32(179) + orig.Spec.ListenPort = &port + + data, err := json.Marshal(orig.Spec) + 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) + } + + if raw, ok := m["listenPort"]; !ok || raw != float64(179) { + t.Errorf("unexpected listenPort value: %v", raw) + } +} + +// TestBGPRouterListenPortOmitEmpty verifies that a router without a +// ListenPort omits the "listenPort" key from JSON output. +func TestBGPRouterListenPortOmitEmpty(t *testing.T) { + orig := newTestRouter() + + data, err := json.Marshal(orig.Spec) + 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) + } + + if _, ok := m["listenPort"]; ok { + t.Error("expected \"listenPort\" key to be absent when empty") + } +} + +// TestBGPRouterListenPortBoundaryValues verifies that port values 1 and 65535 +// marshal and unmarshal correctly. +func TestBGPRouterListenPortBoundaryValues(t *testing.T) { + cases := []struct { + name string + port int32 + }{ + {"min port 1", 1}, + {"max port 65535", 65535}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + orig := newTestRouter() + orig.Spec.ListenPort = &tc.port + + data, err := json.Marshal(orig.Spec) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPRouterSpec + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.ListenPort == nil { + t.Fatal("expected ListenPort to be set") + } else if *got.ListenPort != tc.port { + t.Errorf("ListenPort: got %d, want %d", *got.ListenPort, tc.port) + } + }) + } +} diff --git a/api/network/v1alpha1/serviceroute_admission_envtest_test.go b/api/network/v1alpha1/serviceroute_admission_envtest_test.go new file mode 100644 index 00000000..c149ef2a --- /dev/null +++ b/api/network/v1alpha1/serviceroute_admission_envtest_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package v1alpha1_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/yaml" + + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" +) + +func TestTranslatedAPIAdmission(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("set KUBEBUILDER_ASSETS for isolated API admission tests") + } + apiRoot := os.Getenv("GALACTIC_TEST_NETWORK_API") + if apiRoot == "" { + apiRoot = "../../.." + } + crds := make([]*apiextensionsv1.CustomResourceDefinition, 0, 2) + for _, name := range []string{ + "galactic.datumapis.com_serviceendpoints.yaml", + "galactic.datumapis.com_serviceroutepolicies.yaml", + } { + data, err := os.ReadFile(filepath.Join(apiRoot, "config", "crd", name)) + if err != nil { + t.Fatal(err) + } + crd := &apiextensionsv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(data, crd); err != nil { + t.Fatal(err) + } + crds = append(crds, crd) + } + environment := &envtest.Environment{CRDs: crds} + cfg, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Errorf("stop API: %v", err) + } + }) + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = networkv1alpha1.AddToScheme(scheme) + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := c.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "psc-admission"}}); err != nil { + t.Fatal(err) + } + policy := func(name string) *networkv1alpha1.ServiceRoutePolicy { + return &networkv1alpha1.ServiceRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "psc-admission"}, + Spec: networkv1alpha1.ServiceRoutePolicySpec{ + ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: "dns"}, + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + Frontend: &networkv1alpha1.ServiceRouteFrontend{Address: "fd53::53"}, + ConsumerVPCRef: &networkv1alpha1.ServiceRouteVPCReference{Name: "vpc", UID: "vpc-uid"}, + Authorization: &networkv1alpha1.ServiceRouteAuthorization{ + ValidUntil: metav1.NewTime(time.Now().Add(time.Minute)), + }, + }, + } + } + for _, tt := range []struct { + name string + mutate func(*networkv1alpha1.ServiceRoutePolicy) + wantError bool + }{ + {"missing-pin", func(p *networkv1alpha1.ServiceRoutePolicy) { p.Spec.ConsumerVPCRef = nil }, true}, + {"empty-uid", func(p *networkv1alpha1.ServiceRoutePolicy) { p.Spec.ConsumerVPCRef.UID = "" }, true}, + {"missing-authorization", func(p *networkv1alpha1.ServiceRoutePolicy) { p.Spec.Authorization = nil }, true}, + {"legacy-direct", func(p *networkv1alpha1.ServiceRoutePolicy) { + p.Spec.Frontend = nil + p.Spec.ConsumerVPCRef = nil + p.Spec.Authorization = nil + }, false}, + {"translated", func(*networkv1alpha1.ServiceRoutePolicy) {}, false}, + } { + t.Run(tt.name, func(t *testing.T) { + p := policy(tt.name) + tt.mutate(p) + err := c.Create(ctx, p) + if (err != nil) != tt.wantError { + t.Fatalf("create error=%v", err) + } + if tt.wantError && !apierrors.IsInvalid(err) { + t.Fatalf("expected schema rejection, got %v", err) + } + }) + } + // A real API preserves competing node reports and prevents stale resource- + // version writes from replacing a newer authorization generation. + p := policy("fenced") + if err := c.Create(ctx, p); err != nil { + t.Fatal(err) + } + old := p.DeepCopy() + p.Spec.Authorization.ValidUntil = metav1.NewTime(time.Now().Add(90 * time.Second)) + if err := c.Update(ctx, p); err != nil { + t.Fatal(err) + } + old.Status.Nodes = []networkv1alpha1.ServiceRouteNodeStatus{{ + NodeName: "node-a", PolicyUID: string(old.UID), ObservedGeneration: old.Generation, + InputDigest: "old", Ready: true, ValidUntil: metav1.NewTime(time.Now().Add(time.Minute)), + }} + if err := c.Status().Update(ctx, old); !apierrors.IsConflict(err) { + t.Fatalf("stale status write error=%v", err) + } +} diff --git a/api/network/v1alpha1/serviceroute_types.go b/api/network/v1alpha1/serviceroute_types.go new file mode 100644 index 00000000..2ae877c7 --- /dev/null +++ b/api/network/v1alpha1/serviceroute_types.go @@ -0,0 +1,268 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// ServiceEndpoint publishes a platform service that can be reached through a +// VPC attachment. The object describes the service contract; the controller +// that owns the service is responsible for its health and implementation. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,shortName=svcendpoint +// +kubebuilder:printcolumn:name="ADDRESS",type="string",JSONPath=".spec.address" +// +kubebuilder:printcolumn:name="PORT",type="integer",JSONPath=".spec.port" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type ServiceEndpoint struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceEndpointSpec `json:"spec,omitempty"` +} + +// ServiceEndpointSpec defines a platform service address and its scope. +// +kubebuilder:validation:XValidation:rule="has(self.attachmentRef) != has(self.attachmentSelector)",message="exactly one of attachmentRef or attachmentSelector must be set" +type ServiceEndpointSpec struct { + // ServiceClass identifies the platform capability implemented by this + // endpoint, for example "ipv6-egress-dns". + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ServiceClass string `json:"serviceClass"` + + // Address is the stable address that consumers use to reach the service. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="address must be an IP address" + Address string `json:"address"` + + // Port is the service port. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port int32 `json:"port"` + + // Protocol is the transport protocol accepted by the endpoint. + // +kubebuilder:validation:Required + Protocol NetworkServiceProtocol `json:"protocol"` + + // DeliveryMode defines how a backing attachment is selected relative to a + // consumer. PreferNodeLocal uses a ready attachment on the consumer's node + // when one exists and otherwise falls back to a ready remote attachment. + // The dataplane preserves the consumer source address. If consumers with + // overlapping address space present an identical TCP or UDP five-tuple to the + // same replica, it rejects the later ambiguous flow fail-closed rather than + // translate the source or risk returning traffic to the wrong VPC. + // +kubebuilder:validation:Required + DeliveryMode ServiceEndpointDeliveryMode `json:"deliveryMode"` + + // AttachmentRef identifies the Cloud API VPCAttachment that hosts this + // endpoint when the service is delivered through a private VPC path. + // The network API keeps this reference opaque; the consuming controller + // resolves it against the Cloud API. + // +optional + AttachmentRef *ServiceEndpointAttachmentReference `json:"attachmentRef,omitempty"` + + // AttachmentSelector selects the Cloud API VPCAttachments that host replicas + // of this endpoint. Labels used here must be assigned by the platform service + // controller, not by tenants. The dataplane selects one ready replica for + // each consumer according to DeliveryMode. + // +optional + AttachmentSelector *metav1.LabelSelector `json:"attachmentSelector,omitempty"` + + // Region limits endpoint selection to a region. An empty value means the + // endpoint is not region-scoped. + // +optional + Region string `json:"region,omitempty"` +} + +// ServiceEndpointDeliveryMode defines the placement contract for a service. +// +kubebuilder:validation:Enum=NodeLocal;PreferNodeLocal +type ServiceEndpointDeliveryMode string + +const ( + // ServiceEndpointDeliveryModeNodeLocal requires the endpoint's backing + // attachment to be present on each node where selected consumers run. + ServiceEndpointDeliveryModeNodeLocal ServiceEndpointDeliveryMode = "NodeLocal" + + // ServiceEndpointDeliveryModePreferNodeLocal selects a backing attachment on + // the consumer's node when possible and otherwise tunnels to a ready remote + // attachment while preserving the consumer source address. + ServiceEndpointDeliveryModePreferNodeLocal ServiceEndpointDeliveryMode = "PreferNodeLocal" +) + +// ServiceEndpointAttachmentReference identifies the private attachment that +// backs a platform service endpoint. +type ServiceEndpointAttachmentReference struct { + // Namespace is the namespace containing the Cloud API VPCAttachment. + // +kubebuilder:validation:MinLength=1 + Namespace string `json:"namespace"` + + // Name is the Cloud API VPCAttachment name. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// ServiceEndpointList is a list of ServiceEndpoint resources. +// +kubebuilder:object:root=true +type ServiceEndpointList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceEndpoint `json:"items"` +} + +// ServiceRoutePolicy selects VPC attachments that should receive a route to a +// platform ServiceEndpoint. The selector is evaluated by the network control +// plane; Galactic compiles the selected attachments into dataplane state. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=svcroute +// +kubebuilder:printcolumn:name="SERVICE",type="string",JSONPath=".spec.serviceRef.name" +// +kubebuilder:printcolumn:name="REGION",type="string",JSONPath=".spec.region" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type ServiceRoutePolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceRoutePolicySpec `json:"spec,omitempty"` + Status ServiceRoutePolicyStatus `json:"status,omitempty"` +} + +// ServiceRoutePolicySpec defines service selection and attachment eligibility. +// +kubebuilder:validation:XValidation:rule="!has(self.frontend) || has(self.consumerVPCRef)",message="frontend requires consumerVPCRef" +// +kubebuilder:validation:XValidation:rule="!has(self.frontend) || has(self.authorization)",message="frontend requires authorization" +type ServiceRoutePolicySpec struct { + // ServiceRef identifies the ServiceEndpoint in this namespace. + // +kubebuilder:validation:Required + ServiceRef ServiceEndpointReference `json:"serviceRef"` + + // ConsumerVPCRef pins authorization to the immutable lifetime of the VPC + // selected by AttachmentSelector. Galactic resolves the name through the + // Cloud API and requires both the live name and UID to match. + // +optional + ConsumerVPCRef *ServiceRouteVPCReference `json:"consumerVPCRef,omitempty"` + + // Frontend is the address presented inside the consumer VPC. When set, + // Galactic translates it to the ServiceEndpoint address and restores it on + // replies. + // +optional + Frontend *ServiceRouteFrontend `json:"frontend,omitempty"` + + // Authorization bounds translated access independently of application leases. + // Only trusted networking integration may renew this deadline. + // +optional + Authorization *ServiceRouteAuthorization `json:"authorization,omitempty"` + + // AttachmentSelector selects eligible VPC attachments by authoritative + // labels. Labels that grant access to platform services must be assigned by + // the network control plane, not by tenants. + // +kubebuilder:validation:Required + AttachmentSelector metav1.LabelSelector `json:"attachmentSelector"` + + // ProtocolPorts limits the traffic that the route is intended to carry. + // An empty list means the endpoint's declared port and protocol are used. + // +optional + // +listType=atomic + ProtocolPorts []ServiceRouteProtocolPort `json:"protocolPorts,omitempty"` + + // Region limits selected attachments to a region. An empty value means the + // policy applies in every region where the endpoint is available. + // +optional + Region string `json:"region,omitempty"` +} + +// ServiceRouteAuthorization bounds one network authorization episode. +type ServiceRouteAuthorization struct { + // ValidUntil is the absolute UTC deadline for requests and existing replies. + // Node controllers reject deadlines more than two minutes in the future. + ValidUntil metav1.Time `json:"validUntil"` +} + +// ServiceRouteVPCReference identifies one immutable Cloud API VPC lifetime. +type ServiceRouteVPCReference struct { + // Name is the VPC name in the policy namespace. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // UID is the Kubernetes UID of that VPC lifetime. + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid"` +} + +// ServiceRouteFrontend declares the consumer-facing service address. +type ServiceRouteFrontend struct { + // Address is translated to the ServiceEndpoint address for requests and is + // restored as the source address for replies. + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="address must be an IP address" + Address string `json:"address"` +} + +// ServiceRouteProtocolPort identifies an allowed transport protocol and port. +type ServiceRouteProtocolPort struct { + // Protocol is the transport protocol. + // +kubebuilder:validation:Required + Protocol NetworkServiceProtocol `json:"protocol"` + + // Port is the transport port. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port int32 `json:"port"` +} + +// ServiceEndpointReference identifies a ServiceEndpoint in the policy's +// namespace. +type ServiceEndpointReference struct { + // Name is the ServiceEndpoint name. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// ServiceRoutePolicyStatus contains policy conditions and fenced node reports. +type ServiceRoutePolicyStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains durable acceptance and service-availability state. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Nodes contains short-lived acknowledgments written by trusted node controllers. + // Consumers require current UID, generation, input digest, and unexpired reports. + // +listType=map + // +listMapKey=nodeName + // +optional + Nodes []ServiceRouteNodeStatus `json:"nodes,omitempty"` +} + +// ServiceRouteNodeStatus acknowledges one node's current programmed path. +type ServiceRouteNodeStatus struct { + NodeName string `json:"nodeName"` + PolicyUID string `json:"policyUID"` + ObservedGeneration int64 `json:"observedGeneration"` + InputDigest string `json:"inputDigest"` + Ready bool `json:"ready"` + ValidUntil metav1.Time `json:"validUntil"` +} + +const ( + // ConditionTypeServiceRouteAccepted reports whether the policy is valid and + // can be compiled by the dataplane controllers. + ConditionTypeServiceRouteAccepted = "Accepted" +) + +// ServiceRoutePolicyList is a list of ServiceRoutePolicy resources. +// +kubebuilder:object:root=true +type ServiceRoutePolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceRoutePolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register( + &ServiceEndpoint{}, &ServiceEndpointList{}, + &ServiceRoutePolicy{}, &ServiceRoutePolicyList{}, + ) +} diff --git a/api/network/v1alpha1/serviceroute_types_test.go b/api/network/v1alpha1/serviceroute_types_test.go new file mode 100644 index 00000000..b1cc5f03 --- /dev/null +++ b/api/network/v1alpha1/serviceroute_types_test.go @@ -0,0 +1,65 @@ +package v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestServiceRoutePolicyDeepCopy(t *testing.T) { + original := &ServiceRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "dns", Namespace: "platform"}, + Spec: ServiceRoutePolicySpec{ + ServiceRef: ServiceEndpointReference{Name: "ipv6-egress-dns"}, + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{"egress": "enabled"}}, + ProtocolPorts: []ServiceRouteProtocolPort{{Protocol: NetworkServiceProtocolUDP, Port: 53}}, + Region: "us-central-1", + }, + Status: ServiceRoutePolicyStatus{Conditions: []metav1.Condition{{Type: "Accepted"}}}, + } + + copy := original.DeepCopy() + copy.Spec.AttachmentSelector.MatchLabels["egress"] = "disabled" + copy.Spec.ProtocolPorts[0].Port = 853 + copy.Status.Conditions[0].Type = "Rejected" + + if original.Spec.AttachmentSelector.MatchLabels["egress"] != "enabled" { + t.Fatal("DeepCopy shared selector labels") + } + if original.Status.Conditions[0].Type != "Accepted" { + t.Fatal("DeepCopy shared status conditions") + } + if original.Spec.ProtocolPorts[0].Port != 53 { + t.Fatal("DeepCopy shared protocol ports") + } +} + +func TestServiceRoutePolicyListDeepCopy(t *testing.T) { + original := &ServiceRoutePolicyList{Items: []ServiceRoutePolicy{{ + Spec: ServiceRoutePolicySpec{ + AttachmentSelector: metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "region", Operator: metav1.LabelSelectorOpIn, Values: []string{"us-central-1"}, + }}}, + }}}} + + copy := original.DeepCopy() + copy.Items[0].Spec.AttachmentSelector.MatchExpressions[0].Values[0] = "eu-west-1" + if got := original.Items[0].Spec.AttachmentSelector.MatchExpressions[0].Values[0]; got != "us-central-1" { + t.Fatalf("DeepCopy shared selector values: got %q", got) + } +} + +func TestServiceEndpointDeepCopy(t *testing.T) { + original := &ServiceEndpoint{Spec: ServiceEndpointSpec{ + DeliveryMode: ServiceEndpointDeliveryModePreferNodeLocal, + AttachmentSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ + "service.datumapis.com/name": "dns64", + }}, + }} + + copy := original.DeepCopy() + copy.Spec.AttachmentSelector.MatchLabels["service.datumapis.com/name"] = "other" + if got := original.Spec.AttachmentSelector.MatchLabels["service.datumapis.com/name"]; got != "dns64" { + t.Fatalf("DeepCopy shared endpoint selector labels: got %q", got) + } +} diff --git a/api/network/v1alpha1/shared_types.go b/api/network/v1alpha1/shared_types.go new file mode 100644 index 00000000..3312fbe4 --- /dev/null +++ b/api/network/v1alpha1/shared_types.go @@ -0,0 +1,217 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// AFI is the Address Family Indicator for a BGP address family. +// +// +kubebuilder:validation:Enum=ipv4;ipv6;l2vpn +type AFI string + +const ( + AFIIPv4 AFI = "ipv4" + AFIIPv6 AFI = "ipv6" + AFIL2VPN AFI = "l2vpn" +) + +// SAFI is the Subsequent Address Family Indicator for a BGP address family. +// +// +kubebuilder:validation:Enum=unicast;evpn +type SAFI string + +const ( + SAFIUnicast SAFI = "unicast" + SAFIEVPN SAFI = "evpn" +) + +// AddressFamily is a BGP address family expressed as an AFI/SAFI pair. +// Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. +// +// +kubebuilder:validation:XValidation:rule="self.afi == 'ipv4' ? self.safi == 'unicast' : true",message="IPv4 only supports unicast SAFI" +// +kubebuilder:validation:XValidation:rule="self.afi == 'ipv6' ? self.safi == 'unicast' : true",message="IPv6 only supports unicast SAFI" +// +kubebuilder:validation:XValidation:rule="self.afi == 'l2vpn' ? self.safi == 'evpn' : true",message="L2VPN only supports evpn SAFI" +type AddressFamily struct { + // AFI is the address family indicator. + AFI AFI `json:"afi"` + + // SAFI is the subsequent address family indicator. + SAFI SAFI `json:"safi"` +} + +// SRv6Function is the RFC 8986 endpoint behavior applied to a decapsulated +// SRv6 packet, addressed via the uSID Argument space per RFC 9800. +// +// +kubebuilder:validation:Enum=End.DT4;End.DT6;End.DT46 +type SRv6Function string + +const ( + // SRv6FunctionEndDT4 decapsulates and looks up the packet in an IPv4 VRF. + SRv6FunctionEndDT4 SRv6Function = "End.DT4" + + // SRv6FunctionEndDT6 decapsulates and looks up the packet in an IPv6 VRF. + SRv6FunctionEndDT6 SRv6Function = "End.DT6" + + // SRv6FunctionEndDT46 decapsulates and looks up the packet in an IPv4 or + // IPv6 VRF based on the inner packet's address family. + SRv6FunctionEndDT46 SRv6Function = "End.DT46" +) + +// TargetRef identifies the execution target for a BGPRouter. +// Supported values for kind: Node. +type TargetRef struct { + // Kind is the target resource kind (e.g. Node). + // +kubebuilder:validation:MinLength=1 + Kind string `json:"kind"` + + // Name is the name of the target resource. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// RouterRef is a direct reference to a single BGPRouter by name within the same namespace. +type RouterRef struct { + // Name is the name of the BGPRouter. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// RouterSelector selects one or more BGPRouter resources by label within the same namespace. +type RouterSelector struct { + // MatchLabels is a map of key/value label pairs to match. + // +optional + MatchLabels map[string]string `json:"matchLabels,omitempty"` + + // MatchExpressions is a list of label selector requirements. + // +optional + MatchExpressions []metav1.LabelSelectorRequirement `json:"matchExpressions,omitempty"` +} + +// RouterTarget is embedded by resources that bind to one or more BGPRouters. +// Exactly one of routerRef or routerSelector must be set. +// +// +kubebuilder:validation:XValidation:rule="has(self.routerRef) != has(self.routerSelector)",message="Exactly one of routerRef or routerSelector must be set" +type RouterTarget struct { + // RouterRef targets a single BGPRouter by name. + // Mutually exclusive with routerSelector. + // +optional + RouterRef *RouterRef `json:"routerRef,omitempty"` + + // RouterSelector targets one or more BGPRouters by label. + // Mutually exclusive with routerRef. + // +optional + RouterSelector *RouterSelector `json:"routerSelector,omitempty"` +} + +// LocalSecretRef references a Secret within the same namespace. +// Cross-namespace references are not supported. +type LocalSecretRef struct { + // Name is the name of the Secret. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// RouterStatus holds per-router reconciliation status used by BGPVRFInstance. +type RouterStatus struct { + // RouterName is the name of the BGPRouter this entry describes. + RouterName string `json:"routerName"` + + // Conditions are the per-router conditions. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ResolvedConfig holds the configuration that was actually applied. + // + // +optional + ResolvedConfig *ResolvedRouterConfig `json:"resolvedConfig,omitempty"` +} + +// ResolvedRouterConfig holds the configuration resolved and applied to a specific router. +type ResolvedRouterConfig struct { + // RouterID resolved for this router. + // +optional + RouterID string `json:"routerID,omitempty"` + + // ASNumber is the AS number configured. + // +optional + ASNumber *int64 `json:"asNumber,omitempty"` + + // AddressFamilies configured. + // +optional + AddressFamilies []AddressFamily `json:"addressFamilies,omitempty"` +} + +// BGPPeerBFD defines BFD (Bidirectional Forwarding Detection) parameters for a BGP peer. +// +// BFD provides sub-second failure detection for BGP sessions, enabling fast +// convergence without relying on BGP hold timers (default 90s). +type BGPPeerBFD struct { + // Enabled indicates whether BFD is enabled for this peer. + // +optional + Enabled bool `json:"enabled"` + + // MinimumTx is the minimum transmission interval in microseconds. + // FRR / GoBGP use microseconds — the controller converts to milliseconds + // when programming the runtime. Defaults to 300000 (300ms). + // +optional + MinimumTX *int32 `json:"minimumTx,omitempty"` + + // MinimumRx is the minimum reception interval in microseconds. + // Defaults to 300000 (300ms). + // +optional + MinimumRX *int32 `json:"minimumRx,omitempty"` + + // DetectMultiplier is the number of missed packets before declaring the + // BFD session down. Default is 3. + // +kubebuilder:validation:Minimum=3 + // +kubebuilder:validation:Maximum=50 + // +optional + DetectMultiplier *int32 `json:"detectMultiplier,omitempty"` + + // MultiHop enables BFD for eBGP multi-hop sessions (RFC 5883). + // Must be true when ebgpMultiHop is true. + // +optional + MultiHop bool `json:"multiHop,omitempty"` +} + +// BGPPeerGracefulRestart defines BGP graceful restart parameters for a peer. +// +// Graceful restart (RFC 4724) allows a BGP speaker to restart its control plane +// without tearing down sessions. Routes are preserved during the restart period, +// preventing MAC/IP flapping in EVPN deployments. +type BGPPeerGracefulRestart struct { + // Enabled indicates whether graceful restart is enabled for this peer. + // +optional + Enabled bool `json:"enabled"` + + // RestartTime is the maximum time (in seconds) the peer expects the + // local system to complete a graceful restart. Range: 1-1200. + // Default: 120 seconds. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=1200 + // +optional + RestartTime *uint32 `json:"restartTime,omitempty"` +} + +// BGPPeerAuthentication defines authentication configuration for a BGP peer. +// +// Supports both secret-ref-based (MD5/TCP-AO) and plain-text passwords. +// The plain-text password is primarily for non-production environments. +type BGPPeerAuthentication struct { + // Password is a plain-text authentication password. + // Prefer AuthSecretRef for production deployments. + // +optional + Password *string `json:"password,omitempty"` +} + +// OriginType defines the BGP origin attribute values. +// +// +kubebuilder:validation:Enum=igp;egp;incomplete +type OriginType string + +const ( + OriginIGP OriginType = "igp" + OriginEGP OriginType = "egp" + OriginIncomplete OriginType = "incomplete" +) diff --git a/api/network/v1alpha1/vipbinding_types.go b/api/network/v1alpha1/vipbinding_types.go new file mode 100644 index 00000000..ad2654da --- /dev/null +++ b/api/network/v1alpha1/vipbinding_types.go @@ -0,0 +1,155 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ServiceVIPBinding drives one worker node's backend-side half of the +// DSR/Maglev load-balancer datapath: it tells the node which service VIP a +// specific local backend must be reachable on, so the backend can reply to +// clients directly (the "Direct Server Return" this design depends on — +// see NetworkGateway's doc comment). Generated, never written by users: +// galactic-router on each worker node writes one object per (service, VIP, +// backend) for every backend a NetworkService's BackendSelector places on that +// node, owned by the service, and the same process's per-node reconciler +// consumes it. +// +// EgressKind decides which of two backend mechanisms this object drives, +// mirroring the same veth/tap fork the SRv6 uSID decap datapath already +// has (internal/plumbing/ebpf/usidmap's egress_kind field). Both mechanisms +// now converge on the same VIP-boundary substitution +// (BackendAddress:BackendPort for VIPAddress:Port at the SRv6 uSID TC-BPF +// boundary, usid_ingress's inbound half / usid_egress's outbound half) — +// required for both, not just tap, since a decapsulated ingress packet is +// delivered into the owning tenant's own VRF routing table, which has no +// route to an address bound outside that VRF (found live: see galactic's +// ServiceVIPBindingReconciler doc comment): +// +// - veth (container backend): the node ALSO binds VIPAddress on its own +// galactic-vip0 dummy interface (internal/plumbing/vip's +// Bind/Unbind/Verify in galactic) — this alone does not deliver +// anything to the backend pod (galactic-vip0 lives in the node's root +// namespace, not the tenant's VRF), but still lets the node itself +// verifiably answer on the VIP. +// - tap (VM backend): there is no guest-side configuration capability in +// this repo by design (internal/cnitap's own doc comment), so the +// substitution above is this kind's *only* delivery mechanism — the +// guest OS never needs to know the VIP exists at all. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=svcvip +// +kubebuilder:printcolumn:name="NODE",type="string",JSONPath=".spec.targetRef.name" +// +kubebuilder:printcolumn:name="VIP",type="string",JSONPath=".spec.vipAddress" +// +kubebuilder:printcolumn:name="BACKEND",type="string",JSONPath=".spec.backendAddress" +// +kubebuilder:printcolumn:name="KIND",type="string",JSONPath=".spec.egressKind" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +type ServiceVIPBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceVIPBindingSpec `json:"spec,omitempty"` + Status ServiceVIPBindingStatus `json:"status,omitempty"` +} + +// ConditionTypeBound indicates whether the backend is actually confirmed +// reachable on the VIP -- see ServiceVIPBindingStatus's doc comment. +const ConditionTypeBound string = "Bound" + +// ServiceVIPBindingEgressKind mirrors usidmap's EgressKindVeth/EgressKindTap +// constants at the API layer — see ServiceVIPBinding's doc comment. +// +// +kubebuilder:validation:Enum=veth;tap +type ServiceVIPBindingEgressKind string + +const ( + // ServiceVIPBindingEgressKindVeth selects the netns-bind mechanism. + ServiceVIPBindingEgressKindVeth ServiceVIPBindingEgressKind = "veth" + + // ServiceVIPBindingEgressKindTap selects the transparent tap-boundary + // translation mechanism. + ServiceVIPBindingEgressKindTap ServiceVIPBindingEgressKind = "tap" +) + +// ServiceVIPBindingSpec defines the desired VIP binding/translation state. +type ServiceVIPBindingSpec struct { + // TargetRef identifies the Node this binding applies to. + // +kubebuilder:validation:Required + TargetRef TargetRef `json:"targetRef"` + + // VPCRef is the opaque identifier of the VPC the backend belongs to, + // copied from the owning NetworkService. It names the tenant VRF the + // binding's translation rows are written for, so two tenants using the + // same backend address on one node never share a row. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VPCRef string `json:"vpcRef"` + + // VIPAddress is the service VIP the backend must be reachable on. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="isIP(self)",message="vipAddress must be a valid IP address" + VIPAddress string `json:"vipAddress"` + + // Port is the VIP-facing port traffic arrives on. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port int32 `json:"port"` + + // Protocol is the transport protocol this binding applies to. + // +kubebuilder:validation:Required + Protocol NetworkServiceProtocol `json:"protocol"` + + // BackendAddress is the backend's own real address (its pod-netns + // address for a veth backend, or its actual guest-facing address for a + // tap backend) — the VIP-boundary substitution target for both kinds + // now (see EgressKind's own doc comment for why veth needs this too, + // not just tap). +optional at the API level for the same reason + // EgressKind itself carries no matching CEL requirement; the + // reconciler validates it's set for either kind before doing anything. + // +optional + // +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="backendAddress must be a valid IP address" + BackendAddress string `json:"backendAddress,omitempty"` + + // BackendPort is the backend's own real port, paired with + // BackendAddress — required for both kinds, see that field's doc + // comment. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + BackendPort int32 `json:"backendPort,omitempty"` + + // EgressKind selects which backend mechanism this binding drives. + // +kubebuilder:validation:Required + EgressKind ServiceVIPBindingEgressKind `json:"egressKind"` +} + +// ServiceVIPBindingStatus defines the observed state of a ServiceVIPBinding. +type ServiceVIPBindingStatus struct { + // ObservedGeneration is the .metadata.generation this status was computed from. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions contains the standard conditions for this resource, + // including Bound (set True once internal/plumbing/vip.Verify or the + // equivalent tap-translation-table check confirms the backend is + // actually reachable on VIPAddress, not merely that the bind/table-write + // call itself returned nil). + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// ServiceVIPBindingList is a list of ServiceVIPBinding resources. +// +kubebuilder:object:root=true +type ServiceVIPBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServiceVIPBinding `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ServiceVIPBinding{}, &ServiceVIPBindingList{}) +} diff --git a/api/network/v1alpha1/vrf_types.go b/api/network/v1alpha1/vrf_types.go new file mode 100644 index 00000000..c314309a --- /dev/null +++ b/api/network/v1alpha1/vrf_types.go @@ -0,0 +1,126 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BGPVRFInstance configures an L2VPN EVPN VRF on matched BGPRouters. +// The referenced BGPRouter must have l2vpn-evpn in its addressFamilies. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=bgpvrf +// +kubebuilder:printcolumn:name="VRF-ID",type="integer",JSONPath=".spec.vrfID" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +type BGPVRFInstance struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BGPVRFInstanceSpec `json:"spec,omitempty"` + Status BGPVRFInstanceStatus `json:"status,omitempty"` +} + +// BGPVRFInstanceSpec defines the desired VRF configuration. +type BGPVRFInstanceSpec struct { + RouterTarget `json:",inline"` + + // VRFID is the 16-bit PoP-local VRF identifier used for RFC 9800 uSID + // Argument addressing and to derive the RFC 4364 Type 1 Route + // Distinguisher ("routerID:vrfID"). Unique per (VPC, PoP). Value 0 is + // reserved. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + VRFID int32 `json:"vrfID"` + + // ImportRouteTargets is the list of BGP extended community route targets + // used to import routes into this VRF. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=32 + ImportRouteTargets []RouteTarget `json:"importRouteTargets"` + + // ExportRouteTargets is the list of BGP extended community route targets + // attached to routes exported from this VRF. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=32 + ExportRouteTargets []RouteTarget `json:"exportRouteTargets"` + + // NPTv6 configures stateless RFC 6296 Network Prefix Translation for + // this VRF: backends presenting an address within ULAPrefix are + // translated, checksum-neutrally and bidirectionally, to the + // corresponding address in PublicPrefix. Unset means this VRF's traffic + // crosses the SRv6 fabric with its own ULA source/destination + // unmodified — the common case. Keyed by this VRFID, never by address, + // specifically so two VRFs may configure the identical ULAPrefix (e.g. + // both using the same private range) without collision — each VRF's + // mapping is independent, looked up only after this VRF's own identity + // is already resolved from the SRv6 uSID Argument, never derived from + // address content alone. + // +optional + NPTv6 *NPTv6Spec `json:"nptv6,omitempty"` +} + +// NPTv6Spec is one VRF's stateless RFC 6296 prefix-translation mapping. +// ULAPrefix and PublicPrefix must share the same prefix length — RFC 6296 +// translation only ever rewrites the shared prefix, never the host bits. +// +// +kubebuilder:validation:XValidation:rule="self.ulaPrefix.split('/')[1] == self.publicPrefix.split('/')[1]",message="ulaPrefix and publicPrefix must share the same prefix length" +type NPTv6Spec struct { + // ULAPrefix is this VRF's tenant-facing IPv6 ULA prefix (e.g. + // "fd20:60::/64"), as presented by backends inside the VRF. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="ulaPrefix must be a valid CIDR" + // +kubebuilder:validation:XValidation:rule="self.contains(':')",message="ulaPrefix must be an IPv6 CIDR" + ULAPrefix string `json:"ulaPrefix"` + + // PublicPrefix is the externally-routable IPv6 prefix ULAPrefix + // translates to/from, same prefix length as ULAPrefix. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="publicPrefix must be a valid CIDR" + // +kubebuilder:validation:XValidation:rule="self.contains(':')",message="publicPrefix must be an IPv6 CIDR" + PublicPrefix string `json:"publicPrefix"` +} + +// RouteTarget is a BGP extended community in "ASN:NN" or "IP:NN" format. +// +// +kubebuilder:validation:XValidation:rule="self.value.matches('^([0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}|[0-9]{1,10}):[0-9]{1,10}$')",message="value must be in ASN:NN or IP:NN format" +type RouteTarget struct { + // Value is the route target extended community string. + // Format: "ASN:NN" (e.g. "65000:100") or "IP:NN" (e.g. "192.0.2.1:100"). + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=21 + Value string `json:"value"` +} + +// BGPVRFInstanceStatus defines the observed state of BGPVRFInstance. +type BGPVRFInstanceStatus struct { + // Conditions are top-level conditions for this BGPVRFInstance. + // + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Routers holds per-router reconciliation status. + // + // +listType=map + // +listMapKey=routerName + // +optional + Routers []RouterStatus `json:"routers,omitempty"` +} + +// BGPVRFInstanceList is a list of BGPVRFInstance resources. +// +kubebuilder:object:root=true +type BGPVRFInstanceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BGPVRFInstance `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BGPVRFInstance{}, &BGPVRFInstanceList{}) +} diff --git a/api/network/v1alpha1/vrf_types_test.go b/api/network/v1alpha1/vrf_types_test.go new file mode 100644 index 00000000..3892713a --- /dev/null +++ b/api/network/v1alpha1/vrf_types_test.go @@ -0,0 +1,140 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestVRF() *BGPVRFInstance { + return &BGPVRFInstance{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "galactic.datumapis.com/v1alpha1", + Kind: "BGPVRFInstance", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-vrf"}, + Spec: BGPVRFInstanceSpec{ + RouterTarget: RouterTarget{ + RouterRef: &RouterRef{Name: "overlay-router"}, + }, + VRFID: 100, + ImportRouteTargets: []RouteTarget{{Value: "65000:100"}}, + ExportRouteTargets: []RouteTarget{{Value: "65000:100"}}, + }, + } +} + +// TestBGPVRFInstanceDeepCopy verifies that DeepCopy produces an independent copy: +// mutations to slices and maps in the copy must not affect the original. +func TestBGPVRFInstanceDeepCopy(t *testing.T) { + orig := newTestVRF() + dup := orig.DeepCopy() + + // Mutate dup — original must be unaffected. + dup.Spec.VRFID = 200 + dup.Spec.ImportRouteTargets[0].Value = "65001:200" + dup.Spec.RouterRef.Name = "other-router" + + if orig.Spec.VRFID != 100 { + t.Errorf("VRFID mutated: got %d", orig.Spec.VRFID) + } + if orig.Spec.ImportRouteTargets[0].Value != "65000:100" { + t.Errorf("ImportRouteTargets[0] mutated: got %q", orig.Spec.ImportRouteTargets[0].Value) + } + if orig.Spec.RouterRef.Name != "overlay-router" { + t.Errorf("RouterRef mutated: got %q", orig.Spec.RouterRef.Name) + } +} + +// TestBGPVRFInstanceDeepCopyNil verifies DeepCopy on a nil pointer returns nil. +func TestBGPVRFInstanceDeepCopyNil(t *testing.T) { + var v *BGPVRFInstance + if v.DeepCopy() != nil { + t.Error("DeepCopy on nil pointer should return nil") + } +} + +// TestBGPVRFInstanceJSONRoundTrip verifies that the struct serialises and +// deserialises through JSON without data loss. +func TestBGPVRFInstanceJSONRoundTrip(t *testing.T) { + orig := newTestVRF() + orig.Spec.ImportRouteTargets = []RouteTarget{{Value: "65000:100"}, {Value: "65000:200"}} + orig.Spec.ExportRouteTargets = []RouteTarget{{Value: "65000:100"}} + + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BGPVRFInstance + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if got.Spec.VRFID != orig.Spec.VRFID { + t.Errorf("VRFID: got %d, want %d", got.Spec.VRFID, orig.Spec.VRFID) + } + if got.Spec.RouterRef.Name != orig.Spec.RouterRef.Name { + t.Errorf("RouterRef: got %q, want %q", got.Spec.RouterRef.Name, orig.Spec.RouterRef.Name) + } + if len(got.Spec.ImportRouteTargets) != 2 { + t.Errorf("ImportRouteTargets len: got %d, want 2", len(got.Spec.ImportRouteTargets)) + } + if len(got.Spec.ExportRouteTargets) != 1 { + t.Errorf("ExportRouteTargets len: got %d, want 1", len(got.Spec.ExportRouteTargets)) + } + if got.Spec.ExportRouteTargets[0].Value != "65000:100" { + t.Errorf("ExportRouteTargets[0]: got %q, want %q", got.Spec.ExportRouteTargets[0].Value, "65000:100") + } +} + +// TestBGPVRFInstanceListDeepCopy verifies that BGPVRFInstanceList.DeepCopy +// produces independent copies of each item. +func TestBGPVRFInstanceListDeepCopy(t *testing.T) { + list := &BGPVRFInstanceList{ + Items: []BGPVRFInstance{*newTestVRF()}, + } + copied := list.DeepCopy() + copied.Items[0].Spec.VRFID = 999 + + if list.Items[0].Spec.VRFID != 100 { + t.Errorf("original list item mutated via copy") + } +} + +// TestBGPVRFInstanceVRFIDFieldName verifies the JSON key is "vrfID". +func TestBGPVRFInstanceVRFIDFieldName(t *testing.T) { + orig := newTestVRF() + + data, err := json.Marshal(orig.Spec) + 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) + } + + if raw, ok := m["vrfID"]; !ok || raw != float64(100) { + t.Errorf("unexpected vrfID value: %v", raw) + } +} + +// TestRouteTargetJSONFieldName verifies the JSON field name for RouteTarget.Value +// matches the CRD schema ("value"). +func TestRouteTargetJSONFieldName(t *testing.T) { + rt := RouteTarget{Value: "65000:100"} + data, err := json.Marshal(rt) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if v, ok := m["value"]; !ok || v != "65000:100" { + t.Errorf("expected JSON key \"value\"=\"65000:100\", got %v", m) + } +} diff --git a/api/network/v1alpha1/zz_generated.deepcopy.go b/api/network/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..67127559 --- /dev/null +++ b/api/network/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,2309 @@ +//go:build !ignore_autogenerated + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ASPathFilter) DeepCopyInto(out *ASPathFilter) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ASPathFilter. +func (in *ASPathFilter) DeepCopy() *ASPathFilter { + if in == nil { + return nil + } + out := new(ASPathFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AddressClaimRef) DeepCopyInto(out *AddressClaimRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddressClaimRef. +func (in *AddressClaimRef) DeepCopy() *AddressClaimRef { + if in == nil { + return nil + } + out := new(AddressClaimRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AddressFamily) DeepCopyInto(out *AddressFamily) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddressFamily. +func (in *AddressFamily) DeepCopy() *AddressFamily { + if in == nil { + return nil + } + out := new(AddressFamily) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AdvertisementOriginateFrom) DeepCopyInto(out *AdvertisementOriginateFrom) { + *out = *in + if in.InterfaceName != nil { + in, out := &in.InterfaceName, &out.InterfaceName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdvertisementOriginateFrom. +func (in *AdvertisementOriginateFrom) DeepCopy() *AdvertisementOriginateFrom { + if in == nil { + return nil + } + out := new(AdvertisementOriginateFrom) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AdvertisementPolicyRef) DeepCopyInto(out *AdvertisementPolicyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdvertisementPolicyRef. +func (in *AdvertisementPolicyRef) DeepCopy() *AdvertisementPolicyRef { + if in == nil { + return nil + } + out := new(AdvertisementPolicyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AsPathSet) DeepCopyInto(out *AsPathSet) { + *out = *in + if in.Prepend != nil { + in, out := &in.Prepend, &out.Prepend + *out = new(uint32) + **out = **in + } + if in.ASN != nil { + in, out := &in.ASN, &out.ASN + *out = new(int64) + **out = **in + } + if in.Replace != nil { + in, out := &in.Replace, &out.Replace + *out = make([]int64, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AsPathSet. +func (in *AsPathSet) DeepCopy() *AsPathSet { + if in == nil { + return nil + } + out := new(AsPathSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPAdvertisement) DeepCopyInto(out *BGPAdvertisement) { + *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 BGPAdvertisement. +func (in *BGPAdvertisement) DeepCopy() *BGPAdvertisement { + if in == nil { + return nil + } + out := new(BGPAdvertisement) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPAdvertisement) 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 *BGPAdvertisementList) DeepCopyInto(out *BGPAdvertisementList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPAdvertisement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPAdvertisementList. +func (in *BGPAdvertisementList) DeepCopy() *BGPAdvertisementList { + if in == nil { + return nil + } + out := new(BGPAdvertisementList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPAdvertisementList) 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 *BGPAdvertisementSpec) DeepCopyInto(out *BGPAdvertisementSpec) { + *out = *in + out.RouterRef = in.RouterRef + out.AddressFamily = in.AddressFamily + if in.Prefixes != nil { + in, out := &in.Prefixes, &out.Prefixes + *out = make([]Prefix, len(*in)) + copy(*out, *in) + } + if in.Redistribute != nil { + in, out := &in.Redistribute, &out.Redistribute + *out = make([]RedistributeSource, len(*in)) + copy(*out, *in) + } + if in.OriginateFrom != nil { + in, out := &in.OriginateFrom, &out.OriginateFrom + *out = new(AdvertisementOriginateFrom) + (*in).DeepCopyInto(*out) + } + if in.PolicyRef != nil { + in, out := &in.PolicyRef, &out.PolicyRef + *out = new(AdvertisementPolicyRef) + **out = **in + } + if in.Communities != nil { + in, out := &in.Communities, &out.Communities + *out = make([]Community, len(*in)) + copy(*out, *in) + } + if in.LocalPreference != nil { + in, out := &in.LocalPreference, &out.LocalPreference + *out = new(int32) + **out = **in + } + if in.VRFID != nil { + in, out := &in.VRFID, &out.VRFID + *out = new(int32) + **out = **in + } + if in.Function != nil { + in, out := &in.Function, &out.Function + *out = new(SRv6Function) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPAdvertisementSpec. +func (in *BGPAdvertisementSpec) DeepCopy() *BGPAdvertisementSpec { + if in == nil { + return nil + } + out := new(BGPAdvertisementSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPAdvertisementStatus) DeepCopyInto(out *BGPAdvertisementStatus) { + *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 BGPAdvertisementStatus. +func (in *BGPAdvertisementStatus) DeepCopy() *BGPAdvertisementStatus { + if in == nil { + return nil + } + out := new(BGPAdvertisementStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPCommunitySet) DeepCopyInto(out *BGPCommunitySet) { + *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 BGPCommunitySet. +func (in *BGPCommunitySet) DeepCopy() *BGPCommunitySet { + if in == nil { + return nil + } + out := new(BGPCommunitySet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPCommunitySet) 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 *BGPCommunitySetList) DeepCopyInto(out *BGPCommunitySetList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPCommunitySet, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPCommunitySetList. +func (in *BGPCommunitySetList) DeepCopy() *BGPCommunitySetList { + if in == nil { + return nil + } + out := new(BGPCommunitySetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPCommunitySetList) 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 *BGPCommunitySetSpec) DeepCopyInto(out *BGPCommunitySetSpec) { + *out = *in + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPCommunitySetSpec. +func (in *BGPCommunitySetSpec) DeepCopy() *BGPCommunitySetSpec { + if in == nil { + return nil + } + out := new(BGPCommunitySetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPCommunitySetStatus) DeepCopyInto(out *BGPCommunitySetStatus) { + *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 BGPCommunitySetStatus. +func (in *BGPCommunitySetStatus) DeepCopy() *BGPCommunitySetStatus { + if in == nil { + return nil + } + out := new(BGPCommunitySetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPMaximumPrefix) DeepCopyInto(out *BGPMaximumPrefix) { + *out = *in + if in.ThresholdPercent != nil { + in, out := &in.ThresholdPercent, &out.ThresholdPercent + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPMaximumPrefix. +func (in *BGPMaximumPrefix) DeepCopy() *BGPMaximumPrefix { + if in == nil { + return nil + } + out := new(BGPMaximumPrefix) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeer) DeepCopyInto(out *BGPPeer) { + *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 BGPPeer. +func (in *BGPPeer) DeepCopy() *BGPPeer { + if in == nil { + return nil + } + out := new(BGPPeer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPeer) 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 *BGPPeerAuthentication) DeepCopyInto(out *BGPPeerAuthentication) { + *out = *in + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerAuthentication. +func (in *BGPPeerAuthentication) DeepCopy() *BGPPeerAuthentication { + if in == nil { + return nil + } + out := new(BGPPeerAuthentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeerBFD) DeepCopyInto(out *BGPPeerBFD) { + *out = *in + if in.MinimumTX != nil { + in, out := &in.MinimumTX, &out.MinimumTX + *out = new(int32) + **out = **in + } + if in.MinimumRX != nil { + in, out := &in.MinimumRX, &out.MinimumRX + *out = new(int32) + **out = **in + } + if in.DetectMultiplier != nil { + in, out := &in.DetectMultiplier, &out.DetectMultiplier + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerBFD. +func (in *BGPPeerBFD) DeepCopy() *BGPPeerBFD { + if in == nil { + return nil + } + out := new(BGPPeerBFD) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeerGracefulRestart) DeepCopyInto(out *BGPPeerGracefulRestart) { + *out = *in + if in.RestartTime != nil { + in, out := &in.RestartTime, &out.RestartTime + *out = new(uint32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerGracefulRestart. +func (in *BGPPeerGracefulRestart) DeepCopy() *BGPPeerGracefulRestart { + if in == nil { + return nil + } + out := new(BGPPeerGracefulRestart) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeerList) DeepCopyInto(out *BGPPeerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPPeer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerList. +func (in *BGPPeerList) DeepCopy() *BGPPeerList { + if in == nil { + return nil + } + out := new(BGPPeerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPeerList) 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 *BGPPeerSpec) DeepCopyInto(out *BGPPeerSpec) { + *out = *in + in.RouterTarget.DeepCopyInto(&out.RouterTarget) + if in.RemotePort != nil { + in, out := &in.RemotePort, &out.RemotePort + *out = new(int32) + **out = **in + } + if in.AuthSecretRef != nil { + in, out := &in.AuthSecretRef, &out.AuthSecretRef + *out = new(LocalSecretRef) + **out = **in + } + if in.AddressFamilies != nil { + in, out := &in.AddressFamilies, &out.AddressFamilies + *out = make([]AddressFamily, len(*in)) + copy(*out, *in) + } + if in.HoldTime != nil { + in, out := &in.HoldTime, &out.HoldTime + *out = new(v1.Duration) + **out = **in + } + if in.KeepaliveTime != nil { + in, out := &in.KeepaliveTime, &out.KeepaliveTime + *out = new(v1.Duration) + **out = **in + } + if in.BFD != nil { + in, out := &in.BFD, &out.BFD + *out = new(BGPPeerBFD) + (*in).DeepCopyInto(*out) + } + if in.GracefulRestart != nil { + in, out := &in.GracefulRestart, &out.GracefulRestart + *out = new(BGPPeerGracefulRestart) + (*in).DeepCopyInto(*out) + } + if in.RemovePrivateAS != nil { + in, out := &in.RemovePrivateAS, &out.RemovePrivateAS + *out = new(int32) + **out = **in + } + if in.UpdateSource != nil { + in, out := &in.UpdateSource, &out.UpdateSource + *out = new(string) + **out = **in + } + if in.SendCommunity != nil { + in, out := &in.SendCommunity, &out.SendCommunity + *out = new(SendCommunityType) + **out = **in + } + if in.AllowASIn != nil { + in, out := &in.AllowASIn, &out.AllowASIn + *out = new(int32) + **out = **in + } + if in.MaximumPrefix != nil { + in, out := &in.MaximumPrefix, &out.MaximumPrefix + *out = new(BGPMaximumPrefix) + (*in).DeepCopyInto(*out) + } + if in.DefaultOriginRoute != nil { + in, out := &in.DefaultOriginRoute, &out.DefaultOriginRoute + *out = new(OriginType) + **out = **in + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(BGPPeerAuthentication) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPeerSpec. +func (in *BGPPeerSpec) DeepCopy() *BGPPeerSpec { + if in == nil { + return nil + } + out := new(BGPPeerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPeerStatus) DeepCopyInto(out *BGPPeerStatus) { + *out = *in + if in.LastEstablishedTime != nil { + in, out := &in.LastEstablishedTime, &out.LastEstablishedTime + *out = (*in).DeepCopy() + } + 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 BGPPeerStatus. +func (in *BGPPeerStatus) DeepCopy() *BGPPeerStatus { + if in == nil { + return nil + } + out := new(BGPPeerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPolicy) DeepCopyInto(out *BGPPolicy) { + *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 BGPPolicy. +func (in *BGPPolicy) DeepCopy() *BGPPolicy { + if in == nil { + return nil + } + out := new(BGPPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPolicy) 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 *BGPPolicyList) DeepCopyInto(out *BGPPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPolicyList. +func (in *BGPPolicyList) DeepCopy() *BGPPolicyList { + if in == nil { + return nil + } + out := new(BGPPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPolicyList) 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 *BGPPolicyMatch) DeepCopyInto(out *BGPPolicyMatch) { + *out = *in + if in.AddressFamilies != nil { + in, out := &in.AddressFamilies, &out.AddressFamilies + *out = make([]AddressFamily, len(*in)) + copy(*out, *in) + } + if in.PrefixList != nil { + in, out := &in.PrefixList, &out.PrefixList + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ASPathFilter != nil { + in, out := &in.ASPathFilter, &out.ASPathFilter + *out = new(ASPathFilter) + **out = **in + } + if in.CommunityMatch != nil { + in, out := &in.CommunityMatch, &out.CommunityMatch + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.EVPNRouteType != nil { + in, out := &in.EVPNRouteType, &out.EVPNRouteType + *out = make([]EVPNRouteType, len(*in)) + copy(*out, *in) + } + if in.VNI != nil { + in, out := &in.VNI, &out.VNI + *out = new(uint32) + **out = **in + } + if in.MACAddress != nil { + in, out := &in.MACAddress, &out.MACAddress + *out = new(string) + **out = **in + } + if in.IPPrefix != nil { + in, out := &in.IPPrefix, &out.IPPrefix + *out = new(string) + **out = **in + } + if in.LocalPreference != nil { + in, out := &in.LocalPreference, &out.LocalPreference + *out = new(int32) + **out = **in + } + if in.MED != nil { + in, out := &in.MED, &out.MED + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPolicyMatch. +func (in *BGPPolicyMatch) DeepCopy() *BGPPolicyMatch { + if in == nil { + return nil + } + out := new(BGPPolicyMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPolicySetActions) DeepCopyInto(out *BGPPolicySetActions) { + *out = *in + if in.Communities != nil { + in, out := &in.Communities, &out.Communities + *out = new(CommunitySet) + (*in).DeepCopyInto(*out) + } + if in.LocalPreference != nil { + in, out := &in.LocalPreference, &out.LocalPreference + *out = new(int32) + **out = **in + } + if in.Origin != nil { + in, out := &in.Origin, &out.Origin + *out = new(BGPOrigin) + **out = **in + } + if in.AsPath != nil { + in, out := &in.AsPath, &out.AsPath + *out = new(AsPathSet) + (*in).DeepCopyInto(*out) + } + if in.NextHop != nil { + in, out := &in.NextHop, &out.NextHop + *out = new(NextHopSet) + (*in).DeepCopyInto(*out) + } + if in.ExtCommunities != nil { + in, out := &in.ExtCommunities, &out.ExtCommunities + *out = new(ExtendedCommunitySet) + (*in).DeepCopyInto(*out) + } + if in.Metric != nil { + in, out := &in.Metric, &out.Metric + *out = new(int32) + **out = **in + } + if in.Color != nil { + in, out := &in.Color, &out.Color + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPolicySetActions. +func (in *BGPPolicySetActions) DeepCopy() *BGPPolicySetActions { + if in == nil { + return nil + } + out := new(BGPPolicySetActions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPolicySpec) DeepCopyInto(out *BGPPolicySpec) { + *out = *in + in.RouterTarget.DeepCopyInto(&out.RouterTarget) + if in.Terms != nil { + in, out := &in.Terms, &out.Terms + *out = make([]BGPPolicyTerm, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPolicySpec. +func (in *BGPPolicySpec) DeepCopy() *BGPPolicySpec { + if in == nil { + return nil + } + out := new(BGPPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPolicyStatus) DeepCopyInto(out *BGPPolicyStatus) { + *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 BGPPolicyStatus. +func (in *BGPPolicyStatus) DeepCopy() *BGPPolicyStatus { + if in == nil { + return nil + } + out := new(BGPPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPolicyTerm) DeepCopyInto(out *BGPPolicyTerm) { + *out = *in + in.Match.DeepCopyInto(&out.Match) + if in.Set != nil { + in, out := &in.Set, &out.Set + *out = new(BGPPolicySetActions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPolicyTerm. +func (in *BGPPolicyTerm) DeepCopy() *BGPPolicyTerm { + if in == nil { + return nil + } + out := new(BGPPolicyTerm) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPrefixList) DeepCopyInto(out *BGPPrefixList) { + *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 BGPPrefixList. +func (in *BGPPrefixList) DeepCopy() *BGPPrefixList { + if in == nil { + return nil + } + out := new(BGPPrefixList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPrefixList) 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 *BGPPrefixListEntry) DeepCopyInto(out *BGPPrefixListEntry) { + *out = *in + if in.GE != nil { + in, out := &in.GE, &out.GE + *out = new(int32) + **out = **in + } + if in.LE != nil { + in, out := &in.LE, &out.LE + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPrefixListEntry. +func (in *BGPPrefixListEntry) DeepCopy() *BGPPrefixListEntry { + if in == nil { + return nil + } + out := new(BGPPrefixListEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPrefixListList) DeepCopyInto(out *BGPPrefixListList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPPrefixList, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPrefixListList. +func (in *BGPPrefixListList) DeepCopy() *BGPPrefixListList { + if in == nil { + return nil + } + out := new(BGPPrefixListList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPPrefixListList) 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 *BGPPrefixListSpec) DeepCopyInto(out *BGPPrefixListSpec) { + *out = *in + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]BGPPrefixListEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPPrefixListSpec. +func (in *BGPPrefixListSpec) DeepCopy() *BGPPrefixListSpec { + if in == nil { + return nil + } + out := new(BGPPrefixListSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPPrefixListStatus) DeepCopyInto(out *BGPPrefixListStatus) { + *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 BGPPrefixListStatus. +func (in *BGPPrefixListStatus) DeepCopy() *BGPPrefixListStatus { + if in == nil { + return nil + } + out := new(BGPPrefixListStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPRouter) DeepCopyInto(out *BGPRouter) { + *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 BGPRouter. +func (in *BGPRouter) DeepCopy() *BGPRouter { + if in == nil { + return nil + } + out := new(BGPRouter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPRouter) 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 *BGPRouterList) DeepCopyInto(out *BGPRouterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPRouter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPRouterList. +func (in *BGPRouterList) DeepCopy() *BGPRouterList { + if in == nil { + return nil + } + out := new(BGPRouterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPRouterList) 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 *BGPRouterPeerSummary) DeepCopyInto(out *BGPRouterPeerSummary) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPRouterPeerSummary. +func (in *BGPRouterPeerSummary) DeepCopy() *BGPRouterPeerSummary { + if in == nil { + return nil + } + out := new(BGPRouterPeerSummary) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPRouterSpec) DeepCopyInto(out *BGPRouterSpec) { + *out = *in + out.TargetRef = in.TargetRef + if in.AddressFamilies != nil { + in, out := &in.AddressFamilies, &out.AddressFamilies + *out = make([]AddressFamily, len(*in)) + copy(*out, *in) + } + if in.ListenPort != nil { + in, out := &in.ListenPort, &out.ListenPort + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPRouterSpec. +func (in *BGPRouterSpec) DeepCopy() *BGPRouterSpec { + if in == nil { + return nil + } + out := new(BGPRouterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPRouterStatus) DeepCopyInto(out *BGPRouterStatus) { + *out = *in + out.Peers = in.Peers + 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 BGPRouterStatus. +func (in *BGPRouterStatus) DeepCopy() *BGPRouterStatus { + if in == nil { + return nil + } + out := new(BGPRouterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPVRFInstance) DeepCopyInto(out *BGPVRFInstance) { + *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 BGPVRFInstance. +func (in *BGPVRFInstance) DeepCopy() *BGPVRFInstance { + if in == nil { + return nil + } + out := new(BGPVRFInstance) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPVRFInstance) 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 *BGPVRFInstanceList) DeepCopyInto(out *BGPVRFInstanceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BGPVRFInstance, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPVRFInstanceList. +func (in *BGPVRFInstanceList) DeepCopy() *BGPVRFInstanceList { + if in == nil { + return nil + } + out := new(BGPVRFInstanceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BGPVRFInstanceList) 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 *BGPVRFInstanceSpec) DeepCopyInto(out *BGPVRFInstanceSpec) { + *out = *in + in.RouterTarget.DeepCopyInto(&out.RouterTarget) + if in.ImportRouteTargets != nil { + in, out := &in.ImportRouteTargets, &out.ImportRouteTargets + *out = make([]RouteTarget, len(*in)) + copy(*out, *in) + } + if in.ExportRouteTargets != nil { + in, out := &in.ExportRouteTargets, &out.ExportRouteTargets + *out = make([]RouteTarget, len(*in)) + copy(*out, *in) + } + if in.NPTv6 != nil { + in, out := &in.NPTv6, &out.NPTv6 + *out = new(NPTv6Spec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPVRFInstanceSpec. +func (in *BGPVRFInstanceSpec) DeepCopy() *BGPVRFInstanceSpec { + if in == nil { + return nil + } + out := new(BGPVRFInstanceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BGPVRFInstanceStatus) DeepCopyInto(out *BGPVRFInstanceStatus) { + *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.Routers != nil { + in, out := &in.Routers, &out.Routers + *out = make([]RouterStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BGPVRFInstanceStatus. +func (in *BGPVRFInstanceStatus) DeepCopy() *BGPVRFInstanceStatus { + if in == nil { + return nil + } + out := new(BGPVRFInstanceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommunitySet) DeepCopyInto(out *CommunitySet) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommunitySet. +func (in *CommunitySet) DeepCopy() *CommunitySet { + if in == nil { + return nil + } + out := new(CommunitySet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShard) DeepCopyInto(out *EgressShard) { + *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 EgressShard. +func (in *EgressShard) DeepCopy() *EgressShard { + if in == nil { + return nil + } + out := new(EgressShard) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EgressShard) 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 *EgressShardList) DeepCopyInto(out *EgressShardList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EgressShard, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardList. +func (in *EgressShardList) DeepCopy() *EgressShardList { + if in == nil { + return nil + } + out := new(EgressShardList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EgressShardList) 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 *EgressShardSpec) DeepCopyInto(out *EgressShardSpec) { + *out = *in + out.TargetRef = in.TargetRef + if in.ShardAddressIPv6ClaimRef != nil { + in, out := &in.ShardAddressIPv6ClaimRef, &out.ShardAddressIPv6ClaimRef + *out = new(AddressClaimRef) + **out = **in + } + if in.ShardAddressIPv4ClaimRef != nil { + in, out := &in.ShardAddressIPv4ClaimRef, &out.ShardAddressIPv4ClaimRef + *out = new(AddressClaimRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressShardSpec. +func (in *EgressShardSpec) DeepCopy() *EgressShardSpec { + if in == nil { + return nil + } + out := new(EgressShardSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressShardStatus) DeepCopyInto(out *EgressShardStatus) { + *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 EgressShardStatus. +func (in *EgressShardStatus) DeepCopy() *EgressShardStatus { + if in == nil { + return nil + } + out := new(EgressShardStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtendedCommunitySet) DeepCopyInto(out *ExtendedCommunitySet) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtendedCommunitySet. +func (in *ExtendedCommunitySet) DeepCopy() *ExtendedCommunitySet { + if in == nil { + return nil + } + out := new(ExtendedCommunitySet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalSecretRef) DeepCopyInto(out *LocalSecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalSecretRef. +func (in *LocalSecretRef) DeepCopy() *LocalSecretRef { + if in == nil { + return nil + } + out := new(LocalSecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NPTv6Spec) DeepCopyInto(out *NPTv6Spec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NPTv6Spec. +func (in *NPTv6Spec) DeepCopy() *NPTv6Spec { + if in == nil { + return nil + } + out := new(NPTv6Spec) + in.DeepCopyInto(out) + 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 + 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 NetworkGateway. +func (in *NetworkGateway) DeepCopy() *NetworkGateway { + if in == nil { + return nil + } + out := new(NetworkGateway) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkGateway) 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 *NetworkGatewayList) DeepCopyInto(out *NetworkGatewayList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkGateway, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkGatewayList. +func (in *NetworkGatewayList) DeepCopy() *NetworkGatewayList { + if in == nil { + return nil + } + out := new(NetworkGatewayList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkGatewayList) 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 *NetworkGatewaySpec) DeepCopyInto(out *NetworkGatewaySpec) { + *out = *in + out.TargetRef = in.TargetRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkGatewaySpec. +func (in *NetworkGatewaySpec) DeepCopy() *NetworkGatewaySpec { + if in == nil { + return nil + } + out := new(NetworkGatewaySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkGatewayStatus) DeepCopyInto(out *NetworkGatewayStatus) { + *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 NetworkGatewayStatus. +func (in *NetworkGatewayStatus) DeepCopy() *NetworkGatewayStatus { + if in == nil { + return nil + } + out := new(NetworkGatewayStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkService) DeepCopyInto(out *NetworkService) { + *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 NetworkService. +func (in *NetworkService) DeepCopy() *NetworkService { + if in == nil { + return nil + } + out := new(NetworkService) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkService) 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 *NetworkServiceList) DeepCopyInto(out *NetworkServiceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkService, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkServiceList. +func (in *NetworkServiceList) DeepCopy() *NetworkServiceList { + if in == nil { + return nil + } + out := new(NetworkServiceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkServiceList) 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 *NetworkServiceSpec) DeepCopyInto(out *NetworkServiceSpec) { + *out = *in + if in.VIPAddresses != nil { + in, out := &in.VIPAddresses, &out.VIPAddresses + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.BackendSelector.DeepCopyInto(&out.BackendSelector) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkServiceSpec. +func (in *NetworkServiceSpec) DeepCopy() *NetworkServiceSpec { + if in == nil { + return nil + } + out := new(NetworkServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkServiceStatus) DeepCopyInto(out *NetworkServiceStatus) { + *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 NetworkServiceStatus. +func (in *NetworkServiceStatus) DeepCopy() *NetworkServiceStatus { + if in == nil { + return nil + } + out := new(NetworkServiceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NextHopSet) DeepCopyInto(out *NextHopSet) { + *out = *in + if in.Self != nil { + in, out := &in.Self, &out.Self + *out = new(bool) + **out = **in + } + if in.Address != nil { + in, out := &in.Address, &out.Address + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NextHopSet. +func (in *NextHopSet) DeepCopy() *NextHopSet { + if in == nil { + return nil + } + out := new(NextHopSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedRouterConfig) DeepCopyInto(out *ResolvedRouterConfig) { + *out = *in + if in.ASNumber != nil { + in, out := &in.ASNumber, &out.ASNumber + *out = new(int64) + **out = **in + } + if in.AddressFamilies != nil { + in, out := &in.AddressFamilies, &out.AddressFamilies + *out = make([]AddressFamily, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedRouterConfig. +func (in *ResolvedRouterConfig) DeepCopy() *ResolvedRouterConfig { + if in == nil { + return nil + } + out := new(ResolvedRouterConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteTarget) DeepCopyInto(out *RouteTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteTarget. +func (in *RouteTarget) DeepCopy() *RouteTarget { + if in == nil { + return nil + } + out := new(RouteTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterRef) DeepCopyInto(out *RouterRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterRef. +func (in *RouterRef) DeepCopy() *RouterRef { + if in == nil { + return nil + } + out := new(RouterRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterSelector) DeepCopyInto(out *RouterSelector) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.MatchExpressions != nil { + in, out := &in.MatchExpressions, &out.MatchExpressions + *out = make([]v1.LabelSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterSelector. +func (in *RouterSelector) DeepCopy() *RouterSelector { + if in == nil { + return nil + } + out := new(RouterSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterStatus) DeepCopyInto(out *RouterStatus) { + *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.ResolvedConfig != nil { + in, out := &in.ResolvedConfig, &out.ResolvedConfig + *out = new(ResolvedRouterConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterStatus. +func (in *RouterStatus) DeepCopy() *RouterStatus { + if in == nil { + return nil + } + out := new(RouterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterTarget) DeepCopyInto(out *RouterTarget) { + *out = *in + if in.RouterRef != nil { + in, out := &in.RouterRef, &out.RouterRef + *out = new(RouterRef) + **out = **in + } + if in.RouterSelector != nil { + in, out := &in.RouterSelector, &out.RouterSelector + *out = new(RouterSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterTarget. +func (in *RouterTarget) DeepCopy() *RouterTarget { + if in == nil { + return nil + } + out := new(RouterTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceEndpoint) DeepCopyInto(out *ServiceEndpoint) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpoint. +func (in *ServiceEndpoint) DeepCopy() *ServiceEndpoint { + if in == nil { + return nil + } + out := new(ServiceEndpoint) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceEndpoint) 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 *ServiceEndpointAttachmentReference) DeepCopyInto(out *ServiceEndpointAttachmentReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpointAttachmentReference. +func (in *ServiceEndpointAttachmentReference) DeepCopy() *ServiceEndpointAttachmentReference { + if in == nil { + return nil + } + out := new(ServiceEndpointAttachmentReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceEndpointList) DeepCopyInto(out *ServiceEndpointList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceEndpoint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpointList. +func (in *ServiceEndpointList) DeepCopy() *ServiceEndpointList { + if in == nil { + return nil + } + out := new(ServiceEndpointList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceEndpointList) 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 *ServiceEndpointReference) DeepCopyInto(out *ServiceEndpointReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpointReference. +func (in *ServiceEndpointReference) DeepCopy() *ServiceEndpointReference { + if in == nil { + return nil + } + out := new(ServiceEndpointReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceEndpointSpec) DeepCopyInto(out *ServiceEndpointSpec) { + *out = *in + if in.AttachmentRef != nil { + in, out := &in.AttachmentRef, &out.AttachmentRef + *out = new(ServiceEndpointAttachmentReference) + **out = **in + } + if in.AttachmentSelector != nil { + in, out := &in.AttachmentSelector, &out.AttachmentSelector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceEndpointSpec. +func (in *ServiceEndpointSpec) DeepCopy() *ServiceEndpointSpec { + if in == nil { + return nil + } + out := new(ServiceEndpointSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteAuthorization) DeepCopyInto(out *ServiceRouteAuthorization) { + *out = *in + in.ValidUntil.DeepCopyInto(&out.ValidUntil) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteAuthorization. +func (in *ServiceRouteAuthorization) DeepCopy() *ServiceRouteAuthorization { + if in == nil { + return nil + } + out := new(ServiceRouteAuthorization) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteFrontend) DeepCopyInto(out *ServiceRouteFrontend) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteFrontend. +func (in *ServiceRouteFrontend) DeepCopy() *ServiceRouteFrontend { + if in == nil { + return nil + } + out := new(ServiceRouteFrontend) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteNodeStatus) DeepCopyInto(out *ServiceRouteNodeStatus) { + *out = *in + in.ValidUntil.DeepCopyInto(&out.ValidUntil) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteNodeStatus. +func (in *ServiceRouteNodeStatus) DeepCopy() *ServiceRouteNodeStatus { + if in == nil { + return nil + } + out := new(ServiceRouteNodeStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRoutePolicy) DeepCopyInto(out *ServiceRoutePolicy) { + *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 ServiceRoutePolicy. +func (in *ServiceRoutePolicy) DeepCopy() *ServiceRoutePolicy { + if in == nil { + return nil + } + out := new(ServiceRoutePolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceRoutePolicy) 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 *ServiceRoutePolicyList) DeepCopyInto(out *ServiceRoutePolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceRoutePolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRoutePolicyList. +func (in *ServiceRoutePolicyList) DeepCopy() *ServiceRoutePolicyList { + if in == nil { + return nil + } + out := new(ServiceRoutePolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceRoutePolicyList) 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 *ServiceRoutePolicySpec) DeepCopyInto(out *ServiceRoutePolicySpec) { + *out = *in + out.ServiceRef = in.ServiceRef + if in.ConsumerVPCRef != nil { + in, out := &in.ConsumerVPCRef, &out.ConsumerVPCRef + *out = new(ServiceRouteVPCReference) + **out = **in + } + if in.Frontend != nil { + in, out := &in.Frontend, &out.Frontend + *out = new(ServiceRouteFrontend) + **out = **in + } + if in.Authorization != nil { + in, out := &in.Authorization, &out.Authorization + *out = new(ServiceRouteAuthorization) + (*in).DeepCopyInto(*out) + } + in.AttachmentSelector.DeepCopyInto(&out.AttachmentSelector) + if in.ProtocolPorts != nil { + in, out := &in.ProtocolPorts, &out.ProtocolPorts + *out = make([]ServiceRouteProtocolPort, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRoutePolicySpec. +func (in *ServiceRoutePolicySpec) DeepCopy() *ServiceRoutePolicySpec { + if in == nil { + return nil + } + out := new(ServiceRoutePolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRoutePolicyStatus) DeepCopyInto(out *ServiceRoutePolicyStatus) { + *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.Nodes != nil { + in, out := &in.Nodes, &out.Nodes + *out = make([]ServiceRouteNodeStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRoutePolicyStatus. +func (in *ServiceRoutePolicyStatus) DeepCopy() *ServiceRoutePolicyStatus { + if in == nil { + return nil + } + out := new(ServiceRoutePolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteProtocolPort) DeepCopyInto(out *ServiceRouteProtocolPort) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteProtocolPort. +func (in *ServiceRouteProtocolPort) DeepCopy() *ServiceRouteProtocolPort { + if in == nil { + return nil + } + out := new(ServiceRouteProtocolPort) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceRouteVPCReference) DeepCopyInto(out *ServiceRouteVPCReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceRouteVPCReference. +func (in *ServiceRouteVPCReference) DeepCopy() *ServiceRouteVPCReference { + if in == nil { + return nil + } + out := new(ServiceRouteVPCReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceVIPBinding) DeepCopyInto(out *ServiceVIPBinding) { + *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 ServiceVIPBinding. +func (in *ServiceVIPBinding) DeepCopy() *ServiceVIPBinding { + if in == nil { + return nil + } + out := new(ServiceVIPBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceVIPBinding) 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 *ServiceVIPBindingList) DeepCopyInto(out *ServiceVIPBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceVIPBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceVIPBindingList. +func (in *ServiceVIPBindingList) DeepCopy() *ServiceVIPBindingList { + if in == nil { + return nil + } + out := new(ServiceVIPBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceVIPBindingList) 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 *ServiceVIPBindingSpec) DeepCopyInto(out *ServiceVIPBindingSpec) { + *out = *in + out.TargetRef = in.TargetRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceVIPBindingSpec. +func (in *ServiceVIPBindingSpec) DeepCopy() *ServiceVIPBindingSpec { + if in == nil { + return nil + } + out := new(ServiceVIPBindingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceVIPBindingStatus) DeepCopyInto(out *ServiceVIPBindingStatus) { + *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 ServiceVIPBindingStatus. +func (in *ServiceVIPBindingStatus) DeepCopy() *ServiceVIPBindingStatus { + if in == nil { + return nil + } + out := new(ServiceVIPBindingStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TargetRef) DeepCopyInto(out *TargetRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetRef. +func (in *TargetRef) DeepCopy() *TargetRef { + if in == nil { + return nil + } + out := new(TargetRef) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/galactic-controller/controller_test.go b/cmd/galactic-controller/controller_test.go index 2dd89444..5b07b377 100644 --- a/cmd/galactic-controller/controller_test.go +++ b/cmd/galactic-controller/controller_test.go @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -31,7 +30,7 @@ import ( ctrlwebhook "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/yaml" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/trafficpolicy/cell" ) @@ -50,15 +49,6 @@ type env struct { done chan error } -func moduleDir(t *testing.T, module string) string { - t.Helper() - out, err := exec.CommandContext(context.Background(), "go", "list", "-m", "-f", "{{.Dir}}", module).Output() - if err != nil { - t.Fatalf("locate module %s: %v", module, err) - } - return strings.TrimSpace(string(out)) -} - func startEnv(t *testing.T) *env { t.Helper() if os.Getenv("KUBEBUILDER_ASSETS") == "" { @@ -70,7 +60,6 @@ func startEnv(t *testing.T) *env { te := &envtest.Environment{ CRDDirectoryPaths: []string{ filepath.Join("..", "..", "config", "crd"), - filepath.Join(moduleDir(t, "go.datum.net/cloud"), "config", "crd"), }, ErrorIfCRDPathMissing: true, WebhookInstallOptions: envtest.WebhookInstallOptions{ diff --git a/cmd/galactic-controller/fabric.go b/cmd/galactic-controller/fabric.go new file mode 100644 index 00000000..bd12bb6f --- /dev/null +++ b/cmd/galactic-controller/fabric.go @@ -0,0 +1,104 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os/signal" + "syscall" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/healthz" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + cloudcontroller "go.datum.net/galactic/internal/cloud/controller" + "go.datum.net/galactic/internal/cloud/ipam" + "go.datum.net/galactic/internal/cloud/leaderelection" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// runFabric runs the central identity allocator using the same Galactic +// controller binary. Its manager lease remains local while its data is on the +// federation hub, so it needs a separate deployment from the cell role. +func runFabric(parent context.Context, o *options) error { + if o.identityClass == "" || o.platformProject == "" || o.ipamKubeconfig == "" || o.hubKubeconfig == "" { + return errors.New("fabric role requires --identity-class, --platform-project, --ipam-kubeconfig and --hub-kubeconfig") + } + if parent == nil { + parent = context.Background() + } + ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) + defer stop() + ctrl.SetLogger(logr.FromSlogHandler(slog.Default().Handler())) + + scheme := runtime.NewScheme() + for _, register := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, cloudv1alpha1.AddToScheme, networkingv1alpha.AddToScheme, + } { + if err := register(scheme); err != nil { + return fmt.Errorf("register fabric API: %w", err) + } + } + hubConfig, err := clientcmd.BuildConfigFromFlags("", o.hubKubeconfig) + if err != nil { + return fmt.Errorf("load federation hub kubeconfig: %w", err) + } + localConfig := ctrl.GetConfigOrDie() + mgr, err := ctrl.NewManager(localConfig, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: o.metricsAddress}, + HealthProbeBindAddress: o.healthAddress, + LeaderElection: o.leaderElect, + LeaderElectionID: "galactic-controller-fabric", + LeaderElectionNamespace: o.namespace, + LeaderElectionConfig: leaderelection.RestConfig(localConfig), + LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + return fmt.Errorf("create fabric manager: %w", err) + } + ipamConfig, err := clientcmd.BuildConfigFromFlags("", o.ipamKubeconfig) + if err != nil { + return fmt.Errorf("load IPAM kubeconfig: %w", err) + } + ipamScheme, err := ipam.Scheme() + if err != nil { + return fmt.Errorf("build IPAM scheme: %w", err) + } + ipamClients, err := ipam.NewClientFactory(ipamConfig, ipamScheme, o.platformProject) + if err != nil { + return fmt.Errorf("build IPAM client factory: %w", err) + } + hub, err := cluster.New(hubConfig, func(options *cluster.Options) { options.Scheme = scheme }) + if err != nil { + return fmt.Errorf("create federation hub client: %w", err) + } + if err := mgr.Add(hub); err != nil { + return fmt.Errorf("add federation hub cache: %w", err) + } + if err := (&cloudcontroller.NetworkFabricIdentityReconciler{ + Networks: hub.GetClient(), Hub: hub.GetClient(), HubCluster: hub, + IPAM: ipamClients, IdentityClass: o.identityClass, IdentityNamespace: o.identityNamespace, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("set up fabric identity controller: %w", err) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return err + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return err + } + slog.Info("starting galactic-controller", "role", RoleFabric) + return mgr.Start(ctx) +} diff --git a/cmd/galactic-controller/main.go b/cmd/galactic-controller/main.go index 89c9fd8b..3b82f106 100644 --- a/cmd/galactic-controller/main.go +++ b/cmd/galactic-controller/main.go @@ -18,25 +18,34 @@ import ( "syscall" "github.com/go-logr/logr" + nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ctrlwebhook "sigs.k8s.io/controller-runtime/pkg/webhook" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + computev1alpha "go.datum.net/compute/api/v1alpha" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" + cloudcontroller "go.datum.net/galactic/internal/cloud/controller" + "go.datum.net/galactic/internal/cloud/galactic" + cloudwebhook "go.datum.net/galactic/internal/cloud/webhook" "go.datum.net/galactic/internal/metadata" "go.datum.net/galactic/internal/trafficpolicy/cell" "go.datum.net/galactic/internal/trafficpolicy/webhook" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" ) -// RoleCell is the only role so far: the controllers that run once per edge -// cell. -const RoleCell = "cell" +const ( + RoleCell = "cell" + RoleFabric = "fabric" +) type options struct { role string @@ -47,6 +56,12 @@ type options struct { webhookCertDir string leaderElect bool controllerUsername string + attachmentMode string + identityClass string + identityNamespace string + platformProject string + ipamKubeconfig string + hubKubeconfig string } func main() { @@ -59,7 +74,7 @@ func newRootCommand() *cobra.Command { o := &options{} cmd := &cobra.Command{ Use: "galactic-controller", - Short: "Run Galactic's cluster-scoped controllers", + Short: "Run Galactic's cell or fabric controllers", Version: metadata.Version, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { @@ -67,7 +82,7 @@ func newRootCommand() *cobra.Command { }, } f := cmd.Flags() - f.StringVar(&o.role, "role", "", "Controllers to run; only \"cell\" exists so far (required)") + f.StringVar(&o.role, "role", "", "Controllers to run: cell or fabric (required)") f.StringVar(&o.namespace, "namespace", envOr("POD_NAMESPACE", "galactic-system"), "Namespace of this pod and its leader election lease (env POD_NAMESPACE)") f.StringVar(&o.metricsAddress, "metrics-bind-address", ":9184", "Address serving /metrics") @@ -79,12 +94,26 @@ func newRootCommand() *cobra.Command { f.StringVar(&o.controllerUsername, "controller-username", "", "Authenticated username this controller runs as, the only one allowed to write the reserved "+ "default policy (default system:serviceaccount::galactic-controller)") + f.StringVar(&o.attachmentMode, "attachment-mode", "", + "Required for cell controllers: Netns, Hypervisor or HypervisorDeclared") + f.StringVar(&o.identityClass, "identity-class", "", "Fabric role: IPClass for globally unique fabric identities") + f.StringVar(&o.identityNamespace, "identity-namespace", "default", "Fabric role: namespace for platform IP claims") + f.StringVar(&o.platformProject, "platform-project", "", "Fabric role: platform project for IPAM") + f.StringVar(&o.ipamKubeconfig, "ipam-kubeconfig", "", "Fabric role: IPAM cluster kubeconfig") + f.StringVar(&o.hubKubeconfig, "hub-kubeconfig", "", "Fabric role: federation hub kubeconfig") return cmd } func run(parent context.Context, o *options) error { + if o.role == RoleFabric { + return runFabric(parent, o) + } if o.role != RoleCell { - return fmt.Errorf("--role must be %q, got %q", RoleCell, o.role) + return fmt.Errorf("--role must be %q or %q, got %q", RoleCell, RoleFabric, o.role) + } + mode, err := parseAttachmentMode(o.attachmentMode) + if err != nil { + return err } if o.controllerUsername == "" { o.controllerUsername = "system:serviceaccount:" + o.namespace + ":galactic-controller" @@ -100,6 +129,10 @@ func run(parent context.Context, o *options) error { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(galacticv1alpha1.AddToScheme(scheme)) utilruntime.Must(cloudv1alpha1.AddToScheme(scheme)) + utilruntime.Must(networkv1alpha1.AddToScheme(scheme)) + utilruntime.Must(networkingv1alpha.AddToScheme(scheme)) + utilruntime.Must(nadv1.AddToScheme(scheme)) + utilruntime.Must(computev1alpha.AddToScheme(scheme)) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, @@ -119,6 +152,9 @@ func run(parent context.Context, o *options) error { if err := Setup(mgr, o.controllerUsername); err != nil { return err } + if err := SetupCloud(mgr, mode); err != nil { + return err + } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { return err } @@ -129,6 +165,61 @@ func run(parent context.Context, o *options) error { return mgr.Start(ctx) } +func parseAttachmentMode(value string) (cloudv1alpha1.VPCAttachmentInterfaceMode, error) { + switch mode := cloudv1alpha1.VPCAttachmentInterfaceMode(value); mode { + case cloudv1alpha1.VPCAttachmentInterfaceModeNetns, + cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, + cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared: + return mode, nil + default: + return "", fmt.Errorf("--attachment-mode must be Netns, Hypervisor or HypervisorDeclared, got %q", value) + } +} + +// SetupCloud registers the cloud cell controllers on the same manager and lease. +func SetupCloud(mgr ctrl.Manager, mode cloudv1alpha1.VPCAttachmentInterfaceMode) error { + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &cloudv1alpha1.VPCAttachment{}, + cloudcontroller.IndexVPCAttachmentIdentity, func(obj client.Object) []string { + attachment, ok := obj.(*cloudv1alpha1.VPCAttachment) + if !ok || attachment.Status.VPC == "" || attachment.Status.VPCAttachment == "" { + return nil + } + identity := galactic.AdvertisementName(attachment.Status.VPC, attachment.Status.VPCAttachment) + if identity == "" { + return nil + } + return []string{identity} + }); err != nil { + return fmt.Errorf("index VPC attachment identity: %w", err) + } + for name, setup := range map[string]func() error{ + "NetworkContext": func() error { + reconciler := &cloudcontroller.NetworkContextReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} + return reconciler.SetupWithManager(mgr) + }, + "Subnet": func() error { + reconciler := &cloudcontroller.SubnetReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} + return reconciler.SetupWithManager(mgr) + }, + "NetworkInterface": func() error { + reconciler := &cloudcontroller.NetworkInterfaceReconciler{ + Client: mgr.GetClient(), Scheme: mgr.GetScheme(), APIReader: mgr.GetAPIReader(), AttachmentMode: mode, + } + return reconciler.SetupWithManager(mgr) + }, + "BGPAdvertisement": func() error { + reconciler := &cloudcontroller.BGPAdvertisementReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} + return reconciler.SetupWithManager(mgr) + }, + } { + if err := setup(); err != nil { + return fmt.Errorf("set up %s controller: %w", name, err) + } + } + (&cloudwebhook.PodInterfaceInjector{Client: mgr.GetClient()}).SetupWithManager(mgr) + return nil +} + // Setup registers the cell role's webhook and reconcilers with mgr. func Setup(mgr ctrl.Manager, controllerUsername string) error { mgr.GetWebhookServer().Register(webhook.Path, &ctrlwebhook.Admission{Handler: &webhook.PolicyValidator{ diff --git a/cmd/galactic-gateway/encapsource.go b/cmd/galactic-gateway/encapsource.go index 29d593c5..c68caa2d 100644 --- a/cmd/galactic-gateway/encapsource.go +++ b/cmd/galactic-gateway/encapsource.go @@ -14,9 +14,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Backoff bounds for waiting on this node's BGPRouter at startup. diff --git a/cmd/galactic-gateway/encapsource_test.go b/cmd/galactic-gateway/encapsource_test.go index 161ca3ca..f4a181a6 100644 --- a/cmd/galactic-gateway/encapsource_test.go +++ b/cmd/galactic-gateway/encapsource_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( diff --git a/cmd/galactic-gateway/main.go b/cmd/galactic-gateway/main.go index 974be15f..87b035d3 100644 --- a/cmd/galactic-gateway/main.go +++ b/cmd/galactic-gateway/main.go @@ -4,7 +4,7 @@ // Command galactic-gateway is the edge XDP gateway control plane. It loads and // attaches the edge eBPF program to a gateway node's underlay-facing uplink and -// drives it through the NetworkGateway and NetworkRule reconcilers. +// drives it through the NetworkGateway and NetworkService reconcilers. // // It is a separate binary from galactic-router so that a crash on either side, // tenant BGP or the gateway engine holding the XDP attachment, no longer takes @@ -23,16 +23,16 @@ import ( ) const ( - networkAPIGroup = "network.datumapis.com" + networkAPIGroup = "galactic.datumapis.com" networkAPIVersion = "v1alpha1" resourceNetworkGateways = "networkgateways" - resourceNetworkRules = "networkrules" + resourceNetworkServices = "networkservices" resourceBGPAdvertisements = "bgpadvertisements" resourceBGPRouters = "bgprouters" resourceBGPVRFInstances = "bgpvrfinstances" - cloudAPIGroup = "cloud.datumapis.com" + cloudAPIGroup = "galactic.datumapis.com" cloudAPIVersion = "v1alpha1" resourceVPCAttachments = "vpcattachments" ) @@ -70,7 +70,7 @@ func checkWatchPermissions(mgr ctrl.Manager) { resource string }{ {group: networkAPIGroup, version: networkAPIVersion, resource: resourceNetworkGateways}, - {group: networkAPIGroup, version: networkAPIVersion, resource: resourceNetworkRules}, + {group: networkAPIGroup, version: networkAPIVersion, resource: resourceNetworkServices}, {group: networkAPIGroup, version: networkAPIVersion, resource: resourceBGPAdvertisements}, {group: networkAPIGroup, version: networkAPIVersion, resource: resourceBGPRouters}, {group: networkAPIGroup, version: networkAPIVersion, resource: resourceBGPVRFInstances}, diff --git a/cmd/galactic-gateway/root.go b/cmd/galactic-gateway/root.go index a93c1cd0..48a600bb 100644 --- a/cmd/galactic-gateway/root.go +++ b/cmd/galactic-gateway/root.go @@ -23,14 +23,14 @@ import ( ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/controller" "go.datum.net/galactic/internal/gateway" "go.datum.net/galactic/internal/metadata" "go.datum.net/galactic/internal/plumbing/ebpf/xdpdispatch" "go.datum.net/galactic/internal/plumbing/sysctl" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( @@ -184,13 +184,13 @@ func runCmd(cfg *config.GatewayConfig) error { // The rule reconciler: finalizer-guarded teardown ordering and the Accepted // condition. - if err := (&controller.NetworkRuleReconciler{ + if err := (&controller.NetworkServiceReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), NodeName: nodeName, APIReader: mgr.GetAPIReader(), }).SetupWithManager(mgr); err != nil { - return fmt.Errorf("setup NetworkRule controller: %w", err) + return fmt.Errorf("setup NetworkService controller: %w", err) } if err := mgr.Start(ctx); err != nil { diff --git a/cmd/galactic-nat/main.go b/cmd/galactic-nat/main.go index 6643304d..0296249d 100644 --- a/cmd/galactic-nat/main.go +++ b/cmd/galactic-nat/main.go @@ -25,7 +25,7 @@ import ( ) const ( - networkAPIGroup = "network.datumapis.com" + networkAPIGroup = "galactic.datumapis.com" networkAPIVersion = "v1alpha1" resourceEgressShards = "egressshards" diff --git a/cmd/galactic-nat/root.go b/cmd/galactic-nat/root.go index 7769803f..a8160574 100644 --- a/cmd/galactic-nat/root.go +++ b/cmd/galactic-nat/root.go @@ -25,12 +25,12 @@ import ( ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/controller" "go.datum.net/galactic/internal/metadata" "go.datum.net/galactic/internal/plumbing/ebpf/xdpdispatch" "go.datum.net/galactic/internal/plumbing/sysctl" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/cmd/galactic-router/main.go b/cmd/galactic-router/main.go index a55bc229..e150a75c 100644 --- a/cmd/galactic-router/main.go +++ b/cmd/galactic-router/main.go @@ -19,7 +19,7 @@ import ( ) const ( - bgpAPIGroup = "network.datumapis.com" + bgpAPIGroup = "galactic.datumapis.com" bgpAPIVersion = "v1alpha1" resourceBGPRouters = "bgprouters" @@ -28,7 +28,7 @@ const ( resourceBGPPolicies = "bgppolicies" resourceBGPVRFInstances = "bgpvrfinstances" resourceServiceVIPBindings = "servicevipbindings" - resourceNetworkRules = "networkrules" + resourceNetworkServices = "networkservices" resourceServiceEndpoints = "serviceendpoints" resourceServiceRoutePolicy = "serviceroutepolicies" resourceVPCAttachments = "vpcattachments" @@ -71,10 +71,10 @@ func checkWatchPermissions(mgr ctrl.Manager, trafficPolicy bool) { {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceBGPPolicies}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceBGPVRFInstances}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceVIPBindings}, - {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceNetworkRules}, + {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceNetworkServices}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceEndpoints}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceRoutePolicy}, - {group: "cloud.datumapis.com", version: "v1alpha1", resource: resourceVPCAttachments}, + {group: "galactic.datumapis.com", version: "v1alpha1", resource: resourceVPCAttachments}, {version: "v1", resource: resourceSecrets}, {version: "v1", resource: resourceNodes}, } diff --git a/cmd/galactic-router/policy.go b/cmd/galactic-router/policy.go index b278a30a..0ae41252 100644 --- a/cmd/galactic-router/policy.go +++ b/cmd/galactic-router/policy.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/tpmap" diff --git a/cmd/galactic-router/root.go b/cmd/galactic-router/root.go index 16c7d9f6..b43994bc 100644 --- a/cmd/galactic-router/root.go +++ b/cmd/galactic-router/root.go @@ -26,7 +26,8 @@ import ( ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/controller" @@ -40,7 +41,6 @@ import ( "go.datum.net/galactic/internal/runtime/gobgp" "go.datum.net/galactic/internal/serviceroute" tpnode "go.datum.net/galactic/internal/trafficpolicy/node" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( @@ -259,7 +259,7 @@ func runCmd(cfg *config.RouterConfig) error { return fmt.Errorf("setup Node controller: %w", err) } - // Register the ServiceVIPBinding controller and the NetworkRule binding + // Register the ServiceVIPBinding controller and the NetworkService binding // writer that generates its bindings. if err := setupVIPBindingControllers(mgr, nodeName, vipTranslationTable, cfg.GCInterval); err != nil { return err @@ -364,8 +364,8 @@ func runGCTicker( } // setupVIPBindingControllers registers the two halves of this node's DSR -// backend side: NetworkRuleBindingReconciler, which writes a ServiceVIPBinding -// for every NetworkRule backend whose VPCAttachment is on this node, and +// backend side: NetworkServiceBindingReconciler, which writes a ServiceVIPBinding +// for every NetworkService backend whose VPCAttachment is on this node, and // ServiceVIPBindingReconciler, which programs vip_xlat_table from those // bindings. It also registers NodeDepartureReconciler, which every sweepInterval // and on every Node deletion clears the conditions and bindings of nodes that @@ -381,12 +381,12 @@ func setupVIPBindingControllers( }).SetupWithManager(mgr); err != nil { return fmt.Errorf("setup ServiceVIPBinding controller: %w", err) } - if err := (&controller.NetworkRuleBindingReconciler{ + if err := (&controller.NetworkServiceBindingReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), NodeName: nodeName, }).SetupWithManager(mgr); err != nil { - return fmt.Errorf("setup NetworkRule binding controller: %w", err) + return fmt.Errorf("setup NetworkService binding controller: %w", err) } if err := (&controller.NodeDepartureReconciler{ Client: mgr.GetClient(), diff --git a/cmd/galactic-vrf/root.go b/cmd/galactic-vrf/root.go index 0cb227f3..5a29513f 100644 --- a/cmd/galactic-vrf/root.go +++ b/cmd/galactic-vrf/root.go @@ -20,10 +20,10 @@ import ( ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/ingresssidecar" "go.datum.net/galactic/internal/metadata" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/config/crd/galactic.datumapis.com_bgpadvertisements.yaml b/config/crd/galactic.datumapis.com_bgpadvertisements.yaml new file mode 100644 index 00000000..47488571 --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgpadvertisements.yaml @@ -0,0 +1,296 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgpadvertisements.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPAdvertisement + listKind: BGPAdvertisementList + plural: bgpadvertisements + shortNames: + - bgpadv + singular: bgpadvertisement + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.routerRef.name + name: ROUTER + type: string + - jsonPath: .spec.addressFamily.afi + name: ADDRESS-FAMILY + type: string + - jsonPath: .status.advertisedPrefixes + name: PREFIXES + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPAdvertisement defines routing information to advertise from a single BGPRouter. + Routes are originated from static Prefixes, redistributed routing table entries, + or local interface/kernel routes. Fan-out via routerSelector is intentionally not + supported to avoid ambiguous prefix attribution across multiple routers. + 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: |- + BGPAdvertisementSpec defines the desired advertisement state. + At least one of prefixes, redistribute, or originateFrom must be specified. + properties: + addressFamily: + description: AddressFamily defines the AFI/SAFI for this advertisement. + properties: + afi: + description: AFI is the address family indicator. + enum: + - ipv4 + - ipv6 + - l2vpn + type: string + safi: + description: SAFI is the subsequent address family indicator. + enum: + - unicast + - evpn + type: string + required: + - afi + - safi + type: object + x-kubernetes-validations: + - message: IPv4 only supports unicast SAFI + rule: 'self.afi == ''ipv4'' ? self.safi == ''unicast'' : true' + - message: IPv6 only supports unicast SAFI + rule: 'self.afi == ''ipv6'' ? self.safi == ''unicast'' : true' + - message: L2VPN only supports evpn SAFI + rule: 'self.afi == ''l2vpn'' ? self.safi == ''evpn'' : true' + communities: + description: |- + Communities is the default list of BGP communities to attach to all advertised prefixes. + Per-prefix communities in Prefixes[n].communities replace this value for individual prefixes. + items: + description: Community is a BGP community in ASN:NN or IP:NN format. + maxLength: 32 + type: string + maxItems: 64 + type: array + x-kubernetes-validations: + - message: community must be in ASN:NN or IP:NN format + rule: self.all(c, c.matches('^[0-9]{1,10}:[0-9]{1,10}$') || c.matches('^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}:[0-9]{1,10}$')) + function: + description: |- + Function is the RFC 8986 SRv6 endpoint behavior applied when this + advertisement originates a compressed uSID. Required when VRFID is set. + enum: + - End.DT4 + - End.DT6 + - End.DT46 + type: string + localPreference: + description: |- + LocalPreference sets the default BGP LOCAL_PREF attribute for all advertised prefixes. + Per-prefix localPreference in Prefixes[n].localPreference overrides this value. + Only meaningful for iBGP sessions. + format: int32 + minimum: 0 + type: integer + originateFrom: + description: |- + OriginateFrom defines how routes are sourced from a local interface or kernel table. + When set, routes are originated from the specified source in addition to any static Prefixes. + At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + properties: + interfaceName: + description: |- + InterfaceName is the name of the interface to originate routes from. + Required when type is "interface". + minLength: 1 + type: string + type: + description: Type is the source from which routes are originated. + enum: + - interface + - kernel + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: interfaceName is required when type is interface + rule: 'self.type == ''interface'' ? has(self.interfaceName) : true' + - message: interfaceName must not be set when type is kernel + rule: 'self.type == ''kernel'' ? !has(self.interfaceName) : true' + policyRef: + description: |- + PolicyRef references a BGPPolicy to apply as a conditional filter before advertisement. + Only routes that match the policy are originated. + properties: + name: + description: Name is the name of the BGPPolicy within the same + namespace. + minLength: 1 + type: string + required: + - name + type: object + prefixes: + description: |- + Prefixes is the list of CIDR prefixes to advertise. + At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + items: + description: Prefix is an IPv4 or IPv6 CIDR prefix. + maxLength: 64 + type: string + x-kubernetes-validations: + - message: must be a valid IPv4 or IPv6 CIDR + rule: self.matches('^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]{1,2}$|^[0-9a-fA-F:]+::?[0-9a-fA-F:]+/[0-9]{1,3}$') + maxItems: 256 + type: array + x-kubernetes-list-type: set + redistribute: + description: |- + Redistribute defines routing table sources to redistribute into BGP. + Routes matching the source type are originated without requiring explicit CIDR entries. + At least one of Prefixes, Redistribute, or OriginateFrom must be specified. + items: + description: RedistributeSource is a local routing table source + to redistribute into BGP. + enum: + - static + - connected + - kernel + type: string + maxItems: 3 + type: array + x-kubernetes-list-type: set + routerRef: + description: RouterRef targets a single BGPRouter by name. + properties: + name: + description: Name is the name of the BGPRouter. + minLength: 1 + type: string + required: + - name + type: object + vrfID: + description: |- + VRFID is the 16-bit PoP-local VRF identifier this advertisement's SRv6 + SID resolves into, per RFC 9800 uSID Argument addressing. Required when + Function is set. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - addressFamily + - routerRef + type: object + x-kubernetes-validations: + - message: at least one of prefixes, redistribute, or originateFrom must + be specified + rule: (has(self.prefixes) && self.prefixes.size() > 0) || (has(self.redistribute) + && self.redistribute.size() > 0) || has(self.originateFrom) + status: + description: BGPAdvertisementStatus defines the observed state of BGPAdvertisement. + properties: + advertisedPrefixes: + description: AdvertisedPrefixes is the count of prefixes currently + being originated. + format: int32 + type: integer + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_bgpcommunitysets.yaml b/config/crd/galactic.datumapis.com_bgpcommunitysets.yaml new file mode 100644 index 00000000..67cad07e --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgpcommunitysets.yaml @@ -0,0 +1,166 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgpcommunitysets.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPCommunitySet + listKind: BGPCommunitySetList + plural: bgpcommunitysets + shortNames: + - bgpcs + singular: bgpcommunityset + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: TYPE + type: string + - jsonPath: .spec.entries + name: ENTRIES + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPCommunitySet defines a named list of BGP community values used by BGPPolicy + terms to match or set communities on routes. It is referenced by name from + BGPPolicyMatch.communitySetRef. + + A single BGPCommunitySet holds either standard or large communities — the type + is declared upfront and all entries are validated against it via CEL. + 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: BGPCommunitySetSpec defines the desired state of BGPCommunitySet. + properties: + entries: + description: |- + Entries is the list of community values. + Standard: ASN:NN, IP:NN, or well-known names (graceful-shutdown, no-export, + no-advertise, blackhole). + Large: ASN:NN:NN. + items: + maxLength: 32 + type: string + maxItems: 256 + minItems: 1 + type: array + type: + description: Type is the community format for all entries in this + set. + enum: + - standard + - large + type: string + required: + - entries + - type + type: object + x-kubernetes-validations: + - message: standard entries must be ASN:NN, IP:NN, or a well-known name + rule: 'self.type == ''standard'' ? self.entries.all(e, e.matches(''^[0-9]{1,10}:[0-9]{1,10}$'') + || e.matches(''^[0-9]{1,3}[.][0-9]{1,3}[.][0-9]{1,3}[.][0-9]{1,3}:[0-9]{1,10}$'') + || e == ''graceful-shutdown'' || e == ''no-export'' || e == ''no-advertise'' + || e == ''blackhole'') : true' + - message: large entries must be ASN:NN:NN + rule: 'self.type == ''large'' ? self.entries.all(e, e.matches(''^[0-9]{1,10}:[0-9]{1,10}:[0-9]{1,10}$'')) + : true' + status: + description: BGPCommunitySetStatus defines the observed state of BGPCommunitySet. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_bgppeers.yaml b/config/crd/galactic.datumapis.com_bgppeers.yaml new file mode 100644 index 00000000..93a6a759 --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgppeers.yaml @@ -0,0 +1,466 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgppeers.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPPeer + listKind: BGPPeerList + plural: bgppeers + shortNames: + - bgppr + singular: bgppeer + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.routerRef.name + name: ROUTER + type: string + - jsonPath: .spec.address + name: PEER-ADDRESS + type: string + - jsonPath: .spec.peerASN + name: PEER-ASN + type: integer + - jsonPath: .status.sessionState + name: STATE + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPPeer defines a BGP session to a remote peer. It binds to one or more + BGPRouter instances via routerRef or routerSelector. + 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: BGPPeerSpec defines the desired state of BGPPeer. + properties: + address: + description: Address is the remote peer's IPv4 or IPv6 address. + minLength: 1 + type: string + x-kubernetes-validations: + - message: address must be a valid IPv4 or IPv6 address + rule: isIP(self) + addressFamilies: + description: AddressFamilies defines the address families negotiated + on this session. + items: + description: |- + AddressFamily is a BGP address family expressed as an AFI/SAFI pair. + Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. + properties: + afi: + description: AFI is the address family indicator. + enum: + - ipv4 + - ipv6 + - l2vpn + type: string + safi: + description: SAFI is the subsequent address family indicator. + enum: + - unicast + - evpn + type: string + required: + - afi + - safi + type: object + x-kubernetes-validations: + - message: IPv4 only supports unicast SAFI + rule: 'self.afi == ''ipv4'' ? self.safi == ''unicast'' : true' + - message: IPv6 only supports unicast SAFI + rule: 'self.afi == ''ipv6'' ? self.safi == ''unicast'' : true' + - message: L2VPN only supports evpn SAFI + rule: 'self.afi == ''l2vpn'' ? self.safi == ''evpn'' : true' + minItems: 1 + type: array + allowASIn: + description: |- + AllowASIn allows routes that contain the local ASN in the AS_PATH + to be accepted from this peer. The value specifies the maximum number + of times the local ASN may appear. Range: 1-10. + format: int32 + maximum: 10 + minimum: 1 + type: integer + authSecretRef: + description: |- + AuthSecretRef references a Secret in the same namespace containing the + MD5 TCP authentication password under the key "password". + properties: + name: + description: Name is the name of the Secret. + minLength: 1 + type: string + required: + - name + type: object + authentication: + description: |- + Authentication configures peer authentication. + AuthSecretRef takes precedence when both are set. + properties: + password: + description: |- + Password is a plain-text authentication password. + Prefer AuthSecretRef for production deployments. + type: string + type: object + bfd: + description: |- + BFD configures Bidirectional Forwarding Detection for fast failure + detection (sub-500ms). Without BFD, convergence relies on BGP hold + timers (default 90s). + properties: + detectMultiplier: + description: |- + DetectMultiplier is the number of missed packets before declaring the + BFD session down. Default is 3. + format: int32 + maximum: 50 + minimum: 3 + type: integer + enabled: + description: Enabled indicates whether BFD is enabled for this + peer. + type: boolean + minimumRx: + description: |- + MinimumRx is the minimum reception interval in microseconds. + Defaults to 300000 (300ms). + format: int32 + type: integer + minimumTx: + description: |- + MinimumTx is the minimum transmission interval in microseconds. + FRR / GoBGP use microseconds — the controller converts to milliseconds + when programming the runtime. Defaults to 300000 (300ms). + format: int32 + type: integer + multiHop: + description: |- + MultiHop enables BFD for eBGP multi-hop sessions (RFC 5883). + Must be true when ebgpMultiHop is true. + type: boolean + type: object + defaultOriginRoute: + description: |- + DefaultOriginRoute controls default route origination for this peer. + "igp" originates a default route with IGP origin. + "egp" originates a default route with EGP origin. + "incomplete" originates a default route with incomplete origin. + Empty or unset means no default route origination. + enum: + - igp + - egp + - incomplete + type: string + description: + description: Description is a human-readable label for this peer (e.g., + "spine-1"). + type: string + ebgpMultiHop: + description: |- + EbgpMultiHop enables eBGP multihop when the peer is not directly connected. + When true, the TTL for BGP packets is set to 64 (vs. 1 for direct). + type: boolean + gracefulRestart: + description: |- + GracefulRestart configures BGP graceful restart for this peer. + Prevents route flapping during control-plane restarts. + properties: + enabled: + description: Enabled indicates whether graceful restart is enabled + for this peer. + type: boolean + restartTime: + description: |- + RestartTime is the maximum time (in seconds) the peer expects the + local system to complete a graceful restart. Range: 1-1200. + Default: 120 seconds. + format: int32 + maximum: 1200 + minimum: 1 + type: integer + type: object + holdTime: + description: |- + HoldTime is the BGP hold timer. Must be 0 (disabled) or >= 3s. + Defaults to 90s if unset. + type: string + x-kubernetes-validations: + - message: holdTime must be 0 or >= 3s + rule: duration(self) == duration('0s') || duration(self) >= duration('3s') + keepaliveTime: + description: |- + KeepaliveTime is the BGP keepalive interval. Must be <= HoldTime / 3. + Defaults to 30s if unset. Maximum is 30s; the controller enforces + the keepalive <= holdTime / 3 constraint with an Accepted=False condition. + type: string + x-kubernetes-validations: + - message: keepaliveTime must be 0 or <= 30s + rule: duration(self) == duration('0s') || duration(self) <= duration('30s') + maximumPrefix: + description: |- + MaximumPrefix defines the maximum number of routes accepted from this + peer before triggering a shutdown action. + properties: + limit: + description: Limit is the maximum number of routes accepted from + this peer. + format: int32 + minimum: 1 + type: integer + shutdownAction: + description: ShutdownAction is the action taken when the Limit + is exceeded. + enum: + - warning-only + - restart + - shutdown + type: string + thresholdPercent: + description: |- + ThresholdPercent is the percentage threshold at which a warning is + logged (e.g., 75 means warn at 75% of Limit). Range: 1-100. + format: int32 + maximum: 100 + minimum: 1 + type: integer + required: + - limit + - shutdownAction + type: object + multiSession: + description: |- + MultiSession enables BFD over MP-BGP (RFC 8935). When true, each + AFI/SAFI negotiates its own BFD session independently. When false + (default), a single BFD session covers all AFI/SAFIs. + type: boolean + nextHopSelf: + description: |- + NextHopSelf controls next-hop behavior for iBGP sessions. + When true, the local router's IP is used as next-hop for all + advertised routes. Common in EVPN iBGP peering. + type: boolean + peerASN: + description: PeerASN is the remote AS number. + format: int64 + minimum: 1 + type: integer + remotePort: + description: |- + RemotePort is the TCP port used to establish the BGP session with + this peer. Defaults to 179 (the IANA-assigned BGP port) if unset. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + removePrivateAS: + description: |- + RemovePrivateAS strips private AS numbers (64512-65535, 4200000000-4294967294) + from the AS path on eBGP export. If set to a non-zero value, private ASNs + are replaced with the specified value before export. + format: int32 + minimum: 0 + type: integer + routeMapIn: + description: |- + RouteMapIn is the name of a BGPPolicy term set applied to routes + received from this peer (import direction). The name must match + a BGPPolicy resource in the same namespace. + type: string + routeMapOut: + description: |- + RouteMapOut is the name of a BGPPolicy term set applied to routes + advertised to this peer (export direction). The name must match + a BGPPolicy resource in the same namespace. + type: string + routerRef: + description: |- + RouterRef targets a single BGPRouter by name. + Mutually exclusive with routerSelector. + properties: + name: + description: Name is the name of the BGPRouter. + minLength: 1 + type: string + required: + - name + type: object + routerSelector: + description: |- + RouterSelector targets one or more BGPRouters by label. + Mutually exclusive with routerRef. + properties: + matchExpressions: + description: MatchExpressions is a list of label selector requirements. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: MatchLabels is a map of key/value label pairs to + match. + type: object + type: object + sendCommunity: + description: |- + SendCommunity controls which BGP community attributes are propagated + to this peer. When unset, communities are not sent. + enum: + - standard + - extended + - large + - both + type: string + updateSource: + description: |- + UpdateSource is the local IP address or interface name used as the + source for the BGP TCP session. Useful when the local router has + multiple addresses or when pinning to a specific loopback. + type: string + required: + - address + - addressFamilies + - peerASN + type: object + x-kubernetes-validations: + - message: Exactly one of routerRef or routerSelector must be set + rule: has(self.routerRef) != has(self.routerSelector) + status: + description: BGPPeerStatus defines the observed state of BGPPeer. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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 + lastEstablishedTime: + description: LastEstablishedTime is the timestamp of the most recent + Established transition. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the .metadata.generation this status + was computed from. + format: int64 + type: integer + sessionState: + description: SessionState is the current BGP FSM state of this session. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/galactic.datumapis.com_bgppolicies.yaml b/config/crd/galactic.datumapis.com_bgppolicies.yaml new file mode 100644 index 00000000..bd758be8 --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgppolicies.yaml @@ -0,0 +1,543 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgppolicies.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPPolicy + listKind: BGPPolicyList + plural: bgppolicies + shortNames: + - bgpp + singular: bgppolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.direction + name: DIRECTION + type: string + - jsonPath: .spec.terms + name: TERMS + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPPolicy defines composable, ordered routing policy statements applied to a + BGPRouter in a specific direction (import or export). It binds to one or more + BGPRouter instances via routerRef or routerSelector. + 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: BGPPolicySpec defines the desired route policy state. + properties: + direction: + description: 'Direction is the policy direction: import or export.' + enum: + - import + - export + type: string + routerRef: + description: |- + RouterRef targets a single BGPRouter by name. + Mutually exclusive with routerSelector. + properties: + name: + description: Name is the name of the BGPRouter. + minLength: 1 + type: string + required: + - name + type: object + routerSelector: + description: |- + RouterSelector targets one or more BGPRouters by label. + Mutually exclusive with routerRef. + properties: + matchExpressions: + description: MatchExpressions is a list of label selector requirements. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: MatchLabels is a map of key/value label pairs to + match. + type: object + type: object + terms: + description: |- + Terms is the ordered list of policy statements. + Evaluated from lowest to highest sequence number. + items: + description: BGPPolicyTerm is a single ordered policy statement + with match conditions and an action. + properties: + action: + description: Action is the disposition when this term matches. + enum: + - permit + - deny + type: string + match: + description: Match defines the conditions under which this term + fires. + properties: + addressFamilies: + description: |- + AddressFamilies constrains the match to specific AFI/SAFI combinations. + If empty, all address families are matched. + items: + description: |- + AddressFamily is a BGP address family expressed as an AFI/SAFI pair. + Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. + properties: + afi: + description: AFI is the address family indicator. + enum: + - ipv4 + - ipv6 + - l2vpn + type: string + safi: + description: SAFI is the subsequent address family + indicator. + enum: + - unicast + - evpn + type: string + required: + - afi + - safi + type: object + x-kubernetes-validations: + - message: IPv4 only supports unicast SAFI + rule: 'self.afi == ''ipv4'' ? self.safi == ''unicast'' + : true' + - message: IPv6 only supports unicast SAFI + rule: 'self.afi == ''ipv6'' ? self.safi == ''unicast'' + : true' + - message: L2VPN only supports evpn SAFI + rule: 'self.afi == ''l2vpn'' ? self.safi == ''evpn'' + : true' + maxItems: 8 + type: array + any: + description: Any matches all routes. When true, all other + match fields are ignored. + type: boolean + asPathFilter: + description: |- + ASPathFilter matches routes by AS path using a regex pattern. + The pattern is matched against the full AS path string (space-separated ASNs). + properties: + matchType: + default: contains + description: |- + MatchType determines whether the pattern must match the full path + or can match a substring. Default: "contains". + enum: + - full + - contains + type: string + pattern: + description: |- + Pattern is a regular expression matched against the AS path. + The AS path is represented as a space-separated string of ASNs. + maxLength: 256 + minLength: 1 + type: string + required: + - pattern + type: object + communityMatch: + description: |- + CommunityMatch matches routes by BGP community. + Each entry is a community string in ASN:NN or IP:NN format. + items: + maxLength: 32 + type: string + maxItems: 32 + type: array + x-kubernetes-validations: + - message: each communityMatch entry must be in ASN:NN or + IP:NN format + rule: self.all(c, c.matches('^[0-9]{1,10}:[0-9]{1,10}$') + || c.matches('^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}:[0-9]{1,10}$')) + evpnRouteType: + description: |- + EVPNRouteType matches specific EVPN route types. + Only meaningful when l2vpn/evpn address family is configured. + items: + description: EVPNRouteType is an EVPN route type per RFC + 7432. + enum: + - inclusiveMulticastEthernetTag + - macIPAdvertisement + - iPPrefixAdvertisement + - stickyMACAddress + - iPv6PrefixAdvertisement + type: string + maxItems: 5 + type: array + ipPrefix: + description: IPPrefix matches routes by exact IP prefix + (CIDR notation). + maxLength: 43 + type: string + x-kubernetes-validations: + - message: ipPrefix must be a valid IPv4 or IPv6 CIDR + rule: isCIDR(self) + localPreference: + description: LocalPreference matches routes by BGP LOCAL_PREF + value. + format: int32 + minimum: 0 + type: integer + macAddress: + description: |- + MACAddress matches MAC-IP routes (EVPN Type-2) by MAC address. + Format: colon-separated hex bytes (e.g., "aa:bb:cc:dd:ee:ff"). + pattern: ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$ + type: string + med: + description: MED matches routes by Multi-Exit Discriminator + value. + format: int32 + minimum: 0 + type: integer + prefixList: + description: |- + PrefixList constrains the match to routes whose prefix matches one of + the given CIDR blocks. Each entry must be a valid IPv4 or IPv6 CIDR. + items: + maxLength: 43 + type: string + maxItems: 256 + type: array + x-kubernetes-validations: + - message: each prefixList entry must be a valid IPv4 or + IPv6 CIDR + rule: self.all(p, isCIDR(p)) + vni: + description: |- + VNI matches routes by VNI (VXLAN Network Identifier). + Range: 0–16777215 (24-bit VNI). + format: int32 + maximum: 16777215 + minimum: 0 + type: integer + type: object + x-kubernetes-validations: + - message: prefixList must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.prefixList) + || self.prefixList.size() == 0' + - message: asPathFilter must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.asPathFilter) + || self.asPathFilter.pattern.size() == 0' + - message: communityMatch must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.communityMatch) + || self.communityMatch.size() == 0' + - message: evpnRouteType must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.evpnRouteType) + || self.evpnRouteType.size() == 0' + - message: vni must be unset when any is true + rule: '!has(self.any) || !self.any || !has(self.vni) || self.vni + == 0' + - message: macAddress must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.macAddress) + || self.macAddress.size() == 0' + - message: ipPrefix must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.ipPrefix) + || self.ipPrefix.size() == 0' + - message: localPreference must be unset when any is true + rule: '!has(self.any) || !self.any || !has(self.localPreference) + || self.localPreference == 0' + - message: med must be unset when any is true + rule: '!has(self.any) || !self.any || !has(self.med) || self.med + == 0' + - message: addressFamilies must be empty when any is true + rule: '!has(self.any) || !self.any || !has(self.addressFamilies) + || self.addressFamilies.size() == 0' + sequence: + description: |- + Sequence is the evaluation order. Lower values are evaluated first. + Must be unique within the policy. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + set: + description: |- + Set defines mutations applied when action is "permit". + Must not be set when action is "deny". + properties: + asPath: + description: AsPath manipulates the AS path (prepend or + replace). + properties: + asn: + description: |- + ASN is the AS number to prepend (used when prepend is set). + Defaults to the local ASN if not specified. + format: int64 + type: integer + prepend: + description: Prepend adds an ASN to the AS path N times. + format: int32 + maximum: 10 + minimum: 1 + type: integer + replace: + description: |- + Replace replaces the entire AS path with the given list. + Mutually exclusive with prepend. + items: + format: int64 + type: integer + maxItems: 32 + type: array + type: object + x-kubernetes-validations: + - message: prepend and replace are mutually exclusive + rule: '!has(self.prepend) || !has(self.replace) || self.replace.size() + == 0' + color: + description: Color sets the SRv6 policy color for path selection. + format: int32 + minimum: 0 + type: integer + communities: + description: Communities defines community add/remove operations. + properties: + add: + description: Add is a list of communities to attach. + items: + maxLength: 24 + type: string + maxItems: 32 + type: array + remove: + description: Remove is a list of communities to strip. + items: + maxLength: 24 + type: string + maxItems: 32 + type: array + type: object + extCommunities: + description: |- + ExtCommunities defines extended community add/remove operations. + Each entry must be in a valid extended community format (ASN:NN, IP:NN, + or type-specific like "rt:65000:100"). + properties: + add: + description: Add is a list of extended communities to + attach. + items: + maxLength: 64 + type: string + maxItems: 32 + type: array + x-kubernetes-validations: + - message: each extCommunity entry must contain only + valid characters (digits, letters, colons, dots) + rule: self.all(c, c.matches('^[0-9a-fA-F:.]+$')) + remove: + description: Remove is a list of extended communities + to strip. + items: + maxLength: 64 + type: string + maxItems: 32 + type: array + x-kubernetes-validations: + - message: each extCommunity entry must contain only + valid characters (digits, letters, colons, dots) + rule: self.all(c, c.matches('^[0-9a-fA-F:.]+$')) + type: object + localPreference: + description: |- + LocalPreference sets the LOCAL_PREF attribute. + Only meaningful on import (iBGP) or export to iBGP peers. + format: int32 + minimum: 0 + type: integer + metric: + description: Metric sets the MED (Multi-Exit Discriminator) + attribute. + format: int32 + minimum: 0 + type: integer + nextHop: + description: NextHop overrides the next-hop attribute. + properties: + address: + description: |- + Address sets the next-hop to a specific IP address. + Mutually exclusive with self. + maxLength: 45 + type: string + self: + description: Self sets the next-hop to the local router's + BGP peer address. + type: boolean + type: object + x-kubernetes-validations: + - message: self and address are mutually exclusive + rule: '!has(self.self) || !self.self || !has(self.address) + || self.address.size() == 0' + origin: + description: Origin sets the BGP origin attribute. + enum: + - igp + - egp + - incomplete + type: string + type: object + required: + - action + - match + - sequence + type: object + x-kubernetes-validations: + - message: set actions are not permitted on deny terms + rule: 'self.action == ''deny'' ? !has(self.set) : true' + maxItems: 32 + minItems: 1 + type: array + required: + - direction + - terms + type: object + x-kubernetes-validations: + - message: Term sequence numbers must be unique + rule: self.terms.all(t1, self.terms.filter(t2, t2.sequence == t1.sequence).size() + == 1) + - message: Exactly one of routerRef or routerSelector must be set + rule: has(self.routerRef) != has(self.routerSelector) + status: + description: BGPPolicyStatus defines the observed state of BGPPolicy. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_bgpprefixlists.yaml b/config/crd/galactic.datumapis.com_bgpprefixlists.yaml new file mode 100644 index 00000000..36958f8e --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgpprefixlists.yaml @@ -0,0 +1,194 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgpprefixlists.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPPrefixList + listKind: BGPPrefixListList + plural: bgpprefixlists + shortNames: + - bgppfx + singular: bgpprefixlist + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.entries + name: ENTRIES + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPPrefixList defines a named list of prefix-match entries used by BGPPolicy + terms to filter routes by destination prefix. It is referenced by name from + BGPPolicyMatch.prefixListRef. + + A single BGPPrefixList holds entries for either IPv4 or IPv6 — the address + family is inferred from the CIDR family of the first entry and validated + consistent across all entries via CEL. + 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: BGPPrefixListSpec defines the desired state of BGPPrefixList. + properties: + entries: + description: Entries is the ordered list of prefix-match entries. + items: + description: |- + BGPPrefixListEntry defines a single entry in a named BGP prefix-list. + Entries are evaluated in ascending sequence order. + properties: + action: + description: Action is the disposition when a route's prefix + matches this entry. + enum: + - permit + - deny + type: string + ge: + description: |- + GE is the minimum prefix-length to match (greater-than-or-equal). + For example, "10.0.0.0/8 ge 16" matches 10.0.0.0/16 through 10.0.0.0/32. + When unset, the prefix-length of Prefix is the lower bound. + format: int32 + maximum: 32 + minimum: 0 + type: integer + le: + description: |- + LE is the maximum prefix-length to match (less-than-or-equal). + For example, "10.0.0.0/8 le 24" matches 10.0.0.0/8 through 10.0.0.0/24. + When unset, the prefix-length of Prefix is the upper bound. + format: int32 + maximum: 32 + minimum: 0 + type: integer + prefix: + description: Prefix is the IPv4 or IPv6 CIDR to match against. + maxLength: 43 + minLength: 1 + type: string + x-kubernetes-validations: + - message: prefix must be a valid IPv4 or IPv6 CIDR + rule: isCIDR(self) + sequence: + description: |- + Sequence is the evaluation order for this entry. Entries with lower + sequence numbers are evaluated first. Must be unique within the list. + format: int32 + maximum: 4294967295 + minimum: 5 + type: integer + required: + - action + - prefix + - sequence + type: object + maxItems: 1024 + minItems: 1 + type: array + required: + - entries + type: object + x-kubernetes-validations: + - message: all entries must be the same address family (IPv4 or IPv6) + rule: self.entries.all(e, e.prefix.contains(':') == self.entries[0].prefix.contains(':')) + status: + description: BGPPrefixListStatus defines the observed state of BGPPrefixList. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_bgprouters.yaml b/config/crd/galactic.datumapis.com_bgprouters.yaml new file mode 100644 index 00000000..6cf81204 --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgprouters.yaml @@ -0,0 +1,254 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgprouters.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPRouter + listKind: BGPRouterList + plural: bgprouters + shortNames: + - bgpr + singular: bgprouter + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRef.name + name: TARGET + type: string + - jsonPath: .spec.localASN + name: ASN + type: integer + - jsonPath: .spec.routerID + name: ROUTER-ID + type: string + - jsonPath: .status.phase + name: PHASE + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPRouter defines a logical BGP routing context. It abstracts a processing + instance bound to a specific execution context (e.g., a VRF or network + namespace) on a target node and acts as the primary ownership boundary for + BGPPeer, BGPAdvertisement, and BGPPolicy resources. + 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: BGPRouterSpec defines the desired state of a BGPRouter. + properties: + addressFamilies: + description: AddressFamilies defines the address families this router + activates. + items: + description: |- + AddressFamily is a BGP address family expressed as an AFI/SAFI pair. + Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. + properties: + afi: + description: AFI is the address family indicator. + enum: + - ipv4 + - ipv6 + - l2vpn + type: string + safi: + description: SAFI is the subsequent address family indicator. + enum: + - unicast + - evpn + type: string + required: + - afi + - safi + type: object + x-kubernetes-validations: + - message: IPv4 only supports unicast SAFI + rule: 'self.afi == ''ipv4'' ? self.safi == ''unicast'' : true' + - message: IPv6 only supports unicast SAFI + rule: 'self.afi == ''ipv6'' ? self.safi == ''unicast'' : true' + - message: L2VPN only supports evpn SAFI + rule: 'self.afi == ''l2vpn'' ? self.safi == ''evpn'' : true' + minItems: 1 + type: array + listenPort: + description: |- + ListenPort is the TCP port this router listens on for incoming BGP + peer connections. Defaults to 179 (the IANA-assigned BGP port) if unset. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + localASN: + description: |- + LocalASN is the BGP Autonomous System Number for this router. + Must be a valid 2-byte or 4-byte ASN per RFC 6793. + format: int64 + minimum: 1 + type: integer + nodeID: + description: |- + NodeID is this router's 16-bit slot within its PoP's SRv6Locator block, + used for RFC 9800 NEXT-CSID compression. Unique within the PoP. + The uSID carrier format reserves 0x0000 and 0xE000-0xFFFF (the latter is + Function space, not Node-ID space), leaving 0x0001-0xDFFF assignable. + format: int32 + maximum: 57343 + minimum: 1 + type: integer + routerID: + description: |- + RouterID is a unique 32-bit identifier expressed in IPv4 dotted-decimal notation. + In an IPv6-only underlay this is a logical identifier only. + format: ipv4 + type: string + srv6Locator: + description: |- + SRv6Locator is the SRv6 locator block this router owns, expressed as an + IPv6 CIDR (e.g. "2001:db8:ff01::/48"). Individual SRv6 endpoint SIDs are + host addresses within this block. + type: string + x-kubernetes-validations: + - message: srv6Locator must be a valid CIDR + rule: isCIDR(self) + - message: srv6Locator must be an IPv6 CIDR + rule: self.contains(':') + targetRef: + description: TargetRef identifies the Node this router executes on. + properties: + kind: + description: Kind is the target resource kind (e.g. Node). + minLength: 1 + type: string + name: + description: Name is the name of the target resource. + minLength: 1 + type: string + required: + - kind + - name + type: object + required: + - addressFamilies + - localASN + - routerID + - targetRef + type: object + status: + description: BGPRouterStatus defines the observed state of a BGPRouter. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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 + peers: + description: Peers summarizes peer session counts. + properties: + established: + description: Established is the count of peers currently in the + Established state. + format: int32 + type: integer + total: + description: Total is the total number of configured peers. + format: int32 + type: integer + required: + - established + - total + type: object + phase: + description: Phase is the high-level lifecycle state of this router. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/galactic.datumapis.com_bgpvrfinstances.yaml b/config/crd/galactic.datumapis.com_bgpvrfinstances.yaml new file mode 100644 index 00000000..765ce4af --- /dev/null +++ b/config/crd/galactic.datumapis.com_bgpvrfinstances.yaml @@ -0,0 +1,409 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: bgpvrfinstances.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: BGPVRFInstance + listKind: BGPVRFInstanceList + plural: bgpvrfinstances + shortNames: + - bgpvrf + singular: bgpvrfinstance + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.vrfID + name: VRF-ID + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BGPVRFInstance configures an L2VPN EVPN VRF on matched BGPRouters. + The referenced BGPRouter must have l2vpn-evpn in its addressFamilies. + 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: BGPVRFInstanceSpec defines the desired VRF configuration. + properties: + exportRouteTargets: + description: |- + ExportRouteTargets is the list of BGP extended community route targets + attached to routes exported from this VRF. + items: + description: RouteTarget is a BGP extended community in "ASN:NN" + or "IP:NN" format. + properties: + value: + description: |- + Value is the route target extended community string. + Format: "ASN:NN" (e.g. "65000:100") or "IP:NN" (e.g. "192.0.2.1:100"). + maxLength: 21 + minLength: 1 + type: string + required: + - value + type: object + x-kubernetes-validations: + - message: value must be in ASN:NN or IP:NN format + rule: self.value.matches('^([0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}|[0-9]{1,10}):[0-9]{1,10}$') + maxItems: 32 + minItems: 1 + type: array + importRouteTargets: + description: |- + ImportRouteTargets is the list of BGP extended community route targets + used to import routes into this VRF. + items: + description: RouteTarget is a BGP extended community in "ASN:NN" + or "IP:NN" format. + properties: + value: + description: |- + Value is the route target extended community string. + Format: "ASN:NN" (e.g. "65000:100") or "IP:NN" (e.g. "192.0.2.1:100"). + maxLength: 21 + minLength: 1 + type: string + required: + - value + type: object + x-kubernetes-validations: + - message: value must be in ASN:NN or IP:NN format + rule: self.value.matches('^([0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}[.][0-9]{1,9}|[0-9]{1,10}):[0-9]{1,10}$') + maxItems: 32 + minItems: 1 + type: array + nptv6: + description: |- + NPTv6 configures stateless RFC 6296 Network Prefix Translation for + this VRF: backends presenting an address within ULAPrefix are + translated, checksum-neutrally and bidirectionally, to the + corresponding address in PublicPrefix. Unset means this VRF's traffic + crosses the SRv6 fabric with its own ULA source/destination + unmodified — the common case. Keyed by this VRFID, never by address, + specifically so two VRFs may configure the identical ULAPrefix (e.g. + both using the same private range) without collision — each VRF's + mapping is independent, looked up only after this VRF's own identity + is already resolved from the SRv6 uSID Argument, never derived from + address content alone. + properties: + publicPrefix: + description: |- + PublicPrefix is the externally-routable IPv6 prefix ULAPrefix + translates to/from, same prefix length as ULAPrefix. + type: string + x-kubernetes-validations: + - message: publicPrefix must be a valid CIDR + rule: isCIDR(self) + - message: publicPrefix must be an IPv6 CIDR + rule: self.contains(':') + ulaPrefix: + description: |- + ULAPrefix is this VRF's tenant-facing IPv6 ULA prefix (e.g. + "fd20:60::/64"), as presented by backends inside the VRF. + type: string + x-kubernetes-validations: + - message: ulaPrefix must be a valid CIDR + rule: isCIDR(self) + - message: ulaPrefix must be an IPv6 CIDR + rule: self.contains(':') + required: + - publicPrefix + - ulaPrefix + type: object + x-kubernetes-validations: + - message: ulaPrefix and publicPrefix must share the same prefix length + rule: self.ulaPrefix.split('/')[1] == self.publicPrefix.split('/')[1] + routerRef: + description: |- + RouterRef targets a single BGPRouter by name. + Mutually exclusive with routerSelector. + properties: + name: + description: Name is the name of the BGPRouter. + minLength: 1 + type: string + required: + - name + type: object + routerSelector: + description: |- + RouterSelector targets one or more BGPRouters by label. + Mutually exclusive with routerRef. + properties: + matchExpressions: + description: MatchExpressions is a list of label selector requirements. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: MatchLabels is a map of key/value label pairs to + match. + type: object + type: object + vrfID: + description: |- + VRFID is the 16-bit PoP-local VRF identifier used for RFC 9800 uSID + Argument addressing and to derive the RFC 4364 Type 1 Route + Distinguisher ("routerID:vrfID"). Unique per (VPC, PoP). Value 0 is + reserved. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - exportRouteTargets + - importRouteTargets + - vrfID + type: object + x-kubernetes-validations: + - message: Exactly one of routerRef or routerSelector must be set + rule: has(self.routerRef) != has(self.routerSelector) + status: + description: BGPVRFInstanceStatus defines the observed state of BGPVRFInstance. + properties: + conditions: + description: Conditions are top-level conditions for this BGPVRFInstance. + 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 + routers: + description: Routers holds per-router reconciliation status. + items: + description: RouterStatus holds per-router reconciliation status + used by BGPVRFInstance. + properties: + conditions: + description: Conditions are the per-router 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 + resolvedConfig: + description: ResolvedConfig holds the configuration that was + actually applied. + properties: + addressFamilies: + description: AddressFamilies configured. + items: + description: |- + AddressFamily is a BGP address family expressed as an AFI/SAFI pair. + Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. + properties: + afi: + description: AFI is the address family indicator. + enum: + - ipv4 + - ipv6 + - l2vpn + type: string + safi: + description: SAFI is the subsequent address family + indicator. + enum: + - unicast + - evpn + type: string + required: + - afi + - safi + type: object + x-kubernetes-validations: + - message: IPv4 only supports unicast SAFI + rule: 'self.afi == ''ipv4'' ? self.safi == ''unicast'' + : true' + - message: IPv6 only supports unicast SAFI + rule: 'self.afi == ''ipv6'' ? self.safi == ''unicast'' + : true' + - message: L2VPN only supports evpn SAFI + rule: 'self.afi == ''l2vpn'' ? self.safi == ''evpn'' + : true' + type: array + asNumber: + description: ASNumber is the AS number configured. + format: int64 + type: integer + routerID: + description: RouterID resolved for this router. + type: string + type: object + routerName: + description: RouterName is the name of the BGPRouter this entry + describes. + type: string + required: + - routerName + type: object + type: array + x-kubernetes-list-map-keys: + - routerName + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/galactic.datumapis.com_egressshards.yaml b/config/crd/galactic.datumapis.com_egressshards.yaml new file mode 100644 index 00000000..ccdb02b7 --- /dev/null +++ b/config/crd/galactic.datumapis.com_egressshards.yaml @@ -0,0 +1,495 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: egressshards.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: EgressShard + listKind: EgressShardList + plural: egressshards + shortNames: + - egressshard + singular: egressshard + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRef.name + name: TARGET + type: string + - jsonPath: .status.shardSID + name: SHARD-SID + type: string + - jsonPath: .status.shardAddressIPv6 + name: IPV6 + type: string + - jsonPath: .status.shardAddressIPv4 + name: IPV4 + type: string + - jsonPath: .spec.drain + name: DRAIN + type: boolean + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + EgressShard marks a single node as a member of the sharded, stateful egress + translation tier (galactic-nat) — a component deliberately kept off the + ingress load-balancer's own consistent-hash ring (see NetworkGateway): + tenant egress traffic (backend -> arbitrary internet destination) is a + different traffic pattern from ingress (fixed VIP, fixed backend pool) + and needs its own placement ring, own per-flow state, and its own + self-routing return path, entirely independent of any NetworkGateway node. + + A shard serves one or both address families. NAT66 (IPv6 -> IPv6) and NAT64 + (IPv6 -> IPv4, RFC 6146) are the same function — stateful egress PAT with a + VRF-scoped session table — over different families, so one shard object + describes both rather than there being a second, near-duplicate kind. + Spec.ShardAddressIPv6 and Spec.ShardAddressIPv4 are each assigned only for + the family this shard translates; a shard with no IPv4 address performs no + NAT64. + + Every shard owns a dedicated, publicly-routable address per family it + serves, and a flow's allocated masquerade port lives within it — so a reply + is delivered to the correct shard by ordinary unicast routing alone, with + no hashing or cross-shard lookup on the return path at all (the "any node + can determine the owning shard from the tuple alone" property, satisfied by + construction rather than by a replicated hash table). + + A shard is told which addresses to translate to; it does not choose them. + The controller that owns a cell claims one address per family from the + addressing service and writes it into this spec, which keeps the + addressing-service credential off every translating node and keeps the + allocation request out of the path that attaches a workload. Status reports + what the node's datapath is actually programmed with, so an unclaimed + address, a stale datapath, and a divergence between the two are each + distinguishable. + + A shard names nothing that selects it. Which shards serve which consumer + intent is decided entirely by label selectors evaluated on the selecting + side (see the LabelEgressShard* keys), so no consumer-facing API appears in + this group and no reference points from a datapath resource back at the + resource that placed traffic on it. + 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: |- + EgressShardSpec defines the desired state of an EgressShard. + + The address fields are written by the controller that owns the cell, not by + the shard and not by a consumer. Each is optional: an address the addressing + service has not yet handed out is absent rather than blank-but-required, so + a shard object exists from the moment its node is labelled and gains its + identity afterwards. + + Each is also write-once, and cannot be unassigned once assigned. The + datapath claims a reply by exact match against the address it translates to, + so reassigning one strands the return traffic of every flow already + established through it, with no drain and no dual-address grace period + available to cover the change. A shard holding the wrong address is deleted + and recreated instead, which breaks those flows at a moment someone chose. + + A claim reference alongside an address records where that address came from, + and is write-once on the same terms. It is deliberately not required with an + address, in either direction. Requiring an address before a claim reference + would forbid recording a claim whose allocation has not resolved yet, which + is the window a restarting controller most needs to see so that it finds the + claim it already made instead of making a second one. Requiring a claim + reference before an address would forbid an address allocated by hand, and + whether such an address may be recorded here at all is an open migration + question this validation would answer by fiat. An address with no reference + is therefore accepted and is unattributable, which is a fact about the + allocation rather than something a schema can repair. + properties: + drain: + description: |- + Drain stops this shard from taking new tenants while it keeps serving + the ones it has. Compute nodes that spread egress across a cluster's + shards give a draining shard no share of new tenant addresses, but keep + sending it every flow already pinned to it until that flow goes idle, so + established sessions finish where their translation state lives. + + It changes nothing on the shard itself: the shard keeps translating, + advertising its SID and receiving replies. A shard is safe to remove + once its session count has fallen to zero. Clearing it puts the shard + back into rotation. + + Compute nodes that send each tenant to a single configured shard ignore + it. + type: boolean + nat64Prefix: + description: |- + NAT64Prefix is the IPv6 prefix whose synthesized addresses this shard + translates to IPv4 — a Datum-operated Network-Specific Prefix, shared + fabric-wide, never per-tenant. TranslateWellKnownPrefix adds the RFC 6052 + Well-Known Prefix alongside it. It must be the prefix the resolver + synthesizes into; a shard translating for a different one is a + blackhole with no symptom on either side. + + Set together with ShardAddressIPv4 or not at all: an address with no + prefix has nothing to translate for, and a prefix with no address has + nothing to translate into. Write-once for the same reason the addresses + are: a shard that starts translating a different prefix blackholes every + destination the resolver already synthesized into the old one. + type: string + x-kubernetes-validations: + - message: nat64Prefix must be a valid CIDR + rule: self == '' || isCIDR(self) + - message: nat64Prefix is immutable once assigned + rule: self == oldSelf || oldSelf == '' + shardAddressIPv4: + description: |- + ShardAddressIPv4 is the dedicated, publicly-routable IPv4 address this + shard translates to, and the source an IPv4-only destination sees. + Unlike ShardAddressIPv6, reachability for it is not established by a + BGPAdvertisement into the EVPN fabric: an IPv4 reply arrives from the + internet, so the underlay or an upstream announcement must attract this + address to this node. + + Empty means no IPv4 address is assigned to this shard. + type: string + x-kubernetes-validations: + - message: shardAddressIPv4 must be a valid IPv4 address + rule: self == '' || (isIP(self) && ip(self).family() == 4) + - message: shardAddressIPv4 is immutable once assigned + rule: self == oldSelf || oldSelf == '' + shardAddressIPv4ClaimRef: + description: |- + ShardAddressIPv4ClaimRef records the addressing-service claim + ShardAddressIPv4 came from — see AddressClaimRef. Set it in the same + write as the address whenever that address came from a claim. + + Nothing here resolves it and no component reads it to program anything; + it is the only trail from a translating address back to the allocation + accountable for it. + properties: + apiGroup: + default: ipam.miloapis.com + description: APIGroup is the group of the claim resource. + minLength: 1 + type: string + kind: + default: IPClaim + description: Kind is the kind of the claim resource. + minLength: 1 + type: string + name: + description: |- + Name is the name of the claim. The addressing service derives it from + the thing being addressed, so it is stable across a shard's lifetime and + is what a replacement finds again. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace holding the claim within + Project. + minLength: 1 + type: string + project: + description: |- + Project is the project whose control plane serves the claim. Required: + a claim is namespaced within a project, and this reference is read from + outside every project. + minLength: 1 + type: string + required: + - name + - namespace + - project + type: object + x-kubernetes-validations: + - message: shardAddressIPv4ClaimRef is immutable once assigned + rule: self == oldSelf + shardAddressIPv6: + description: |- + ShardAddressIPv6 is the dedicated, publicly-routable IPv6 address this + shard translates to — every NAT66 masquerade port it allocates lives + within this address, so any node can route a reply to the owning shard + using ordinary unicast routing on it alone, with no per-flow state + lookup anywhere but that shard. + + Empty means no IPv6 address is assigned to this shard. + type: string + x-kubernetes-validations: + - message: shardAddressIPv6 must be a valid IPv6 address + rule: self == '' || (isIP(self) && ip(self).family() == 6) + - message: shardAddressIPv6 is immutable once assigned + rule: self == oldSelf || oldSelf == '' + shardAddressIPv6ClaimRef: + description: |- + ShardAddressIPv6ClaimRef records the addressing-service claim + ShardAddressIPv6 came from — see AddressClaimRef. Set it in the same + write as the address whenever that address came from a claim. + + Nothing here resolves it and no component reads it to program anything; + it is the only trail from a translating address back to the allocation + accountable for it. + properties: + apiGroup: + default: ipam.miloapis.com + description: APIGroup is the group of the claim resource. + minLength: 1 + type: string + kind: + default: IPClaim + description: Kind is the kind of the claim resource. + minLength: 1 + type: string + name: + description: |- + Name is the name of the claim. The addressing service derives it from + the thing being addressed, so it is stable across a shard's lifetime and + is what a replacement finds again. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace holding the claim within + Project. + minLength: 1 + type: string + project: + description: |- + Project is the project whose control plane serves the claim. Required: + a claim is namespaced within a project, and this reference is read from + outside every project. + minLength: 1 + type: string + required: + - name + - namespace + - project + type: object + x-kubernetes-validations: + - message: shardAddressIPv6ClaimRef is immutable once assigned + rule: self == oldSelf + shardSID: + description: |- + ShardSID is this shard's own SRv6 uSID, the outer destination a tenant + VRF's egress route encapsulates toward. The shard claims a packet by its + Block and Node-ID alone, the Argument carrying the tenant, so those 64 + bits must be reserved for this shard and disjoint from every BGPRouter + locator: a value another node already uses silently diverts that node's + traffic. + + Write-once for the same reason the addresses are: every established + flow's reply is re-encapsulated from this SID, so changing it strands + them. Operator-chosen until the addressing service hands out identifiers + of this kind. + + Empty means no SID is assigned yet, and the shard claims no packet. + type: string + x-kubernetes-validations: + - message: shardSID must be a valid IPv6 address + rule: self == '' || (isIP(self) && ip(self).family() == 6) + - message: shardSID is immutable once assigned + rule: self == oldSelf || oldSelf == '' + targetRef: + description: TargetRef identifies the Node this shard executes on. + properties: + kind: + description: Kind is the target resource kind (e.g. Node). + minLength: 1 + type: string + name: + description: Name is the name of the target resource. + minLength: 1 + type: string + required: + - kind + - name + type: object + translateWellKnownPrefix: + description: |- + TranslateWellKnownPrefix additionally translates destinations inside the + RFC 6052 Well-Known Prefix 64:ff9b::/96 to IPv4 from ShardAddressIPv4, so + clients using a public DNS64 resolver reach IPv4 destinations. Requires + NAT64Prefix. + + RFC 6052 §3.1 forbids the Well-Known Prefix from representing non-global + IPv4 addresses; the implementation must refuse such destinations. + + Mutable, unlike NAT64Prefix. Disabling it blackholes every destination + clients already resolved into the Well-Known Prefix until their DNS TTLs + expire. + type: boolean + required: + - targetRef + type: object + x-kubernetes-validations: + - message: shardSID cannot be unassigned once assigned + rule: '!(has(oldSelf.shardSID) && size(oldSelf.shardSID) > 0) || (has(self.shardSID) + && size(self.shardSID) > 0)' + - message: shardAddressIPv4 and nat64Prefix must be set together + rule: (has(self.shardAddressIPv4) && size(self.shardAddressIPv4) > 0) + == (has(self.nat64Prefix) && size(self.nat64Prefix) > 0) + - message: shardAddressIPv6 cannot be unassigned once assigned + rule: '!(has(oldSelf.shardAddressIPv6) && size(oldSelf.shardAddressIPv6) + > 0) || (has(self.shardAddressIPv6) && size(self.shardAddressIPv6) + > 0)' + - message: shardAddressIPv4 cannot be unassigned once assigned + rule: '!(has(oldSelf.shardAddressIPv4) && size(oldSelf.shardAddressIPv4) + > 0) || (has(self.shardAddressIPv4) && size(self.shardAddressIPv4) + > 0)' + - message: nat64Prefix cannot be unassigned once assigned + rule: '!(has(oldSelf.nat64Prefix) && size(oldSelf.nat64Prefix) > 0) + || (has(self.nat64Prefix) && size(self.nat64Prefix) > 0)' + - message: shardAddressIPv6ClaimRef cannot be unassigned once assigned + rule: '!has(oldSelf.shardAddressIPv6ClaimRef) || has(self.shardAddressIPv6ClaimRef)' + - message: shardAddressIPv4ClaimRef cannot be unassigned once assigned + rule: '!has(oldSelf.shardAddressIPv4ClaimRef) || has(self.shardAddressIPv4ClaimRef)' + - message: translateWellKnownPrefix requires nat64Prefix + rule: '!(has(self.translateWellKnownPrefix) && self.translateWellKnownPrefix) + || (has(self.nat64Prefix) && size(self.nat64Prefix) > 0)' + status: + description: |- + EgressShardStatus defines the observed state of an EgressShard. + + The address and prefix fields report what this node's datapath is programmed + with, not what it was asked for. A value here that disagrees with the spec is + a shard that has not converged; a value here with no counterpart in the spec + is a shard still translating to an address nothing assigns any more. + properties: + conditions: + description: |- + Conditions contains the standard conditions for this resource, + including Programmed (see ConditionTypeProgrammed). + 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 + nat64Prefix: + description: |- + NAT64Prefix is the prefix this shard's datapath is programmed to + translate. Empty whenever ShardAddressIPv4 is empty. + type: string + x-kubernetes-validations: + - message: nat64Prefix must be a valid CIDR + rule: self == '' || isCIDR(self) + observedGeneration: + description: ObservedGeneration is the .metadata.generation this status + was computed from. + format: int64 + type: integer + shardAddressIPv4: + description: |- + ShardAddressIPv4 is the IPv4 masquerade source this shard's datapath is + programmed with. Empty means it performs no NAT64. Publishing it is also + what makes the underlay reachability prerequisite in + Spec.ShardAddressIPv4 checkable. + type: string + x-kubernetes-validations: + - message: shardAddressIPv4 must be a valid IPv4 address + rule: self == '' || (isIP(self) && ip(self).family() == 4) + shardAddressIPv6: + description: |- + ShardAddressIPv6 is the IPv6 masquerade source this shard's datapath is + programmed with. Empty means it translates no IPv6 flow. + type: string + x-kubernetes-validations: + - message: shardAddressIPv6 must be a valid IPv6 address + rule: self == '' || (isIP(self) && ip(self).family() == 6) + shardSID: + description: |- + ShardSID is this shard's own uSID locator — a real SRv6 uSID (unlike + the ShardAddress fields, which are plain routable addresses), + advertised into BGP the same way any other node-reachability route is + (a /128 BGPAdvertisement, no VRFID/Function) so every other node learns + a kernel SEG6 route toward it before installing a tenant VRF's egress + route against it. One SID serves both families: which translation a + packet gets is decided from the inner destination, not from a second + SID. + + Reports the SID this node's datapath is programmed with, from + Spec.ShardSID. + type: string + x-kubernetes-validations: + - message: shardSID must be a valid IPv6 address + rule: self == '' || (isIP(self) && ip(self).family() == 6) + translatesWellKnownPrefix: + description: |- + TranslatesWellKnownPrefix reports whether this shard's datapath is + programmed to translate the RFC 6052 Well-Known Prefix 64:ff9b::/96, from + Spec.TranslateWellKnownPrefix. False whenever ShardAddressIPv4 is empty. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/galactic.datumapis.com_networkegresspolicies.yaml b/config/crd/galactic.datumapis.com_networkegresspolicies.yaml new file mode 100644 index 00000000..12012f86 --- /dev/null +++ b/config/crd/galactic.datumapis.com_networkegresspolicies.yaml @@ -0,0 +1,180 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: networkegresspolicies.galactic.datumapis.com +spec: + group: galactic.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: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NetworkEgressPolicy enables internet egress for a single tenant + VPC/VPCAttachment, served by the sharded, stateful galactic-nat tier + (see EgressShard). Unlike NetworkService, 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 + NetworkService's vpcRef, vpcRef/vpcAttachmentRef are opaque string identifiers + because this kind carries identifiers from the VPC API in + api/cloud/v1alpha1 without a Kubernetes object reference. 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's VRF get an egress route toward the shared + translation tier at all) — unlike this type's original design (superseded), there is + no single "assigned gateway node" to compute or pin: any EgressShard may + serve any tenant's flow, chosen by the shard-placement consistent-hash + ring (internal/maglev, keyed on (tenant VRFID, backend, destination) — + see EgressShard's doc comment), not by a per-tenant node assignment stored + here. *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 VRFID + carried in the tenant's own SRv6 Argument, 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: + 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/galactic.datumapis.com_networkfabricidentities.yaml b/config/crd/galactic.datumapis.com_networkfabricidentities.yaml new file mode 100644 index 00000000..f77bda4b --- /dev/null +++ b/config/crd/galactic.datumapis.com_networkfabricidentities.yaml @@ -0,0 +1,116 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: networkfabricidentities.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: NetworkFabricIdentity + listKind: NetworkFabricIdentityList + plural: networkfabricidentities + singular: networkfabricidentity + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.identity + name: Identity + type: integer + - jsonPath: .spec.networkRef.name + name: Network + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NetworkFabricIdentity tells a location what identity the fabric knows a + network by. + + There is one per network, not one per location. A VPC is the network's + realization at a single location and takes its identity from here, which is + what makes the locations of one network the same network on the fabric + instead of unrelated ones that happen to share a name. + + This is platform-internal. It is written centrally and carried to the cells + where the network is required; it never appears in a project control plane + and no consumer reads or writes one. The identity is a value the fabric acts + on directly, so it is kept to the platform rather than published beside the + network it belongs to. + + This object is managed for you. It follows the Network it was allocated for. + 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: |- + Spec is the whole of this object. There is no status: federation carries + configuration to a cell and deliberately does not carry status, so + anything a cell has to read has to be here. + properties: + identity: + description: |- + Identity is what the fabric knows the network by, the same in every + location the network reaches. The Route Target is derived from it, which + is what makes two locations of one network import each other's routes + rather than behave as two networks that share a name. The VRF device is + named from it for the same reason. + + It is an integer rather than an encoded string because a consumer builds + `ASN:` from it and encodes it for its own use. It is 32 bits + wide because that is what survives into the Route Target: the fabric + truncates, so a wider value would be uniqueness the platform believes it + has and the fabric does not. + + It is never zero and never changes. The fabric embeds it in import policy + in every location the network reaches, so a network that changed identity + would be a different network to everything already carrying its traffic. + format: int64 + maximum: 4294967295 + minimum: 1 + type: integer + x-kubernetes-validations: + - message: identity is immutable + rule: self == oldSelf + networkRef: + description: |- + NetworkRef names the network this identity belongs to. + + It carries a name and no UID, deliberately. The identity is a permanent + property of a name in a namespace, not of one object's lifetime: a + network deleted and recreated under the same name inherits it. A UID here + would document the opposite of the rule. + properties: + name: + description: Name is the network's name. + type: string + required: + - name + type: object + required: + - identity + - networkRef + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/galactic.datumapis.com_networkgateways.yaml b/config/crd/galactic.datumapis.com_networkgateways.yaml new file mode 100644 index 00000000..d9b39e41 --- /dev/null +++ b/config/crd/galactic.datumapis.com_networkgateways.yaml @@ -0,0 +1,162 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: networkgateways.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: NetworkGateway + listKind: NetworkGatewayList + plural: networkgateways + shortNames: + - netgw + singular: networkgateway + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRef.name + name: TARGET + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NetworkGateway marks a single dedicated gateway-role node as running the + Maglev/DSR consistent-hash L4 load-balancer engine. Exactly one + NetworkGateway exists per gateway node (spec.targetRef.name is the + Kubernetes node name), mirroring the BGPRouter node-scoped root object + pattern. NetworkService resources are served by every NetworkGateway in the + namespace equally (anycast — see NetworkServiceStatus's doc comment); this + object's only job is to identify which nodes participate at all and + surface each node's engine health via Conditions. + + This design does no address rewriting on the load-balancing path at all + (DSR — Direct Server Return): the gateway's XDP program picks a backend + via consistent hashing on the flow's 5-tuple and pushes an SRv6 uSID outer + header addressed to the backend's worker node directly, untouched + otherwise. The backend node answers the client directly (see + ServiceVIPBinding) — reply traffic never re-enters this gateway node, so + unlike the Full-NAT design this type originally described, a gateway node + has no SNAT source address of its own to publish and nothing analogous to + sRv6Address/egressAddress/egressSID belongs on this status anymore. + 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: NetworkGatewaySpec defines the desired state of a NetworkGateway. + properties: + targetRef: + description: TargetRef identifies the Node this gateway engine executes + on. + properties: + kind: + description: Kind is the target resource kind (e.g. Node). + minLength: 1 + type: string + name: + description: Name is the name of the target resource. + minLength: 1 + type: string + required: + - kind + - name + type: object + required: + - targetRef + type: object + status: + description: NetworkGatewayStatus defines the observed state of a NetworkGateway. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_networkservices.yaml b/config/crd/galactic.datumapis.com_networkservices.yaml new file mode 100644 index 00000000..666ff4df --- /dev/null +++ b/config/crd/galactic.datumapis.com_networkservices.yaml @@ -0,0 +1,277 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: networkservices.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: NetworkService + listKind: NetworkServiceList + plural: networkservices + shortNames: + - netsvc + singular: networkservice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.vpcRef + name: VPC + type: string + - jsonPath: .spec.protocol + name: PROTOCOL + type: string + - jsonPath: .spec.port + name: PORT + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NetworkService defines ingress load-balancing for a single tenant VPC, served + by every NetworkGateway node identically (anycast Direct Server Return — + see NetworkGateway's doc comment). It is namespaced (deployed to + galactic-system) and tenant-writable; vpcRef is an opaque string identifier + because this kind carries identifiers from the VPC API in + api/cloud/v1alpha1 without a Kubernetes object reference. An admission webhook (implemented by the consuming controller) must + verify the requester is authorized for vpcRef before a service is accepted — + see the Accepted condition. + + Unlike the earlier Full-NAT design this type originally described, there + is no primary/secondary gateway node for a service: every NetworkGateway + advertises every accepted service's vipAddresses at equal BGP preference, + consistent-hashes the same backend list to the same backend for the same + flow (internal/maglev), and forwards without rewriting anything — + backend selection never needs a single "owning" node the way Full-NAT's + SNAT-source model did. + + Backends are not listed. BackendSelector picks the VPCAttachments that + serve the service, so the backend set follows attachments as they are + created, deleted or moved to another node, and galactic-router on each + node generates the ServiceVIPBinding its backends need from the same + selection. + 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: |- + NetworkServiceSpec defines the desired ingress load-balancing state for a + tenant VPC. + properties: + backendPort: + description: BackendPort is the destination port on every selected + backend. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + backendSelector: + description: |- + BackendSelector selects the VPCAttachments (galactic.datumapis.com/v1alpha1, + in any namespace) that serve this service, matched against each + VPCAttachment object's own labels. Only attachments whose status.vpc + equals VPCRef are candidates, whatever their labels, so a selector cannot + reach into another tenant's VPC. Each selected attachment contributes the + IPv6 addresses in its spec.interface.addresses as backends, on + BackendPort; the DSR datapath carries IPv6 backends only. An attachment + with no status.node or no IPv6 address is not a backend until it has + both. An empty selector is rejected rather than read as "every attachment + in the VPC". + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: backendSelector must not be empty + rule: (has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions) + && size(self.matchExpressions) > 0) + port: + description: Port is the ingress port on VIPAddresses that this service + load-balances. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + description: Protocol is the transport protocol matched by VIPAddresses/Port. + enum: + - tcp + - udp + type: string + vipAddresses: + description: |- + VIPAddresses is the list of ingress VIP addresses this service provisions + on the assigned gateway node(s). + + Only IPv6 addresses are admitted until the gateway datapath serves IPv4 + VIPs (datum-cloud/galactic#705). A service created with an IPv4 VIP before + this restriction keeps it while the list is unchanged, since the API + server ratchets validation of an unchanged field on Kubernetes 1.30 and + later; any edit to the list must drop it, and on an older API server any + update to such a service is refused. The gateways do not load or advertise + an IPv4 VIP. + items: + maxLength: 45 + type: string + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-type: set + x-kubernetes-validations: + - message: vipAddresses must all be valid IPv4 or IPv6 addresses + rule: self.all(v, isIP(v)) + - message: 'vipAddresses must be IPv6: IPv4 VIPs are not served yet + (datum-cloud/galactic#705)' + rule: self.all(v, !isIP(v) || ip(v).family() == 6) + vpcRef: + description: |- + VPCRef is the opaque identifier of the target VPC this service 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 service is accepted. + minLength: 1 + type: string + required: + - backendPort + - backendSelector + - port + - protocol + - vipAddresses + - vpcRef + type: object + status: + description: NetworkServiceStatus defines the observed state of a NetworkService. + properties: + conditions: + description: Conditions contains the standard conditions for this + resource. + 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/galactic.datumapis.com_serviceendpoints.yaml b/config/crd/galactic.datumapis.com_serviceendpoints.yaml new file mode 100644 index 00000000..2a6cd73e --- /dev/null +++ b/config/crd/galactic.datumapis.com_serviceendpoints.yaml @@ -0,0 +1,185 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: serviceendpoints.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: ServiceEndpoint + listKind: ServiceEndpointList + plural: serviceendpoints + shortNames: + - svcendpoint + singular: serviceendpoint + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.address + name: ADDRESS + type: string + - jsonPath: .spec.port + name: PORT + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ServiceEndpoint publishes a platform service that can be reached through a + VPC attachment. The object describes the service contract; the controller + that owns the service is responsible for its health and implementation. + 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: ServiceEndpointSpec defines a platform service address and + its scope. + properties: + address: + description: Address is the stable address that consumers use to reach + the service. + type: string + x-kubernetes-validations: + - message: address must be an IP address + rule: isIP(self) + attachmentRef: + description: |- + AttachmentRef identifies the Cloud API VPCAttachment that hosts this + endpoint when the service is delivered through a private VPC path. + The network API keeps this reference opaque; the consuming controller + resolves it against the Cloud API. + properties: + name: + description: Name is the Cloud API VPCAttachment name. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace containing the Cloud API + VPCAttachment. + minLength: 1 + type: string + required: + - name + - namespace + type: object + attachmentSelector: + description: |- + AttachmentSelector selects the Cloud API VPCAttachments that host replicas + of this endpoint. Labels used here must be assigned by the platform service + controller, not by tenants. The dataplane selects one ready replica for + each consumer according to DeliveryMode. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + deliveryMode: + description: |- + DeliveryMode defines how a backing attachment is selected relative to a + consumer. PreferNodeLocal uses a ready attachment on the consumer's node + when one exists and otherwise falls back to a ready remote attachment. + The dataplane preserves the consumer source address. If consumers with + overlapping address space present an identical TCP or UDP five-tuple to the + same replica, it rejects the later ambiguous flow fail-closed rather than + translate the source or risk returning traffic to the wrong VPC. + enum: + - NodeLocal + - PreferNodeLocal + type: string + port: + description: Port is the service port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + description: Protocol is the transport protocol accepted by the endpoint. + enum: + - tcp + - udp + type: string + region: + description: |- + Region limits endpoint selection to a region. An empty value means the + endpoint is not region-scoped. + type: string + serviceClass: + description: |- + ServiceClass identifies the platform capability implemented by this + endpoint, for example "ipv6-egress-dns". + minLength: 1 + type: string + required: + - address + - deliveryMode + - port + - protocol + - serviceClass + type: object + x-kubernetes-validations: + - message: exactly one of attachmentRef or attachmentSelector must be + set + rule: has(self.attachmentRef) != has(self.attachmentSelector) + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/galactic.datumapis.com_serviceroutepolicies.yaml b/config/crd/galactic.datumapis.com_serviceroutepolicies.yaml new file mode 100644 index 00000000..759b18ee --- /dev/null +++ b/config/crd/galactic.datumapis.com_serviceroutepolicies.yaml @@ -0,0 +1,315 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: serviceroutepolicies.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: ServiceRoutePolicy + listKind: ServiceRoutePolicyList + plural: serviceroutepolicies + shortNames: + - svcroute + singular: serviceroutepolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.serviceRef.name + name: SERVICE + type: string + - jsonPath: .spec.region + name: REGION + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ServiceRoutePolicy selects VPC attachments that should receive a route to a + platform ServiceEndpoint. The selector is evaluated by the network control + plane; Galactic compiles the selected attachments into dataplane state. + 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: ServiceRoutePolicySpec defines service selection and attachment + eligibility. + properties: + attachmentSelector: + description: |- + AttachmentSelector selects eligible VPC attachments by authoritative + labels. Labels that grant access to platform services must be assigned by + the network control plane, not by tenants. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + authorization: + description: |- + Authorization bounds translated access independently of application leases. + Only trusted networking integration may renew this deadline. + properties: + validUntil: + description: |- + ValidUntil is the absolute UTC deadline for requests and existing replies. + Node controllers reject deadlines more than two minutes in the future. + format: date-time + type: string + required: + - validUntil + type: object + consumerVPCRef: + description: |- + ConsumerVPCRef pins authorization to the immutable lifetime of the VPC + selected by AttachmentSelector. Galactic resolves the name through the + Cloud API and requires both the live name and UID to match. + properties: + name: + description: Name is the VPC name in the policy namespace. + minLength: 1 + type: string + uid: + description: UID is the Kubernetes UID of that VPC lifetime. + minLength: 1 + type: string + required: + - name + - uid + type: object + frontend: + description: |- + Frontend is the address presented inside the consumer VPC. When set, + Galactic translates it to the ServiceEndpoint address and restores it on + replies. + properties: + address: + description: |- + Address is translated to the ServiceEndpoint address for requests and is + restored as the source address for replies. + type: string + x-kubernetes-validations: + - message: address must be an IP address + rule: isIP(self) + required: + - address + type: object + protocolPorts: + description: |- + ProtocolPorts limits the traffic that the route is intended to carry. + An empty list means the endpoint's declared port and protocol are used. + items: + description: ServiceRouteProtocolPort identifies an allowed transport + protocol and port. + properties: + port: + description: Port is the transport port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + description: Protocol is the transport protocol. + enum: + - tcp + - udp + type: string + required: + - port + - protocol + type: object + type: array + x-kubernetes-list-type: atomic + region: + description: |- + Region limits selected attachments to a region. An empty value means the + policy applies in every region where the endpoint is available. + type: string + serviceRef: + description: ServiceRef identifies the ServiceEndpoint in this namespace. + properties: + name: + description: Name is the ServiceEndpoint name. + minLength: 1 + type: string + required: + - name + type: object + required: + - attachmentSelector + - serviceRef + type: object + x-kubernetes-validations: + - message: frontend requires consumerVPCRef + rule: '!has(self.frontend) || has(self.consumerVPCRef)' + - message: frontend requires authorization + rule: '!has(self.frontend) || has(self.authorization)' + status: + description: ServiceRoutePolicyStatus contains policy conditions and fenced + node reports. + properties: + conditions: + description: Conditions contains durable acceptance and service-availability + state. + 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 + nodes: + description: |- + Nodes contains short-lived acknowledgments written by trusted node controllers. + Consumers require current UID, generation, input digest, and unexpired reports. + items: + description: ServiceRouteNodeStatus acknowledges one node's current + programmed path. + properties: + inputDigest: + type: string + nodeName: + type: string + observedGeneration: + format: int64 + type: integer + policyUID: + type: string + ready: + type: boolean + validUntil: + format: date-time + type: string + required: + - inputDigest + - nodeName + - observedGeneration + - policyUID + - ready + - validUntil + type: object + type: array + x-kubernetes-list-map-keys: + - nodeName + 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/galactic.datumapis.com_servicevipbindings.yaml b/config/crd/galactic.datumapis.com_servicevipbindings.yaml new file mode 100644 index 00000000..8cc5d948 --- /dev/null +++ b/config/crd/galactic.datumapis.com_servicevipbindings.yaml @@ -0,0 +1,250 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: servicevipbindings.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: ServiceVIPBinding + listKind: ServiceVIPBindingList + plural: servicevipbindings + shortNames: + - svcvip + singular: servicevipbinding + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRef.name + name: NODE + type: string + - jsonPath: .spec.vipAddress + name: VIP + type: string + - jsonPath: .spec.backendAddress + name: BACKEND + type: string + - jsonPath: .spec.egressKind + name: KIND + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ServiceVIPBinding drives one worker node's backend-side half of the + DSR/Maglev load-balancer datapath: it tells the node which service VIP a + specific local backend must be reachable on, so the backend can reply to + clients directly (the "Direct Server Return" this design depends on — + see NetworkGateway's doc comment). Generated, never written by users: + galactic-router on each worker node writes one object per (service, VIP, + backend) for every backend a NetworkService's BackendSelector places on that + node, owned by the service, and the same process's per-node reconciler + consumes it. + + EgressKind decides which of two backend mechanisms this object drives, + mirroring the same veth/tap fork the SRv6 uSID decap datapath already + has (internal/plumbing/ebpf/usidmap's egress_kind field). Both mechanisms + now converge on the same VIP-boundary substitution + (BackendAddress:BackendPort for VIPAddress:Port at the SRv6 uSID TC-BPF + boundary, usid_ingress's inbound half / usid_egress's outbound half) — + required for both, not just tap, since a decapsulated ingress packet is + delivered into the owning tenant's own VRF routing table, which has no + route to an address bound outside that VRF (found live: see galactic's + ServiceVIPBindingReconciler doc comment): + + - veth (container backend): the node ALSO binds VIPAddress on its own + galactic-vip0 dummy interface (internal/plumbing/vip's + Bind/Unbind/Verify in galactic) — this alone does not deliver + anything to the backend pod (galactic-vip0 lives in the node's root + namespace, not the tenant's VRF), but still lets the node itself + verifiably answer on the VIP. + - tap (VM backend): there is no guest-side configuration capability in + this repo by design (internal/cnitap's own doc comment), so the + substitution above is this kind's *only* delivery mechanism — the + guest OS never needs to know the VIP exists at all. + 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: ServiceVIPBindingSpec defines the desired VIP binding/translation + state. + properties: + backendAddress: + description: |- + BackendAddress is the backend's own real address (its pod-netns + address for a veth backend, or its actual guest-facing address for a + tap backend) — the VIP-boundary substitution target for both kinds + now (see EgressKind's own doc comment for why veth needs this too, + not just tap). +optional at the API level for the same reason + EgressKind itself carries no matching CEL requirement; the + reconciler validates it's set for either kind before doing anything. + type: string + x-kubernetes-validations: + - message: backendAddress must be a valid IP address + rule: self == '' || isIP(self) + backendPort: + description: |- + BackendPort is the backend's own real port, paired with + BackendAddress — required for both kinds, see that field's doc + comment. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + egressKind: + description: EgressKind selects which backend mechanism this binding + drives. + enum: + - veth + - tap + type: string + port: + description: Port is the VIP-facing port traffic arrives on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + description: Protocol is the transport protocol this binding applies + to. + enum: + - tcp + - udp + type: string + targetRef: + description: TargetRef identifies the Node this binding applies to. + properties: + kind: + description: Kind is the target resource kind (e.g. Node). + minLength: 1 + type: string + name: + description: Name is the name of the target resource. + minLength: 1 + type: string + required: + - kind + - name + type: object + vipAddress: + description: VIPAddress is the service VIP the backend must be reachable + on. + type: string + x-kubernetes-validations: + - message: vipAddress must be a valid IP address + rule: isIP(self) + vpcRef: + description: |- + VPCRef is the opaque identifier of the VPC the backend belongs to, + copied from the owning NetworkService. It names the tenant VRF the + binding's translation rows are written for, so two tenants using the + same backend address on one node never share a row. + minLength: 1 + type: string + required: + - egressKind + - port + - protocol + - targetRef + - vipAddress + - vpcRef + type: object + status: + description: ServiceVIPBindingStatus defines the observed state of a ServiceVIPBinding. + properties: + conditions: + description: |- + Conditions contains the standard conditions for this resource, + including Bound (set True once internal/plumbing/vip.Verify or the + equivalent tap-translation-table check confirms the backend is + actually reachable on VIPAddress, not merely that the bind/table-write + call itself returned nil). + 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/galactic.datumapis.com_vpcattachments.yaml b/config/crd/galactic.datumapis.com_vpcattachments.yaml new file mode 100644 index 00000000..441bc4c2 --- /dev/null +++ b/config/crd/galactic.datumapis.com_vpcattachments.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: vpcattachments.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: VPCAttachment + listKind: VPCAttachmentList + plural: vpcattachments + singular: vpcattachment + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: VPCAttachment is the Schema for the vpcattachments API + 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: spec defines the desired state of VPCAttachment + properties: + interface: + description: Interface defines the network interface configuration. + properties: + addresses: + description: |- + A list of IPv4 or IPv6 addresses associated with the interface. Empty when + the guest manages its own addressing. + items: + description: IPAddress is an IPv4 or IPv6 address with CIDR + notation. + maxLength: 64 + type: string + maxItems: 16 + type: array + mode: + default: Netns + description: |- + Mode is how the workload consumes the interface, resolved and written by + the attachment controller rather than by whoever runs the workload. + enum: + - Netns + - Hypervisor + - HypervisorDeclared + type: string + name: + default: eth0 + description: Name of the interface (e.g., eth0). + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: each address must be a valid IPv4 or IPv6 CIDR + rule: '!has(self.addresses) || self.addresses.all(a, isCIDR(a))' + interfaceRef: + description: NetworkInterface this attachment realizes. + properties: + name: + description: Name of the NetworkInterface. + minLength: 1 + type: string + required: + - name + type: object + vpc: + description: VPC this attachment belongs to. + properties: + name: + description: Name is the name of the VPC. + minLength: 1 + type: string + required: + - name + type: object + required: + - interface + - vpc + type: object + status: + description: status defines the observed state of VPCAttachment + 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 + containerID: + description: Full container ID (46 hex characters). + maxLength: 46 + minLength: 46 + type: string + guestInterface: + description: Guest-side veth device name (e.g., "G000000010013G"). + minLength: 1 + type: string + hostInterface: + description: Host-side veth or tap device name (e.g., "G000000010013H"). + minLength: 1 + type: string + networkAttachmentDefinition: + description: NetworkAttachmentDefinition rendered for this attachment. + minLength: 1 + type: string + node: + description: Kubernetes node name where the attachment lives. + minLength: 1 + type: string + observedGeneration: + format: int64 + type: integer + podName: + description: Pod name. + minLength: 1 + type: string + podSubnet: + description: Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80"). + minLength: 1 + type: string + x-kubernetes-validations: + - message: podSubnet must be a valid IPv6 CIDR + rule: isCIDR(self) + vpc: + description: Base62-encoded VPC identifier. + maxLength: 16 + minLength: 1 + type: string + vpcAttachment: + description: Base62-encoded VPCAttachment identifier. + maxLength: 16 + minLength: 1 + type: string + vrfInterface: + description: VRF device name, which is per-VPC (e.g., "G000000010V"). + minLength: 1 + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/galactic.datumapis.com_vpcs.yaml b/config/crd/galactic.datumapis.com_vpcs.yaml new file mode 100644 index 00000000..ba9cc392 --- /dev/null +++ b/config/crd/galactic.datumapis.com_vpcs.yaml @@ -0,0 +1,149 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: vpcs.galactic.datumapis.com +spec: + group: galactic.datumapis.com + names: + kind: VPC + listKind: VPCList + plural: vpcs + singular: vpc + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Base62 VPC identifier + jsonPath: .status.vpc + name: VPC + type: string + - description: Ready status + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + VPC represents a virtual private cloud — an isolated Layer 2 domain backed + by one or more CIDR blocks. + 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: Desired CIDR address space. + properties: + networks: + description: CIDR blocks that form the VPC address space. + items: + description: Network is an IPv4 or IPv6 CIDR block (e.g., "10.0.0.0/24"). + maxLength: 64 + type: string + maxItems: 64 + minItems: 1 + type: array + required: + - networks + type: object + x-kubernetes-validations: + - message: each network must be a valid IPv4 or IPv6 CIDR + rule: self.networks.all(n, isCIDR(n)) + - message: spec is immutable + rule: self == oldSelf + status: + description: Controller-observed state. + 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 + vpc: + description: Base62-encoded VPC identifier. + maxLength: 16 + minLength: 1 + type: string + required: + - vpc + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 28ee2f2d..9ecc2490 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -1,8 +1,22 @@ -# The galactic.datumapis.com CRDs. Not part of the root kustomization: nothing -# reads them until the traffic policy controllers ship, and infra owns the -# CRDs it installs in each cluster. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - galactic.datumapis.com_bgpadvertisements.yaml + - galactic.datumapis.com_bgpcommunitysets.yaml + - galactic.datumapis.com_bgppeers.yaml + - galactic.datumapis.com_bgppolicies.yaml + - galactic.datumapis.com_bgpprefixlists.yaml + - galactic.datumapis.com_bgprouters.yaml + - galactic.datumapis.com_bgpvrfinstances.yaml + - galactic.datumapis.com_egressshards.yaml + - galactic.datumapis.com_networkegresspolicies.yaml + - galactic.datumapis.com_networkfabricidentities.yaml + - galactic.datumapis.com_networkgateways.yaml + - galactic.datumapis.com_networkservices.yaml + - galactic.datumapis.com_serviceendpoints.yaml + - galactic.datumapis.com_serviceroutepolicies.yaml + - galactic.datumapis.com_servicevipbindings.yaml - galactic.datumapis.com_trafficpolicies.yaml - galactic.datumapis.com_trafficpolicybindings.yaml + - galactic.datumapis.com_vpcattachments.yaml + - galactic.datumapis.com_vpcs.yaml diff --git a/config/fabric-api/crd/network.datumapis.com_fabricqueries.yaml b/config/fabric-api/crd/galactic.datumapis.com_fabricqueries.yaml similarity index 99% rename from config/fabric-api/crd/network.datumapis.com_fabricqueries.yaml rename to config/fabric-api/crd/galactic.datumapis.com_fabricqueries.yaml index be6cd9e4..7d03ae80 100644 --- a/config/fabric-api/crd/network.datumapis.com_fabricqueries.yaml +++ b/config/fabric-api/crd/galactic.datumapis.com_fabricqueries.yaml @@ -3,10 +3,10 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 - name: fabricqueries.network.datumapis.com + controller-gen.kubebuilder.io/version: v0.21.0 + name: fabricqueries.galactic.datumapis.com spec: - group: network.datumapis.com + group: galactic.datumapis.com names: kind: FabricQuery listKind: FabricQueryList diff --git a/config/fabric-api/rbac.yaml b/config/fabric-api/rbac.yaml index d0fbe65a..e5efef87 100644 --- a/config/fabric-api/rbac.yaml +++ b/config/fabric-api/rbac.yaml @@ -7,10 +7,10 @@ kind: ClusterRole metadata: name: fabric-api-gateway rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: ["fabricqueries"] verbs: ["get", "list", "watch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: ["fabricqueries/status"] verbs: ["get", "update", "patch"] - apiGroups: [""] @@ -70,7 +70,7 @@ kind: ClusterRole metadata: name: fabric-api-janitor rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: ["fabricqueries"] verbs: ["list", "delete"] --- diff --git a/config/galactic-cni/rbac.yaml b/config/galactic-cni/rbac.yaml index aa2197ae..273da345 100644 --- a/config/galactic-cni/rbac.yaml +++ b/config/galactic-cni/rbac.yaml @@ -3,11 +3,11 @@ kind: ClusterRole metadata: name: galactic-cni rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgprouters verbs: ["get", "list"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgpadvertisements - bgpvrfinstances @@ -15,7 +15,7 @@ rules: # egressshards (read only): in hashed egress mode, galactic-cni run spreads # tenant egress across every EgressShard in the cluster and watches them for # membership and drain changes. See internal/installer/egressgroup.go. - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - egressshards verbs: ["get", "list", "watch"] diff --git a/config/galactic-controller/base/deployment.yaml b/config/galactic-controller/base/deployment.yaml index 954baa79..40cf4c98 100644 --- a/config/galactic-controller/base/deployment.yaml +++ b/config/galactic-controller/base/deployment.yaml @@ -39,6 +39,8 @@ spec: image: ghcr.io/datum-cloud/galactic-controller:latest args: - --role=cell + # Override in Hypervisor and HypervisorDeclared cells. + - --attachment-mode=Netns env: - name: POD_NAMESPACE valueFrom: diff --git a/config/galactic-controller/base/kustomization.yaml b/config/galactic-controller/base/kustomization.yaml index b6f0838f..66f97bef 100644 --- a/config/galactic-controller/base/kustomization.yaml +++ b/config/galactic-controller/base/kustomization.yaml @@ -3,7 +3,7 @@ # certificate, and the fail-closed ValidatingWebhookConfiguration. # # Needs, applied first: the galactic.datumapis.com CRDs (config/crd), the -# cloud.datumapis.com VPCAttachment CRD (the controller watches it, and does +# galactic.datumapis.com VPCAttachment CRD (the controller watches it, and does # not become ready without it, which with a fail-closed webhook blocks every # TrafficPolicy write), cert-manager, and ../ (ServiceAccount and RBAC). resources: @@ -12,6 +12,7 @@ resources: - pdb.yaml - certificate.yaml - webhook.yaml + - pod-webhook.yaml images: - name: ghcr.io/datum-cloud/galactic-controller newTag: latest diff --git a/config/galactic-controller/base/pod-webhook.yaml b/config/galactic-controller/base/pod-webhook.yaml new file mode 100644 index 00000000..1bd90861 --- /dev/null +++ b/config/galactic-controller/base/pod-webhook.yaml @@ -0,0 +1,24 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: galactic-controller-pod-interfaces + annotations: + cert-manager.io/inject-ca-from: galactic-system/galactic-controller-webhook +webhooks: + - name: pod-interfaces.galactic.datumapis.com + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + objectSelector: + matchLabels: + networking.datumapis.com/inject-interfaces: "true" + clientConfig: + service: + name: galactic-controller-webhook + namespace: galactic-system + path: /mutate-v1-pod + rules: + - apiGroups: [""] + apiVersions: [v1] + operations: [CREATE] + resources: [pods] diff --git a/config/galactic-controller/fabric/deployment.yaml b/config/galactic-controller/fabric/deployment.yaml new file mode 100644 index 00000000..baf8f289 --- /dev/null +++ b/config/galactic-controller/fabric/deployment.yaml @@ -0,0 +1,117 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: galactic-controller-fabric + namespace: fabric-identity-system + labels: + app.kubernetes.io/name: galactic-controller-fabric + app.kubernetes.io/component: fabric-identity-controller + app.kubernetes.io/managed-by: kustomize +spec: + # A network's identity is decided once. Leader election is what keeps that + # true across a rollout; the replica count is not. + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: galactic-controller-fabric + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + app.kubernetes.io/name: galactic-controller-fabric + app.kubernetes.io/component: fabric-identity-controller + spec: + serviceAccountName: galactic-controller-fabric + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: manager + image: ghcr.io/datum-cloud/galactic-controller:latest + # Args reference env vars so an overlay can retarget any value with a + # strategic-merge patch on env, matched by name, instead of rewriting + # the args list. + args: + - --role=fabric + - --namespace=fabric-identity-system + - --leader-elect + - --health-probe-bind-address=:8081 + - --metrics-bind-address=:8080 + - --identity-class=$(IDENTITY_CLASS) + - --identity-namespace=$(IDENTITY_NAMESPACE) + - --platform-project=$(PLATFORM_PROJECT) + - --ipam-kubeconfig=/etc/ipam-cluster/kubeconfig + - --hub-kubeconfig=/etc/kubernetes/federation/auth/kubeconfig + env: + # The IPClass that hands out identities. Required; a deployment + # naming no class refuses to start rather than draw from a default + # that would collide with something else's space. + - name: IDENTITY_CLASS + value: datum-fabric-identity + - name: IDENTITY_NAMESPACE + value: default + # The identity has to be unique across every consumer, so the claim + # is written in a project the platform owns rather than in the + # consumer's. Required, and deployment-specific. + - name: PLATFORM_PROJECT + value: "" + ports: + - name: metrics + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 256Mi + volumeMounts: + - name: ipam-cluster-kubeconfig + mountPath: /etc/ipam-cluster + readOnly: true + - name: federation-kubeconfig + mountPath: /etc/kubernetes/federation/auth + readOnly: true + volumes: + # Neither mount is optional. This component reads networks on the hub + # and draws identities from IPAM, so it can do nothing at all without + # both. A pod waiting in ContainerCreating for a credential that has not + # landed yet says so plainly; one started against an empty dir + # crashloops until the kubelet's next volume resync swaps the real + # secret in, which reads as a broken image rather than a missing secret. + # + # The hub credential is not what this pod authenticates to its own + # cluster with. Leader election runs locally under the ServiceAccount. + - name: ipam-cluster-kubeconfig + secret: + secretName: ipam-cluster-kubeconfig + # The hub credential is a client certificate paired with a kubeconfig + # naming the Karmada endpoint. They are separate objects because the + # certificate is issued per deployment and the endpoint is not, so they + # are joined at the mount rather than duplicated into one secret. + - name: federation-kubeconfig + projected: + sources: + - secret: + name: fabric-identity-federation-client-cert + - configMap: + name: fabric-identity-federation-kubeconfig + terminationGracePeriodSeconds: 10 diff --git a/config/galactic-controller/fabric/hub-rbac.yaml b/config/galactic-controller/fabric/hub-rbac.yaml new file mode 100644 index 00000000..9c70f51d --- /dev/null +++ b/config/galactic-controller/fabric/hub-rbac.yaml @@ -0,0 +1,16 @@ +# Apply this ClusterRole on the federation hub and bind it to the identity in +# the mounted hub kubeconfig. The binding is deployment specific. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: galactic-controller-fabric +rules: + - apiGroups: [networking.datumapis.com] + resources: [networks, networkcontexts] + verbs: [get, list, watch] + - apiGroups: [galactic.datumapis.com] + resources: [networkfabricidentities] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [policy.karmada.io] + resources: [clusterpropagationpolicies] + verbs: [get, list, watch, create, update, patch, delete] diff --git a/config/galactic-controller/fabric/kustomization.yaml b/config/galactic-controller/fabric/kustomization.yaml new file mode 100644 index 00000000..0c001424 --- /dev/null +++ b/config/galactic-controller/fabric/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - serviceaccount.yaml + - rbac.yaml + - deployment.yaml + - metrics_service.yaml +images: + - name: ghcr.io/datum-cloud/galactic-controller + newTag: latest diff --git a/config/galactic-controller/fabric/metrics_service.yaml b/config/galactic-controller/fabric/metrics_service.yaml new file mode 100644 index 00000000..4fd353b9 --- /dev/null +++ b/config/galactic-controller/fabric/metrics_service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: fabric-identity-metrics + namespace: fabric-identity-system + labels: + app.kubernetes.io/name: galactic-controller-fabric + app.kubernetes.io/component: fabric-identity-controller + app.kubernetes.io/managed-by: kustomize +spec: + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: metrics + selector: + app.kubernetes.io/name: galactic-controller-fabric diff --git a/config/galactic-controller/fabric/namespace.yaml b/config/galactic-controller/fabric/namespace.yaml new file mode 100644 index 00000000..d010a13c --- /dev/null +++ b/config/galactic-controller/fabric/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: fabric-identity-system diff --git a/config/galactic-controller/fabric/rbac.yaml b/config/galactic-controller/fabric/rbac.yaml new file mode 100644 index 00000000..c38c7e52 --- /dev/null +++ b/config/galactic-controller/fabric/rbac.yaml @@ -0,0 +1,28 @@ +# Only the lease and events are local. The federation hub credential needs +# hub-rbac.yaml bound on the hub by the deployment operator. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: galactic-controller-fabric + namespace: fabric-identity-system +rules: + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [""] + resources: [events] + verbs: [create, patch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: galactic-controller-fabric + namespace: fabric-identity-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: galactic-controller-fabric +subjects: + - kind: ServiceAccount + name: galactic-controller-fabric + namespace: fabric-identity-system diff --git a/config/galactic-controller/fabric/serviceaccount.yaml b/config/galactic-controller/fabric/serviceaccount.yaml new file mode 100644 index 00000000..65e4a143 --- /dev/null +++ b/config/galactic-controller/fabric/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: galactic-controller-fabric + namespace: fabric-identity-system diff --git a/config/galactic-controller/rbac.yaml b/config/galactic-controller/rbac.yaml index fcf31f5c..c38d204f 100644 --- a/config/galactic-controller/rbac.yaml +++ b/config/galactic-controller/rbac.yaml @@ -14,10 +14,30 @@ rules: - apiGroups: [galactic.datumapis.com] resources: [trafficpolicybindings] verbs: [get, list, watch] - # Which namespaces hold an interface, so the default policy is provisioned - # only where it can be referenced. - - apiGroups: [cloud.datumapis.com] - resources: [vpcattachments] + # Cell VPC, attachment and fabric identity reconcilers. + - apiGroups: [galactic.datumapis.com] + resources: [networkfabricidentities, vpcs, vpcattachments] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [galactic.datumapis.com] + resources: [vpcs/status, vpcattachments/status] + verbs: [get, update, patch] + - apiGroups: [galactic.datumapis.com] + resources: [bgpadvertisements, bgprouters] + verbs: [get, list, watch] + - apiGroups: [networking.datumapis.com] + resources: [networkcontexts, networkinterfaceclaims, networkinterfaces, networks, subnets] + verbs: [get, list, watch] + - apiGroups: [networking.datumapis.com] + resources: [networkinterfaceclaims/status, networkinterfaces/status, subnets/status] + verbs: [get, update, patch] + - apiGroups: [compute.datumapis.com] + resources: [instances] + verbs: [get, list, watch] + - apiGroups: [k8s.cni.cncf.io] + resources: [network-attachment-definitions] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [""] + resources: [pods] verbs: [get, list, watch] # The webhook allows the namespace controller to delete default-deny-all # while its namespace is being torn down. diff --git a/config/galactic-gateway/rbac.yaml b/config/galactic-gateway/rbac.yaml index 7e857c6e..dbdb902e 100644 --- a/config/galactic-gateway/rbac.yaml +++ b/config/galactic-gateway/rbac.yaml @@ -1,13 +1,13 @@ # The edge XDP DSR load-balancer control plane's own RBAC. This ClusterRole -# covers only what NetworkGatewayReconciler/NetworkRuleReconciler +# covers only what NetworkGatewayReconciler/NetworkServiceReconciler # (internal/controller/networkgateway_controller.go, -# internal/controller/networkrule_controller.go) actually touch. +# internal/controller/networkservice_controller.go) actually touch. # # bgprouters (get/list/watch) is included even though neither reconciler # imports internal/runtime or internal/reconcile (this binary needs no # BGP client of its own): internal/controller/usidresolver.go's # buildBackendSIDIndex lists BGPRouter CRDs directly -# (alongside BGPAdvertisement) to resolve a NetworkRule backend's SRv6 +# (alongside BGPAdvertisement) to resolve a NetworkService backend's SRv6 # uSID. "No BGP client" and "no BGPRouter read access needed" are # different claims — only the first is true. cmd/galactic-gateway's # resolveEncapSource also lists BGPRouters cluster-wide at startup, to derive @@ -24,26 +24,26 @@ kind: ClusterRole metadata: name: galactic-gateway rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - networkgateways - - networkrules + - networkservices verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - networkgateways/status - - networkrules/status + - networkservices/status verbs: ["get", "update", "patch"] # Full CRUD: NetworkGatewayReconciler creates one BGPAdvertisement per VIP - # address family per NetworkRule it serves plus one for its own - # SRv6Address, and NetworkRuleReconciler deletes them on rule teardown + # address family per NetworkService it serves plus one for its own + # SRv6Address, and NetworkServiceReconciler deletes them on rule teardown # (internal/controller/networkgateway_controller.go, - # internal/controller/networkrule_controller.go). - - apiGroups: ["network.datumapis.com"] + # internal/controller/networkservice_controller.go). + - apiGroups: ["galactic.datumapis.com"] resources: - bgpadvertisements verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgprouters verbs: ["get", "list", "watch"] @@ -58,15 +58,15 @@ rules: # BGPVRFInstance -- found live as the manager never finishing # WaitForCacheSync (no controller ever started reconciling, silently, # behind a "bgpvrfinstances ... is forbidden" reflector error loop). - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgpvrfinstances verbs: ["get", "list", "watch"] - # A NetworkRule's backends are the VPCAttachments its backendSelector + # A NetworkService's backends are the VPCAttachments its backendSelector # picks, read cluster-wide since they live in tenant namespaces, and # watched so a backend appearing, moving node or changing address # reconverges the rule. - - apiGroups: ["cloud.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - vpcattachments verbs: ["get", "list", "watch"] diff --git a/config/galactic-nat/rbac.yaml b/config/galactic-nat/rbac.yaml index e4a7da8a..57e71130 100644 --- a/config/galactic-nat/rbac.yaml +++ b/config/galactic-nat/rbac.yaml @@ -24,19 +24,19 @@ kind: ClusterRole metadata: name: galactic-nat rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - egressshards verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - egressshards/status verbs: ["get", "update", "patch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgpadvertisements verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgprouters verbs: ["get", "list", "watch"] diff --git a/config/galactic-router/rbac.yaml b/config/galactic-router/rbac.yaml index dbd93771..a03ed675 100644 --- a/config/galactic-router/rbac.yaml +++ b/config/galactic-router/rbac.yaml @@ -3,7 +3,7 @@ kind: ClusterRole metadata: name: galactic-router rules: - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgprouters - bgppeers @@ -15,38 +15,38 @@ rules: # base resource: galactic-cni already owns creating/updating both # (config/galactic-cni/rbac.yaml) via galactic-bgp, and this binary's own # reconcilers only read them and write their /status subresource (see - # the rule below). NetworkGatewayReconciler/NetworkRuleReconciler need + # the rule below). NetworkGatewayReconciler/NetworkServiceReconciler need # create/update/patch on bgpadvertisements, but that's galactic-gateway's # ClusterRole (config/galactic-gateway/rbac.yaml), not this one. - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgpadvertisements - bgpvrfinstances verbs: ["get", "list", "watch", "delete"] # servicevipbindings needs full CRUD on the base resource: - # NetworkRuleBindingReconciler creates, updates and deletes this node's + # NetworkServiceBindingReconciler creates, updates and deletes this node's # generated bindings, and ServiceVIPBindingReconciler adds and removes its # own teardown finalizer with a Patch on the object itself, not just its # /status subresource. NodeDepartureReconciler deletes a departed node's # generated bindings and patches the finalizer off every binding that # targets it. - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - servicevipbindings verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # NetworkRuleBindingReconciler reads every NetworkRule to find the backends + # NetworkServiceBindingReconciler reads every NetworkService to find the backends # on this node, and writes its per-node BackendsBound condition to rule # status. It never writes the rule itself. - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - - networkrules + - networkservices verbs: ["get", "list", "watch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - serviceendpoints - serviceroutepolicies verbs: ["get", "list", "watch"] - - apiGroups: ["network.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - bgprouters/status - bgppeers/status @@ -54,10 +54,10 @@ rules: - bgppolicies/status - bgpvrfinstances/status - servicevipbindings/status - - networkrules/status + - networkservices/status - serviceroutepolicies/status verbs: ["get", "update", "patch"] - - apiGroups: ["cloud.datumapis.com"] + - apiGroups: ["galactic.datumapis.com"] resources: - vpcs - vpcattachments diff --git a/containers/fabric-api/Dockerfile b/containers/fabric-api/Dockerfile index 9999bfee..7183fe5b 100644 --- a/containers/fabric-api/Dockerfile +++ b/containers/fabric-api/Dockerfile @@ -4,9 +4,6 @@ # build and version independently; the sidecar checks FRR's version at # runtime instead (see internal/fabric/frr/version.go). -# "network" is an empty stage by default -- see -# containers/galactic-router/Dockerfile's own comment on this stage. -FROM scratch AS network # fabric-api imports no eBPF packages, so unlike the other Go images this # needs no clang or go generate step. @@ -23,7 +20,6 @@ ARG GIT_URL=https://github.com/datum-cloud/galactic WORKDIR /workspace COPY go.mod go.mod COPY go.sum go.sum -COPY --from=network / /network RUN go mod download COPY api/ api/ diff --git a/containers/fabric-router/Dockerfile b/containers/fabric-router/Dockerfile index 776a7173..040c0ed4 100644 --- a/containers/fabric-router/Dockerfile +++ b/containers/fabric-router/Dockerfile @@ -5,9 +5,6 @@ # each node's own frr.conf from its fabric-router. ConfigMap. ARG FRR_VERSION=10.7.1 -# "network" is an empty stage by default -- see -# containers/galactic-router/Dockerfile's own comment on this stage. -FROM scratch AS network # Build fabric-config-agent. It imports no eBPF packages, so unlike the other # Go images this needs no clang or go generate step. @@ -24,7 +21,6 @@ ARG GIT_URL=https://github.com/datum-cloud/galactic WORKDIR /workspace COPY go.mod go.mod COPY go.sum go.sum -COPY --from=network / /network RUN go mod download COPY cmd/ cmd/ diff --git a/containers/galactic-cni/Dockerfile b/containers/galactic-cni/Dockerfile index 9734cd3c..0a168dc3 100644 --- a/containers/galactic-cni/Dockerfile +++ b/containers/galactic-cni/Dockerfile @@ -1,16 +1,3 @@ -# "network" is an empty stage by default -- overridden with a real -# go.datum.net/network checkout via `docker build --build-context -# network= ...` whenever go.mod carries a local -# `replace go.datum.net/network => ../network` directive (this repo's own -# convention for developing against unpublished network CRD changes before -# they're tagged -- see go.mod's own comment on that line). Copied to -# /network below, exactly matching what that relative replace path resolves -# to from /workspace/go.mod inside this build. Harmless when no such -# replace directive exists and no override is passed: go.mod then has -# nothing under /network to resolve, so an empty directory there is never -# read. -FROM scratch AS network - # Build the galactic CNI plugin FROM --platform=$BUILDPLATFORM golang:1.26 AS builder ARG TARGETOS @@ -27,10 +14,6 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# Local cross-repo development: satisfies go.mod's `replace -# go.datum.net/network => ../network`, when present -- see the "network" -# stage's own comment above for where this comes from. -COPY --from=network / /network # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download @@ -51,9 +34,9 @@ COPY internal/ internal/ # identical output across build sites isn't the goal here, a working BPF # program compiled from usid.c is. RUN apt-get update && apt-get install -y --no-install-recommends \ - clang llvm linux-libc-dev \ + clang-19 llvm-19 linux-libc-dev \ && rm -rf /var/lib/apt/lists/* -RUN go generate ./internal/plumbing/ebpf/prog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/prog/... # Build galactic-cni, the CNI installer: stages every chain binary onto the # host, writes the static conflist/kubeconfig, and (via "run") refreshes diff --git a/containers/galactic-controller/Dockerfile b/containers/galactic-controller/Dockerfile index 28d20588..3c1d186a 100644 --- a/containers/galactic-controller/Dockerfile +++ b/containers/galactic-controller/Dockerfile @@ -1,8 +1,3 @@ -# "network" is an empty stage by default; see containers/galactic-gateway's -# Dockerfile for how it satisfies a local `replace go.datum.net/network => -# ../network` directive during cross-repo development. -FROM scratch AS network - # Build the galactic-controller binary FROM --platform=$BUILDPLATFORM golang:1.26 AS builder ARG TARGETOS @@ -17,7 +12,6 @@ ARG GIT_URL=https://github.com/datum-cloud/galactic WORKDIR /workspace COPY go.mod go.mod COPY go.sum go.sum -COPY --from=network / /network RUN go mod download # galactic-controller imports no eBPF package, so unlike the node-side diff --git a/containers/galactic-gateway/Dockerfile b/containers/galactic-gateway/Dockerfile index 5f5fe7fc..5a3b48d3 100644 --- a/containers/galactic-gateway/Dockerfile +++ b/containers/galactic-gateway/Dockerfile @@ -1,16 +1,3 @@ -# "network" is an empty stage by default -- overridden with a real -# go.datum.net/network checkout via `docker build --build-context -# network= ...` whenever go.mod carries a local -# `replace go.datum.net/network => ../network` directive (this repo's own -# convention for developing against unpublished network CRD changes before -# they're tagged -- see go.mod's own comment on that line). Copied to -# /network below, exactly matching what that relative replace path resolves -# to from /workspace/go.mod inside this build. Harmless when no such -# replace directive exists and no override is passed: go.mod then has -# nothing under /network to resolve, so an empty directory there is never -# read. -FROM scratch AS network - # Build the galactic-gateway binary FROM --platform=$BUILDPLATFORM golang:1.26 AS builder ARG TARGETOS @@ -26,10 +13,6 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# Local cross-repo development: satisfies go.mod's `replace -# go.datum.net/network => ../network`, when present -- see the "network" -# stage's own comment above for where this comes from. -COPY --from=network / /network # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download @@ -47,13 +30,13 @@ COPY internal/ internal/ # doc.go), so both must be regenerated here, same as # containers/galactic-router/Dockerfile now does too. RUN apt-get update && apt-get install -y --no-install-recommends \ - clang llvm linux-libc-dev \ + clang-19 llvm-19 linux-libc-dev \ && rm -rf /var/lib/apt/lists/* -RUN go generate ./internal/plumbing/ebpf/prog/... -RUN go generate ./internal/plumbing/ebpf/edgeprog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/prog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/edgeprog/... # internal/plumbing/ebpf/xdpattach, which this binary attaches through, # links the shared XDP dispatcher's bindings too. -RUN go generate ./internal/plumbing/ebpf/xdpdispatch/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/xdpdispatch/... # Build galactic-gateway RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ diff --git a/containers/galactic-nat/Dockerfile b/containers/galactic-nat/Dockerfile index fc0597ef..9f24bcec 100644 --- a/containers/galactic-nat/Dockerfile +++ b/containers/galactic-nat/Dockerfile @@ -1,16 +1,3 @@ -# "network" is an empty stage by default -- overridden with a real -# go.datum.net/network checkout via `docker build --build-context -# network= ...` whenever go.mod carries a local -# `replace go.datum.net/network => ../network` directive (this repo's own -# convention for developing against unpublished network CRD changes before -# they're tagged -- see go.mod's own comment on that line). Copied to -# /network below, exactly matching what that relative replace path resolves -# to from /workspace/go.mod inside this build. Harmless when no such -# replace directive exists and no override is passed: go.mod then has -# nothing under /network to resolve, so an empty directory there is never -# read. -FROM scratch AS network - # Build the galactic-nat binary FROM --platform=$BUILDPLATFORM golang:1.26 AS builder ARG TARGETOS @@ -26,10 +13,6 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# Local cross-repo development: satisfies go.mod's `replace -# go.datum.net/network => ../network`, when present -- see the "network" -# stage's own comment above for where this comes from. -COPY --from=network / /network # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download @@ -58,12 +41,12 @@ COPY internal/ internal/ # Dropping any one of these turns into an "undefined: " build # failure in the go build step below, not a missing-file error. RUN apt-get update && apt-get install -y --no-install-recommends \ - clang llvm linux-libc-dev \ + clang-19 llvm-19 linux-libc-dev \ && rm -rf /var/lib/apt/lists/* -RUN go generate ./internal/plumbing/ebpf/prog/... -RUN go generate ./internal/plumbing/ebpf/edgeprog/... -RUN go generate ./internal/plumbing/ebpf/natprog/... -RUN go generate ./internal/plumbing/ebpf/xdpdispatch/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/prog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/edgeprog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/natprog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/xdpdispatch/... # Build galactic-nat RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ diff --git a/containers/galactic-router/Dockerfile b/containers/galactic-router/Dockerfile index b32af15a..4ee0d6c1 100644 --- a/containers/galactic-router/Dockerfile +++ b/containers/galactic-router/Dockerfile @@ -1,16 +1,3 @@ -# "network" is an empty stage by default -- overridden with a real -# go.datum.net/network checkout via `docker build --build-context -# network= ...` whenever go.mod carries a local -# `replace go.datum.net/network => ../network` directive (this repo's own -# convention for developing against unpublished network CRD changes before -# they're tagged -- see go.mod's own comment on that line). Copied to -# /network below, exactly matching what that relative replace path resolves -# to from /workspace/go.mod inside this build. Harmless when no such -# replace directive exists and no override is passed: go.mod then has -# nothing under /network to resolve, so an empty directory there is never -# read. -FROM scratch AS network - # Build the galactic router binary FROM --platform=$BUILDPLATFORM golang:1.26 AS builder ARG TARGETOS @@ -26,10 +13,6 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# Local cross-repo development: satisfies go.mod's `replace -# go.datum.net/network => ../network`, when present -- see the "network" -# stage's own comment above for where this comes from. -COPY --from=network / /network # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download @@ -40,17 +23,17 @@ COPY api/ api/ COPY internal/ internal/ # galactic-router transitively imports both internal/plumbing/ebpf/prog -# (via usidmap) and, via the NetworkGateway/NetworkRule reconcilers' +# (via usidmap) and, via the NetworkGateway/NetworkService reconcilers' # internal/gateway.Engine, internal/plumbing/ebpf/edgemap's # internal/plumbing/ebpf/edgeprog -- neither package's bpf2go output # (*_bpfel.go/.o, *_bpfeb.go/.o) is committed to git (see each package's # doc.go), so both must be regenerated here, same as # containers/galactic-gateway/Dockerfile does. RUN apt-get update && apt-get install -y --no-install-recommends \ - clang llvm linux-libc-dev \ + clang-19 llvm-19 linux-libc-dev \ && rm -rf /var/lib/apt/lists/* -RUN go generate ./internal/plumbing/ebpf/prog/... -RUN go generate ./internal/plumbing/ebpf/edgeprog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/prog/... +RUN PATH=/usr/lib/llvm-19/bin:$PATH BPF2GO_CC=clang-19 go generate ./internal/plumbing/ebpf/edgeprog/... # Build the router RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ diff --git a/deploy/containerlab/AGENTS.md b/deploy/containerlab/AGENTS.md index 2ab1064f..93b6e657 100644 --- a/deploy/containerlab/AGENTS.md +++ b/deploy/containerlab/AGENTS.md @@ -37,7 +37,7 @@ - **iad-control** — `iad-worker3`, the lab's single EVPN route reflector; every galactic-router in every cluster peers with it. galactic-router + BGP CRDs in `resources/galactic-control/iad/` (one `bgppeer-.yaml` per client), fabric in `resources/fabric-router/iad/` (`frr.conf.iad-worker3`). - **Edge** — the four `nat=enabled` workers (`dfw-worker2`, `dfw-worker3`, `sjc-worker2`, `iad-worker2`): each site's transit-facing nodes, the uplinks its compute node routes through, the egress shards and the VIP gateways. Their `BGPRouter` and route-reflector `BGPPeer` live alongside the compute node's in `resources/galactic-router//` (`bgprouter-.yaml`, `bgppeer-.yaml`). - **Egress** — `galactic-nat`, one egress shard per edge node, running from that node's XDP dispatcher on `bond0`/`bond1`. One per-site overlay under `resources/galactic-nat//` (uplink override + one `EgressShard` per edge node; dfw has two). Each site's compute node lists only its own site's shard SIDs (`resources/galactic-cni//egress-shards-patch.yaml`), and each edge node's `frr.conf` originates its shard's SID `/64` and masquerade addresses. -- **Ingress** — `galactic-gateway`, one DaemonSet per edge node (`resources/galactic-gateway//`: hostname pin, `PUBLIC_INTERFACE=bond0`, `INTERNAL_INTERFACES=bond1`, `SRV6_ADDRESS` left unset so the gateway derives the node's own uSID from its `BGPRouter`, and its `NetworkGateway`). Each site directory (`resources/galactic-gateway//`) adds the `ns60-tcp`/`ns60-udp` `NetworkRule`s for that site's own VIP, which select the site's two `ns60` backends by label through their `VPCAttachment`s (`resources/tenants/ns60//vpcattachments.yaml`, fixed addresses from their NADs; `scripts/publish-attachments.sh` writes their status, as it does every tenant's, the attachment controller's job in production). The compute node's `galactic-router` writes one `ServiceVIPBinding` per rule per backend; none is hand-written. Each edge node's `frr.conf` originates its site's VIP `/64` (`2001:db8:6060:{1,2,3}::/64` for dfw, sjc, iad) to `Null0`. Both backends share the site's one compute node on purpose: the gateway carries Maglev's choice to the node in the SRv6 destination's slot, so each gets its own flows (#799). +- **Ingress** — `galactic-gateway`, one DaemonSet per edge node (`resources/galactic-gateway//`: hostname pin, `PUBLIC_INTERFACE=bond0`, `INTERNAL_INTERFACES=bond1`, `SRV6_ADDRESS` left unset so the gateway derives the node's own uSID from its `BGPRouter`, and its `NetworkGateway`). Each site directory (`resources/galactic-gateway//`) adds the `ns60-tcp`/`ns60-udp` `NetworkService`s for that site's own VIP, which select the site's two `ns60` backends by label through their `VPCAttachment`s (`resources/tenants/ns60//vpcattachments.yaml`, fixed addresses from their NADs; `scripts/publish-attachments.sh` writes their status, as it does every tenant's, the attachment controller's job in production). The compute node's `galactic-router` writes one `ServiceVIPBinding` per rule per backend; none is hand-written. Each edge node's `frr.conf` originates its site's VIP `/64` (`2001:db8:6060:{1,2,3}::/64` for dfw, sjc, iad) to `Null0`. Both backends share the site's one compute node on purpose: the gateway carries Maglev's choice to the node in the SRv6 destination's slot, so each gets its own flows (#799). ## References diff --git a/deploy/containerlab/README.md b/deploy/containerlab/README.md index 716951eb..adb899cc 100644 --- a/deploy/containerlab/README.md +++ b/deploy/containerlab/README.md @@ -133,7 +133,7 @@ nodes, plus `galactic-nat`, the egress shard, and `galactic-gateway`, the VIP lo can restart without detaching the other. Each site has its own VIP `/64` (`2001:db8:6060:1::/64` for dfw, `:2::` for sjc, `:3::` for iad), originated only by that site's edge nodes and served by two `ns60` backends on the site's compute node, which the -site's `NetworkRule`s select by label through their `VPCAttachment`s. +site's `NetworkService`s select by label through their `VPCAttachment`s. Compute nodes run no shard: each egresses through its own site's edge shards only. The reflector runs neither `galactic-cni` nor a shard — its `galactic=control` label is mutually exclusive with the `galactic=router` value that pulls @@ -417,7 +417,7 @@ deploy/containerlab/ │ ├── bmp-collector/iad/ # the lab's one BMP collector, on the reflector's host network │ ├── galactic-nat/ # egress shards per site, one per edge node (dfw has two) │ ├── galactic-gateway/ # one gateway overlay per edge node, plus each site's -│ │ # ns60 NetworkRules (backends picked by selector) +│ │ # ns60 NetworkServices (backends picked by selector) │ ├── galactic-controller/ # traffic policy's cell controller, on the host network │ └── tenants/ # test VPCs — one shared base/ (Namespace + netshoot │ ├── base/ # Deployment), each tenant patching its namespace and @@ -514,7 +514,7 @@ task deploy | `verify:fabric-metrics` | Scrape every fabric-router's frr-exporter; fail on a failed collector or a session not Established | | `verify:bmp` | Check the route reflector and every fabric-router stream to the BMP collector, and that it agrees with each on every session's state | | `verify:underlay` | Ping every underlay loopback from tr1 over both IPv4 and IPv6 | -| `verify:gateway` | Check every NetworkGateway is Ready, every NetworkRule Accepted, every generated ServiceVIPBinding Bound | +| `verify:gateway` | Check every NetworkGateway is Ready, every NetworkService Accepted, every generated ServiceVIPBinding Bound | | `verify:gateway-ingress` | TCP and UDP from the off-fabric host to each site's VIP, through every gateway node in turn | | `verify:gateway-restart` | Restart dfw-worker2's gateway and shard in turn while the other carries traffic (#710) | | `verify:gateway-detach` | Turn dfw-worker2's gateway datapath off: the ingress check must fail, then pass (not in `verify`) | diff --git a/deploy/containerlab/Taskfile.yaml b/deploy/containerlab/Taskfile.yaml index 3152aa67..2cfef36b 100644 --- a/deploy/containerlab/Taskfile.yaml +++ b/deploy/containerlab/Taskfile.yaml @@ -36,47 +36,37 @@ tasks: # No --build-arg FRR_VERSION: the lab builds the FRR version the # Dockerfile pins, the same one the published image ships to production. cmds: - - docker build --network=host --build-context network=../../../network -t {{.FABRIC_ROUTER_IMAGE}} -f ../../containers/fabric-router/Dockerfile ../.. + - docker build --network=host -t {{.FABRIC_ROUTER_IMAGE}} -f ../../containers/fabric-router/Dockerfile ../.. "build:fabric-api": desc: Build the fabric-api image (node sidecar, cell gateway, janitor) cmds: - - docker build --network=host --build-context network=../../../network -t fabric-api:latest -f ../../containers/fabric-api/Dockerfile ../.. - - # --build-context network=../../../network satisfies go.mod's local - # `replace go.datum.net/network => ../network` (see that line's own - # comment) inside each Dockerfile's "network" build stage -- see - # containers/galactic-{router,cni,nat,gateway}/Dockerfile's own comment on - # that stage for the full mechanism. ../../../network is this Taskfile's - # own cwd (deploy/containerlab) relative to the network repo checked out - # as galactic's sibling; harmless to pass even once that replace - # directive is eventually removed, since the Dockerfile's "network" - # stage is unused by go.mod at that point regardless of what's passed - # here. + - docker build --network=host -t fabric-api:latest -f ../../containers/fabric-api/Dockerfile ../.. + "build:galactic-router": desc: Build the galactic-router container image cmds: - - docker build --network=host --build-context network=../../../network -t galactic-router:latest -f ../../containers/galactic-router/Dockerfile ../.. + - docker build --network=host -t galactic-router:latest -f ../../containers/galactic-router/Dockerfile ../.. "build:galactic-cni": desc: Build the galactic-cni installer image cmds: - - docker build --network=host --build-context network=../../../network -t galactic-cni:latest -f ../../containers/galactic-cni/Dockerfile ../.. + - docker build --network=host -t galactic-cni:latest -f ../../containers/galactic-cni/Dockerfile ../.. "build:galactic-nat": desc: Build the galactic-nat egress datapath image cmds: - - docker build --network=host --build-context network=../../../network -t galactic-nat:latest -f ../../containers/galactic-nat/Dockerfile ../.. + - docker build --network=host -t galactic-nat:latest -f ../../containers/galactic-nat/Dockerfile ../.. "build:galactic-gateway": desc: Build the galactic-gateway edge load balancer image cmds: - - docker build --network=host --build-context network=../../../network -t galactic-gateway:latest -f ../../containers/galactic-gateway/Dockerfile ../.. + - docker build --network=host -t galactic-gateway:latest -f ../../containers/galactic-gateway/Dockerfile ../.. "build:galactic-controller": desc: Build the galactic-controller image (traffic policy's cell role) cmds: - - docker build --network=host --build-context network=../../../network -t galactic-controller:latest -f ../../containers/galactic-controller/Dockerfile ../.. + - docker build --network=host -t galactic-controller:latest -f ../../containers/galactic-controller/Dockerfile ../.. "build:remote-host": desc: Build the off-fabric remote host image (nginx) @@ -110,7 +100,7 @@ tasks: - task: "deploy:galactic-router" - task: "deploy:galactic-nat" - task: "deploy:scenarios" - # After the scenarios: the gateway's NetworkRules select ns60's + # After the scenarios: the gateway's NetworkServices select ns60's # backends, which deploy:scenarios creates. - task: "deploy:galactic-gateway" - task: "deploy:fabric-api" @@ -549,7 +539,7 @@ tasks: for site in dfw sjc iad; do cp=${site}-control-plane echo "--- ${site} ---" - docker exec ${cp} kubectl -n galactic-system get networkgateways,networkrules,servicevipbindings + docker exec ${cp} kubectl -n galactic-system get networkgateways,networkservices,servicevipbindings # Ready: the engine loaded every rule and advertised it. Accepted: # the rule found a gateway. Bound: the backend's node rewrites the # VIP for it. Each backend's node writes one binding per rule per @@ -562,7 +552,7 @@ tasks: echo "FAIL ${site}: ${bindings} generated ServiceVIPBindings, want 4" >&2 rc=1 fi - for check in "networkgateway Ready --all" "networkrule Accepted --all" "servicevipbinding Bound -l=${generated}"; do + for check in "networkgateway Ready --all" "networkservice Accepted --all" "servicevipbinding Bound -l=${generated}"; do set -- ${check} if ! docker exec ${cp} kubectl -n galactic-system wait $1 $3 \ --for=condition=$2 --timeout=10s >/dev/null; then diff --git a/deploy/containerlab/docs/verification.md b/deploy/containerlab/docs/verification.md index d69fc467..0331ecdc 100644 --- a/deploy/containerlab/docs/verification.md +++ b/deploy/containerlab/docs/verification.md @@ -201,7 +201,7 @@ and a `NetworkGateway` named after the node. Each site has its own VIP (`2001:db8:6060:1::1` in dfw, `:2::1` in sjc, `:3::1` in iad), in a `/64` only that site's edge nodes originate, and two `ns60` backends on its compute node that answer TCP and UDP on port 80 with their pod name and the client address -they saw. The site's `NetworkRule`s select both through their +they saw. The site's `NetworkService`s select both through their `VPCAttachment`s, and the compute node writes one `ServiceVIPBinding` per rule per backend, labelled `app.kubernetes.io/managed-by=galactic-router`. `verify:gateway` counts and checks only those, and `deploy:galactic-gateway` @@ -210,7 +210,7 @@ an older lab applied. `verify:gateway-ingress` fails unless both backends answer. ```bash -# NetworkGateways Ready, NetworkRules Accepted, ServiceVIPBindings Bound +# NetworkGateways Ready, NetworkServices Accepted, ServiceVIPBindings Bound task verify:gateway # TCP and UDP from remote-host to every site's VIP, pinned through each diff --git a/deploy/containerlab/resources/fabric-router/dfw/frr.conf.dfw-worker2 b/deploy/containerlab/resources/fabric-router/dfw/frr.conf.dfw-worker2 index 2122e0ed..6d96d26f 100644 --- a/deploy/containerlab/resources/fabric-router/dfw/frr.conf.dfw-worker2 +++ b/deploy/containerlab/resources/fabric-router/dfw/frr.conf.dfw-worker2 @@ -21,7 +21,7 @@ interface bond1 ! # This site's ingress VIP prefix (resources/galactic-gateway/dfw/), the -# /64 every VIP in dfw's NetworkRules sits in. A VIP's own reachability is +# /64 every VIP in dfw's NetworkServices sits in. A VIP's own reachability is # carried as an EVPN Type 5 path inside galactic-router's iBGP mesh, which the # plain-IPv6-unicast transit never sees, so without this the off-fabric host # (remote-host on tr4) has no route to any VIP at all. diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker.yaml index 97803c21..e6a48d1b 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward dfw-worker (dfw compute) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-dfw-worker diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker2.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker2.yaml index 86f16e6d..9c5e001c 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker2.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker2.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward dfw-worker2 (dfw edge) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-dfw-worker2 diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker3.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker3.yaml index 00123659..92acc73e 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker3.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-dfw-worker3.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward dfw-worker3 (dfw edge) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-dfw-worker3 diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker.yaml index 7d1d2cf9..f9b4786a 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward iad-worker (iad compute) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-iad-worker diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker2.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker2.yaml index 1bad4e80..4bec7700 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker2.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-iad-worker2.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward iad-worker2 (iad edge) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-iad-worker2 diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker.yaml index ad7c2b38..fb2b2f71 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward sjc-worker (sjc compute) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-sjc-worker diff --git a/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker2.yaml b/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker2.yaml index b8992758..c32bde32 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker2.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgppeer-sjc-worker2.yaml @@ -1,5 +1,5 @@ # Reflector-side session toward sjc-worker2 (sjc edge) -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-router-sjc-worker2 diff --git a/deploy/containerlab/resources/galactic-control/iad/bgprouter.yaml b/deploy/containerlab/resources/galactic-control/iad/bgprouter.yaml index 3ff5e105..5449aa93 100644 --- a/deploy/containerlab/resources/galactic-control/iad/bgprouter.yaml +++ b/deploy/containerlab/resources/galactic-control/iad/bgprouter.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-control diff --git a/deploy/containerlab/resources/galactic-gateway/dfw-worker2/networkgateway.yaml b/deploy/containerlab/resources/galactic-gateway/dfw-worker2/networkgateway.yaml index da4cbcdc..8f03c9a6 100644 --- a/deploy/containerlab/resources/galactic-gateway/dfw-worker2/networkgateway.yaml +++ b/deploy/containerlab/resources/galactic-gateway/dfw-worker2/networkgateway.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: NetworkGateway metadata: name: dfw-worker2 diff --git a/deploy/containerlab/resources/galactic-gateway/dfw-worker3/networkgateway.yaml b/deploy/containerlab/resources/galactic-gateway/dfw-worker3/networkgateway.yaml index b32d3edd..47199ccf 100644 --- a/deploy/containerlab/resources/galactic-gateway/dfw-worker3/networkgateway.yaml +++ b/deploy/containerlab/resources/galactic-gateway/dfw-worker3/networkgateway.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: NetworkGateway metadata: name: dfw-worker3 diff --git a/deploy/containerlab/resources/galactic-gateway/dfw/kustomization.yaml b/deploy/containerlab/resources/galactic-gateway/dfw/kustomization.yaml index 717a8cfa..ccf38299 100644 --- a/deploy/containerlab/resources/galactic-gateway/dfw/kustomization.yaml +++ b/deploy/containerlab/resources/galactic-gateway/dfw/kustomization.yaml @@ -1,5 +1,5 @@ # dfw's ingress tier: a galactic-gateway and NetworkGateway on each of -# its edge nodes, and the NetworkRules for ns60, the site's backend tenant +# its edge nodes, and the NetworkServices for ns60, the site's backend tenant # (resources/tenants/ns60/dfw/). The VIP is dfw's own, and so are its # backends: a load balancer belongs to one region # (datum-cloud/enhancements#884). @@ -7,4 +7,4 @@ namespace: galactic-system resources: - ../dfw-worker2 - ../dfw-worker3 - - networkrules.yaml + - networkservices.yaml diff --git a/deploy/containerlab/resources/galactic-gateway/dfw/networkrules.yaml b/deploy/containerlab/resources/galactic-gateway/dfw/networkservices.yaml similarity index 82% rename from deploy/containerlab/resources/galactic-gateway/dfw/networkrules.yaml rename to deploy/containerlab/resources/galactic-gateway/dfw/networkservices.yaml index 57ab067a..7913b2ff 100644 --- a/deploy/containerlab/resources/galactic-gateway/dfw/networkrules.yaml +++ b/deploy/containerlab/resources/galactic-gateway/dfw/networkservices.yaml @@ -1,6 +1,6 @@ # ns60's port 80 over TCP, served by every ns60 backend its selector picks in dfw. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-tcp namespace: galactic-system @@ -16,8 +16,8 @@ spec: backendPort: 80 --- # ns60's port 80 over UDP, served by every ns60 backend its selector picks in dfw. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-udp namespace: galactic-system diff --git a/deploy/containerlab/resources/galactic-gateway/iad-worker2/networkgateway.yaml b/deploy/containerlab/resources/galactic-gateway/iad-worker2/networkgateway.yaml index 6477661e..dc3b47c1 100644 --- a/deploy/containerlab/resources/galactic-gateway/iad-worker2/networkgateway.yaml +++ b/deploy/containerlab/resources/galactic-gateway/iad-worker2/networkgateway.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: NetworkGateway metadata: name: iad-worker2 diff --git a/deploy/containerlab/resources/galactic-gateway/iad/kustomization.yaml b/deploy/containerlab/resources/galactic-gateway/iad/kustomization.yaml index 8b5c4f8d..d612813a 100644 --- a/deploy/containerlab/resources/galactic-gateway/iad/kustomization.yaml +++ b/deploy/containerlab/resources/galactic-gateway/iad/kustomization.yaml @@ -1,9 +1,9 @@ # iad's ingress tier: a galactic-gateway and NetworkGateway on each of -# its edge nodes, and the NetworkRules for ns60, the site's backend tenant +# its edge nodes, and the NetworkServices for ns60, the site's backend tenant # (resources/tenants/ns60/iad/). The VIP is iad's own, and so are its # backends: a load balancer belongs to one region # (datum-cloud/enhancements#884). namespace: galactic-system resources: - ../iad-worker2 - - networkrules.yaml + - networkservices.yaml diff --git a/deploy/containerlab/resources/galactic-gateway/iad/networkrules.yaml b/deploy/containerlab/resources/galactic-gateway/iad/networkservices.yaml similarity index 82% rename from deploy/containerlab/resources/galactic-gateway/iad/networkrules.yaml rename to deploy/containerlab/resources/galactic-gateway/iad/networkservices.yaml index 8d02c04f..731154b2 100644 --- a/deploy/containerlab/resources/galactic-gateway/iad/networkrules.yaml +++ b/deploy/containerlab/resources/galactic-gateway/iad/networkservices.yaml @@ -1,6 +1,6 @@ # ns60's port 80 over TCP, served by every ns60 backend its selector picks in iad. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-tcp namespace: galactic-system @@ -16,8 +16,8 @@ spec: backendPort: 80 --- # ns60's port 80 over UDP, served by every ns60 backend its selector picks in iad. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-udp namespace: galactic-system diff --git a/deploy/containerlab/resources/galactic-gateway/sjc-worker2/networkgateway.yaml b/deploy/containerlab/resources/galactic-gateway/sjc-worker2/networkgateway.yaml index acf6b6f4..a215df24 100644 --- a/deploy/containerlab/resources/galactic-gateway/sjc-worker2/networkgateway.yaml +++ b/deploy/containerlab/resources/galactic-gateway/sjc-worker2/networkgateway.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: NetworkGateway metadata: name: sjc-worker2 diff --git a/deploy/containerlab/resources/galactic-gateway/sjc/kustomization.yaml b/deploy/containerlab/resources/galactic-gateway/sjc/kustomization.yaml index e77ce317..e9bdc3cd 100644 --- a/deploy/containerlab/resources/galactic-gateway/sjc/kustomization.yaml +++ b/deploy/containerlab/resources/galactic-gateway/sjc/kustomization.yaml @@ -1,9 +1,9 @@ # sjc's ingress tier: a galactic-gateway and NetworkGateway on each of -# its edge nodes, and the NetworkRules for ns60, the site's backend tenant +# its edge nodes, and the NetworkServices for ns60, the site's backend tenant # (resources/tenants/ns60/sjc/). The VIP is sjc's own, and so are its # backends: a load balancer belongs to one region # (datum-cloud/enhancements#884). namespace: galactic-system resources: - ../sjc-worker2 - - networkrules.yaml + - networkservices.yaml diff --git a/deploy/containerlab/resources/galactic-gateway/sjc/networkrules.yaml b/deploy/containerlab/resources/galactic-gateway/sjc/networkservices.yaml similarity index 82% rename from deploy/containerlab/resources/galactic-gateway/sjc/networkrules.yaml rename to deploy/containerlab/resources/galactic-gateway/sjc/networkservices.yaml index 90660701..4811c7fc 100644 --- a/deploy/containerlab/resources/galactic-gateway/sjc/networkrules.yaml +++ b/deploy/containerlab/resources/galactic-gateway/sjc/networkservices.yaml @@ -1,6 +1,6 @@ # ns60's port 80 over TCP, served by every ns60 backend its selector picks in sjc. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-tcp namespace: galactic-system @@ -16,8 +16,8 @@ spec: backendPort: 80 --- # ns60's port 80 over UDP, served by every ns60 backend its selector picks in sjc. -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-udp namespace: galactic-system diff --git a/deploy/containerlab/resources/galactic-nat/README.md b/deploy/containerlab/resources/galactic-nat/README.md index 4b32934b..856c4c2b 100644 --- a/deploy/containerlab/resources/galactic-nat/README.md +++ b/deploy/containerlab/resources/galactic-nat/README.md @@ -69,7 +69,7 @@ the rollout: a shard reports ready once attached, before its identity is programmed. `task deploy:galactic-gateway` runs later, after `deploy:scenarios`: the -gateway's `NetworkRule`s select the `ns60` backends that step creates. +gateway's `NetworkService`s select the `ns60` backends that step creates. Because the shard and the gateway share the uplinks through the dispatcher, neither has to be deployed first for the other to attach. diff --git a/deploy/containerlab/resources/galactic-nat/dfw/egressshard.yaml b/deploy/containerlab/resources/galactic-nat/dfw/egressshard.yaml index 191ba5b7..f1b35ba8 100644 --- a/deploy/containerlab/resources/galactic-nat/dfw/egressshard.yaml +++ b/deploy/containerlab/resources/galactic-nat/dfw/egressshard.yaml @@ -1,6 +1,6 @@ # One shard per edge node: dfw has two. Compute # lists dfw-worker2's first (resources/galactic-cni/dfw/). -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: EgressShard metadata: name: dfw-worker2-egress @@ -21,7 +21,7 @@ spec: shardAddressIPv4: "192.0.2.1" nat64Prefix: "2001:db8:64::/96" --- -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: EgressShard metadata: name: dfw-worker3-egress diff --git a/deploy/containerlab/resources/galactic-nat/iad/egressshard.yaml b/deploy/containerlab/resources/galactic-nat/iad/egressshard.yaml index 04495951..386c04e2 100644 --- a/deploy/containerlab/resources/galactic-nat/iad/egressshard.yaml +++ b/deploy/containerlab/resources/galactic-nat/iad/egressshard.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: EgressShard metadata: name: iad-worker2-egress diff --git a/deploy/containerlab/resources/galactic-nat/sjc/egressshard.yaml b/deploy/containerlab/resources/galactic-nat/sjc/egressshard.yaml index 20ce99d2..f8ec01a3 100644 --- a/deploy/containerlab/resources/galactic-nat/sjc/egressshard.yaml +++ b/deploy/containerlab/resources/galactic-nat/sjc/egressshard.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: EgressShard metadata: name: sjc-worker2-egress diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker2.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker2.yaml index e9c058d1..f480c271 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker2.yaml @@ -1,5 +1,5 @@ # Client session to the lab's single EVPN route reflector. -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-control-dfw-worker2 diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker3.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker3.yaml index 2174e51b..34085968 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker3.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgppeer-dfw-worker3.yaml @@ -1,5 +1,5 @@ # Client session to the lab's single EVPN route reflector. -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-control-dfw-worker3 diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgppeer.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgppeer.yaml index 04b43c48..bb385933 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgppeer.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgppeer.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-route-reflector-iad diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker2.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker2.yaml index cff39d00..835ec821 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker2.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router-dfw-worker2 diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker3.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker3.yaml index 50faae52..0dbd7e8f 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker3.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgprouter-dfw-worker3.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router-dfw-worker3 diff --git a/deploy/containerlab/resources/galactic-router/dfw/bgprouter.yaml b/deploy/containerlab/resources/galactic-router/dfw/bgprouter.yaml index 0bb80f2c..c0683154 100644 --- a/deploy/containerlab/resources/galactic-router/dfw/bgprouter.yaml +++ b/deploy/containerlab/resources/galactic-router/dfw/bgprouter.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router diff --git a/deploy/containerlab/resources/galactic-router/iad/bgppeer-iad-worker2.yaml b/deploy/containerlab/resources/galactic-router/iad/bgppeer-iad-worker2.yaml index 31db4c5a..5a7c2087 100644 --- a/deploy/containerlab/resources/galactic-router/iad/bgppeer-iad-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/iad/bgppeer-iad-worker2.yaml @@ -1,5 +1,5 @@ # Client session to the lab's single EVPN route reflector. -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-control-iad-worker2 diff --git a/deploy/containerlab/resources/galactic-router/iad/bgppeer.yaml b/deploy/containerlab/resources/galactic-router/iad/bgppeer.yaml index 04b43c48..bb385933 100644 --- a/deploy/containerlab/resources/galactic-router/iad/bgppeer.yaml +++ b/deploy/containerlab/resources/galactic-router/iad/bgppeer.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-route-reflector-iad diff --git a/deploy/containerlab/resources/galactic-router/iad/bgprouter-iad-worker2.yaml b/deploy/containerlab/resources/galactic-router/iad/bgprouter-iad-worker2.yaml index 6a70d1aa..0a82cb06 100644 --- a/deploy/containerlab/resources/galactic-router/iad/bgprouter-iad-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/iad/bgprouter-iad-worker2.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router-iad-worker2 diff --git a/deploy/containerlab/resources/galactic-router/iad/bgprouter.yaml b/deploy/containerlab/resources/galactic-router/iad/bgprouter.yaml index 023aada6..fb76e8d5 100644 --- a/deploy/containerlab/resources/galactic-router/iad/bgprouter.yaml +++ b/deploy/containerlab/resources/galactic-router/iad/bgprouter.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router diff --git a/deploy/containerlab/resources/galactic-router/sjc/bgppeer-sjc-worker2.yaml b/deploy/containerlab/resources/galactic-router/sjc/bgppeer-sjc-worker2.yaml index 727f5b8e..ca160896 100644 --- a/deploy/containerlab/resources/galactic-router/sjc/bgppeer-sjc-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/sjc/bgppeer-sjc-worker2.yaml @@ -1,5 +1,5 @@ # Client session to the lab's single EVPN route reflector. -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-control-sjc-worker2 diff --git a/deploy/containerlab/resources/galactic-router/sjc/bgppeer.yaml b/deploy/containerlab/resources/galactic-router/sjc/bgppeer.yaml index 04b43c48..bb385933 100644 --- a/deploy/containerlab/resources/galactic-router/sjc/bgppeer.yaml +++ b/deploy/containerlab/resources/galactic-router/sjc/bgppeer.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-route-reflector-iad diff --git a/deploy/containerlab/resources/galactic-router/sjc/bgprouter-sjc-worker2.yaml b/deploy/containerlab/resources/galactic-router/sjc/bgprouter-sjc-worker2.yaml index d387cc61..bea93968 100644 --- a/deploy/containerlab/resources/galactic-router/sjc/bgprouter-sjc-worker2.yaml +++ b/deploy/containerlab/resources/galactic-router/sjc/bgprouter-sjc-worker2.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router-sjc-worker2 diff --git a/deploy/containerlab/resources/galactic-router/sjc/bgprouter.yaml b/deploy/containerlab/resources/galactic-router/sjc/bgprouter.yaml index 4c3402aa..7e916a23 100644 --- a/deploy/containerlab/resources/galactic-router/sjc/bgprouter.yaml +++ b/deploy/containerlab/resources/galactic-router/sjc/bgprouter.yaml @@ -1,4 +1,4 @@ -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router diff --git a/deploy/containerlab/resources/tenants/ns60/base/backend.yaml b/deploy/containerlab/resources/tenants/ns60/base/backend.yaml index 4d7960f1..600c631a 100644 --- a/deploy/containerlab/resources/tenants/ns60/base/backend.yaml +++ b/deploy/containerlab/resources/tenants/ns60/base/backend.yaml @@ -1,4 +1,4 @@ -# ns60's VIP backends, two per site, behind that site's NetworkRules +# ns60's VIP backends, two per site, behind that site's NetworkServices # (resources/galactic-gateway//). Each answers TCP and UDP on port 80 # with its own pod name and the address it saw the client as, which is how # scripts/verify-gateway-ingress.sh tells which backend answered and checks diff --git a/deploy/containerlab/resources/tenants/ns60/dfw/vpcattachments.yaml b/deploy/containerlab/resources/tenants/ns60/dfw/vpcattachments.yaml index 6e308ef2..94b48dc5 100644 --- a/deploy/containerlab/resources/tenants/ns60/dfw/vpcattachments.yaml +++ b/deploy/containerlab/resources/tenants/ns60/dfw/vpcattachments.yaml @@ -1,10 +1,10 @@ # The VPCAttachments for ns60's two backends in dfw, which the site's -# NetworkRules select by label (resources/galactic-gateway/dfw/). +# NetworkServices select by label (resources/galactic-gateway/dfw/). # In production the attachment controller writes these and their status. The # lab has no such controller, so deploy:ns60 applies them here and then # writes each one's status from where its pod actually runs # (scripts/publish-attachments.sh). -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-a @@ -21,7 +21,7 @@ spec: addresses: - fd20:60:ff01:a::100/96 --- -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-b diff --git a/deploy/containerlab/resources/tenants/ns60/iad/vpcattachments.yaml b/deploy/containerlab/resources/tenants/ns60/iad/vpcattachments.yaml index a4764999..6acafaf7 100644 --- a/deploy/containerlab/resources/tenants/ns60/iad/vpcattachments.yaml +++ b/deploy/containerlab/resources/tenants/ns60/iad/vpcattachments.yaml @@ -1,10 +1,10 @@ # The VPCAttachments for ns60's two backends in iad, which the site's -# NetworkRules select by label (resources/galactic-gateway/iad/). +# NetworkServices select by label (resources/galactic-gateway/iad/). # In production the attachment controller writes these and their status. The # lab has no such controller, so deploy:ns60 applies them here and then # writes each one's status from where its pod actually runs # (scripts/publish-attachments.sh). -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-a @@ -21,7 +21,7 @@ spec: addresses: - fd20:60:ff03:a::100/96 --- -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-b diff --git a/deploy/containerlab/resources/tenants/ns60/sjc/vpcattachments.yaml b/deploy/containerlab/resources/tenants/ns60/sjc/vpcattachments.yaml index 95a13e55..e1702faa 100644 --- a/deploy/containerlab/resources/tenants/ns60/sjc/vpcattachments.yaml +++ b/deploy/containerlab/resources/tenants/ns60/sjc/vpcattachments.yaml @@ -1,10 +1,10 @@ # The VPCAttachments for ns60's two backends in sjc, which the site's -# NetworkRules select by label (resources/galactic-gateway/sjc/). +# NetworkServices select by label (resources/galactic-gateway/sjc/). # In production the attachment controller writes these and their status. The # lab has no such controller, so deploy:ns60 applies them here and then # writes each one's status from where its pod actually runs # (scripts/publish-attachments.sh). -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-a @@ -21,7 +21,7 @@ spec: addresses: - fd20:60:ff02:a::100/96 --- -apiVersion: cloud.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: VPCAttachment metadata: name: backend-b diff --git a/deploy/containerlab/scripts/deploy-fabric-api.sh b/deploy/containerlab/scripts/deploy-fabric-api.sh index aa9d2934..7090ef80 100755 --- a/deploy/containerlab/scripts/deploy-fabric-api.sh +++ b/deploy/containerlab/scripts/deploy-fabric-api.sh @@ -20,7 +20,7 @@ for site in dfw iad sjc; do # The CRD is the lab's copy until the kind ships in the network # repository; infra owns it in production. k apply --server-side -f "${CONFIG_DIR}/fabric-api/crd/" - k wait --for condition=established --timeout=60s crd/fabricqueries.network.datumapis.com + k wait --for condition=established --timeout=60s crd/fabricqueries.galactic.datumapis.com k apply -k "${CONFIG_DIR}/fabric-api/" sed "s/CELL_NAME/${site}/g" "${RESOURCES_DIR}/fabric-api/pki/pki.yaml" | k apply -f - k -n galactic-system wait --for condition=Ready --timeout=120s certificate/fabric-api-ca certificate/fabric-api-operator diff --git a/deploy/containerlab/scripts/deploy-galactic-gateway.sh b/deploy/containerlab/scripts/deploy-galactic-gateway.sh index c2bf4f79..385e6f7d 100755 --- a/deploy/containerlab/scripts/deploy-galactic-gateway.sh +++ b/deploy/containerlab/scripts/deploy-galactic-gateway.sh @@ -1,6 +1,6 @@ #!/bin/bash # deploy-galactic-gateway.sh — Install galactic-gateway on every edge node, one -# NetworkGateway per node, and each site's ns60 NetworkRules +# NetworkGateway per node, and each site's ns60 NetworkServices # (resources/galactic-gateway/). Requires deploy:system # (the gateway's RBAC/ServiceAccount), deploy:images (galactic-gateway:latest # on every edge node), deploy:galactic-router (each node's BGPRouter, which @@ -43,7 +43,7 @@ copy_gateway_config() { NS60_BINDINGS=4 # GENERATED_BINDINGS selects the ServiceVIPBindings galactic-router writes -# from the NetworkRules. Any other binding in galactic-system is stale. +# from the NetworkServices. Any other binding in galactic-system is stale. GENERATED_BINDINGS=app.kubernetes.io/managed-by=galactic-router # wait_bindings NODE COUNT TIMEOUT waits up to TIMEOUT seconds for exactly @@ -85,7 +85,7 @@ for site in dfw sjc iad; do # its accepted rule, so wait for all of them to exist first. docker exec "${node}" kubectl -n galactic-system wait networkgateway --all \ --for=condition=Ready --timeout=120s - docker exec "${node}" kubectl -n galactic-system wait networkrule --all \ + docker exec "${node}" kubectl -n galactic-system wait networkservice --all \ --for=condition=Accepted --timeout=60s wait_bindings "${node}" "${NS60_BINDINGS}" 60 docker exec "${node}" kubectl -n galactic-system wait servicevipbinding \ diff --git a/deploy/containerlab/scripts/deploy-system.sh b/deploy/containerlab/scripts/deploy-system.sh index 5e412046..9f9514c7 100755 --- a/deploy/containerlab/scripts/deploy-system.sh +++ b/deploy/containerlab/scripts/deploy-system.sh @@ -1,6 +1,5 @@ #!/bin/bash -# deploy-system.sh — Install BGP CRDs (datum-cloud/network) and VPC CRDs -# (datum-cloud/cloud), then apply the galactic-system namespace and shared +# deploy-system.sh — Install Galactic CRDs, then apply the galactic-system namespace and shared # RBAC (galactic-cni, galactic-router, galactic-nat, galactic-gateway) to # every cluster. The namespace and # ServiceAccount/RBAC manifests are applied straight from the repo's @@ -10,67 +9,12 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) source "${SCRIPT_DIR}/lib.sh" -# Network CRDs track datum-cloud/network's latest commit on NETWORK_REF -# (default main), not the version go.mod requires -- the lab exercises -# galactic against network's current API, and a go.mod pin routinely lags -# the schema changes the lab needs (e.g. BGPRouter's 16-bit spec.nodeID). -# The ref is resolved to a SHA once up front so every site installs the -# same schema even if the branch moves mid-deploy; set NETWORK_SHA to pin -# a specific commit instead. -NETWORK_REF="${NETWORK_REF:-main}" -if [[ -z "${NETWORK_SHA:-}" ]]; then - NETWORK_SHA=$(git ls-remote https://github.com/datum-cloud/network.git "refs/heads/${NETWORK_REF}" | cut -f1) - if [[ -z "${NETWORK_SHA}" ]]; then - echo "error: could not resolve datum-cloud/network ref ${NETWORK_REF}" >&2 - exit 1 - fi -fi -echo "Using datum-cloud/network CRDs at ${NETWORK_SHA} (${NETWORK_REF})" -NETWORK_CRD_URL="https://raw.githubusercontent.com/datum-cloud/network/${NETWORK_SHA}/config/crd" - -# VPC/VPCAttachment CRDs come from the separate companion VPC operator, -# datum-cloud/cloud. galactic-router and galactic-gateway read VPCAttachments -# through go.datum.net/cloud, so pin the same commit go.mod's pseudo-version -# names, and bump both together. -CLOUD_SHA="260f797bbc5c3f7b2ae323f041c185b544f79dbd" -CLOUD_CRD_URL="https://raw.githubusercontent.com/datum-cloud/cloud/${CLOUD_SHA}/config/crd" - -# Install whatever network's own config/crd/kustomization.yaml lists at -# NETWORK_SHA, so a CRD added upstream is picked up without editing this -# script. A missing CRD isn't benign: galactic-router's manager registers -# watches unconditionally and crash-loops on a cache-sync timeout if any -# watched kind is absent. -mapfile -t network_crds < <( - curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused "${NETWORK_CRD_URL}/kustomization.yaml" | - awk '$1 == "-" && $2 ~ /\.yaml$/ {print $2}' -) -if [[ ${#network_crds[@]} -eq 0 ]]; then - echo "error: no CRDs listed in ${NETWORK_CRD_URL}/kustomization.yaml" >&2 - exit 1 -fi - -cloud_crds=( - cloud.datumapis.com_vpcs.yaml - cloud.datumapis.com_vpcattachments.yaml -) - for site in dfw sjc iad; do node=$(control_plane "${site}") echo "Applying system to ${node}..." - # Install CRDs from GitHub before any namespace-scoped resources. - # -f makes curl exit non-zero on a 4xx/5xx instead of piping an empty/ - # error body into "kubectl apply -f -" (which fails opaquely with - # "no objects passed to apply"); --retry rides out transient GitHub - # hiccups (rate-limits, cold CDN cache) instead of failing the deploy. - for crd in "${network_crds[@]}"; do - curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused "${NETWORK_CRD_URL}/${crd}" | docker exec -i "${node}" kubectl apply -f - - done - for crd in "${cloud_crds[@]}"; do - curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused "${CLOUD_CRD_URL}/${crd}" | docker exec -i "${node}" kubectl apply -f - - done - copy_config "${node}" + apply_k "${node}" /galactic/config/crd apply_f "${node}" /galactic/config/galactic-system/namespace.yaml apply_f "${node}" /galactic/config/galactic-cni/serviceaccount.yaml apply_f "${node}" /galactic/config/galactic-cni/rbac.yaml diff --git a/deploy/containerlab/scripts/publish-attachments.sh b/deploy/containerlab/scripts/publish-attachments.sh index e57d7115..39567259 100755 --- a/deploy/containerlab/scripts/publish-attachments.sh +++ b/deploy/containerlab/scripts/publish-attachments.sh @@ -4,7 +4,7 @@ # # Every galactic NetworkAttachmentDefinition in the namespace is one # attachment, named after the NAD. One the tenant's own manifests do not -# define (only ns60's do, for the labels its NetworkRules select by) is +# define (only ns60's do, for the labels its NetworkServices select by) is # created from the NAD's vpc. Each one's status then gets what its pod # reports once scheduled: # @@ -15,7 +15,7 @@ # (internal/plumbing/intf), which galactic-router's traffic policy # reconciler binds the attachment's policies to. # -# ns60's NetworkRules wait on node to select a backend; every tenant +# ns60's NetworkServices wait on node to select a backend; every tenant # interface waits on hostInterface, since the lab enforces traffic policy and # an interface with no published bindings denies everything. # @@ -47,7 +47,7 @@ for site in "$@"; do if ! docker exec "${cp}" kubectl get vpcattachment -n "${NS}" "${nad}" >/dev/null 2>&1; then docker exec -i "${cp}" kubectl apply -f - >/dev/null </dev/null < The edge XDP Maglev/DSR (Direct Server Return) load-balancing gateway > control plane: a controller-runtime process that loads and attaches an > anycast consistent-hash L4 load-balancing program to a dedicated gateway -> node's public uplink and drives it from `NetworkGateway`/`NetworkRule` +> node's public uplink and drives it from `NetworkGateway`/`NetworkService` > CRDs. Every gateway node advertises every VIP identically over the > existing tenant EVPN mesh — there is no primary/secondary node, no BGP > local-preference split, and no address rewriting anywhere in the datapath. @@ -11,7 +11,7 @@ _Last updated: 2026-10-06_ This document covers `galactic-gateway` and the `NetworkGateway`/ -`NetworkRule` reconcilers only. See +`NetworkService` reconcilers only. See [ARCHITECTURE-ROUTER.md](ARCHITECTURE-ROUTER.md) for the tenant-BGP core (`galactic-router`, its own separate DaemonSet co-located on gateway-role nodes — not the same pod) and @@ -106,9 +106,9 @@ per-VPC gateway design to colocate with the workload's own VRF. | CRD | Scope | Written by | Purpose | | ---------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NetworkGateway` | Namespaced, one per gateway node (`spec.targetRef.name` = node name) | Operator, once per gateway node (see [worked example](#worked-containerlab-example) below) | Node-scoped root object, mirroring `BGPRouter`'s pattern. Identifies which nodes participate in the anycast mesh and surfaces each node's engine health via `status.conditions`. Carries **no** self-address field — DSR rewrites nothing, so there is no translation source to publish. | -| `NetworkRule` | Namespaced, internal | The platform API that serves tenants, after it verifies the tenant owns `vpcRef` (galactic does not repeat the check — see Known Constraints) | Ingress load-balancing spec: `vpcRef` (opaque tenant identifier), `vipAddresses` (1–8, IPv4/IPv6; IPv4 is not served yet, #705), `protocol` (`tcp`/`udp`), `port`, `backendSelector` (the VPCAttachments in `vpcRef` that serve it), `backendPort`. Served by every `NetworkGateway` in the namespace identically. | +| `NetworkService` | Namespaced, internal | The platform API that serves tenants, after it verifies the tenant owns `vpcRef` (galactic does not repeat the check — see Known Constraints) | Ingress load-balancing spec: `vpcRef` (opaque tenant identifier), `vipAddresses` (1–8, IPv4/IPv6; IPv4 is not served yet, #705), `protocol` (`tcp`/`udp`), `port`, `backendSelector` (the VPCAttachments in `vpcRef` that serve it), `backendPort`. Served by every `NetworkGateway` in the namespace identically. | -Both are defined in `go.datum.net/network`'s `api/v1alpha1` package +Both are defined in `api/network/v1alpha1` package (`gateway_types.go`, `rule_types.go`) — the same external CRD module the BGP-family types live in. Both types' own doc comments describe this DSR/anycast model directly. @@ -122,7 +122,7 @@ galactic/ ├── cmd/ │ └── galactic-gateway/ # Gateway control-plane binary (controller-runtime) ├── internal/ -│ ├── controller/ # NetworkGatewayReconciler, NetworkRuleReconciler, +│ ├── controller/ # NetworkGatewayReconciler, NetworkServiceReconciler, │ │ # usidresolver.go (backend-address → SRv6 uSID │ │ # resolution) — shares the internal/controller │ │ # package with the BGP-family reconcilers (see @@ -203,7 +203,7 @@ The node's `BGPRouter`/`BGPPeer` live with the separate `galactic-router` DaemonSet's config, in `resources/galactic-router//`. Each site directory (`dfw/`, `sjc/`, `iad/`) adds the site's -`networkrules.yaml` (`ns60-tcp` and `ns60-udp`, port 80) and +`networkservices.yaml` (`ns60-tcp` and `ns60-udp`, port 80) and `servicevipbindings.yaml`. Each site has its own VIP, in a `/64` only that site's edge nodes originate into the underlay (`2001:db8:6060:1::/64` for dfw, `:2::` for sjc, `:3::` for iad), and one `ns60` backend on its compute @@ -222,7 +222,7 @@ See [docs/architecture/](../architecture/) for C4 context/container diagrams cov that converges this node's whole gateway engine: 1. **Assemble desired state.** Lists every accepted, non-deleting - `NetworkRule` in the namespace — under the DSR anycast model every + `NetworkService` in the namespace — under the DSR anycast model every gateway node in a PoP serves every accepted rule identically, so there is no primary/secondary subset to filter on — expands each rule's `backendSelector` into backends, the IPv6 interface addresses of the @@ -243,7 +243,7 @@ that converges this node's whole gateway engine: none of the fields `selectRuleBackends` reads (labels, `status.vpc`, `status.node`, `spec.interface.addresses`, `spec.interface.mode`), so a conditions-only status write triggers no pass. The router's - `NetworkRuleBindingReconciler` uses the same predicate and re-queues + `NetworkServiceBindingReconciler` uses the same predicate and re-queues only the rules in the attachment's VPC. A deleting rule stays in that state, as *draining*, while it still carries the teardown finalizer and any `BGPAdvertisement` labelled with its name exists: it is not advertised @@ -282,14 +282,14 @@ that converges this node's whole gateway engine: the error or the unresolved backends in the message), and, for a rule with an IPv4 VIP, a `/IPv4Served` condition that is `False` with reason `Unsupported`. The type is node-scoped because - `NetworkRule` status is shared by every gateway node while loading + `NetworkService` status is shared by every gateway node while loading is per node; a node writes it only when it changes, and a departing, deleted, or disabled node's conditions are removed with its advertisements. The `NetworkGateway`'s `EngineDegraded` message lists every failed rule. Every rule advertisement carries a `networkRuleLabel` label and a plain (not controller, no `blockOwnerDeletion`) owner reference to its - `NetworkRule`, both backfilled on existing objects, and nothing is + `NetworkService`, both backfilled on existing objects, and nothing is created or updated for a rule that is being deleted. A pass whose cache has not yet seen that deletion can still create one after teardown has finished. Each pass therefore withdraws this node's advertisements for @@ -299,7 +299,7 @@ that converges this node's whole gateway engine: 3. **Crash recovery.** Runs `Engine.ReconcileOrphans` (see [Crash recovery](#crash-recovery) below). -`NetworkRuleReconciler.Reconcile` separately owns two **per-object** +`NetworkServiceReconciler.Reconcile` separately owns two **per-object** lifecycle pieces the aggregate pass above is the wrong place for: `status.conditions`'s `Accepted` condition (`updateAcceptedCondition` — set once gateway nodes exist for the namespace) and the @@ -316,7 +316,7 @@ not wait for every node to finish draining; that would need a cross-node protocol that does not exist. Every gateway node's own process runs both reconcilers, unconditionally, on -every `NetworkGateway`/`NetworkRule` in the namespace, with no leader +every `NetworkGateway`/`NetworkService` in the namespace, with no leader election anywhere in this codebase — safe because the reconcile logic is either idempotent (finalizer add/remove, `BGPAdvertisement` create/delete) or a pure function of inputs every gateway node observes identically @@ -480,10 +480,10 @@ labeled by `vip` and `vpc`. `main.go` holds `checkWatchPermissions` (RBAC pre-flight, mirroring `cmd/galactic-router/main.go`'s identically-named function, scoped to this -binary's own resource set: `networkgateways`, `networkrules`, +binary's own resource set: `networkgateways`, `networkservices`, `bgpadvertisements`, and — even though this binary has no BGP client of its own — `bgprouters`, since `usidresolver.go` reads `BGPRouter` CRDs directly -to resolve backend uSIDs). The scheme also registers `cloud.datumapis.com`, +to resolve backend uSIDs). The scheme also registers the cloud types in `galactic.datumapis.com`, since a rule's backends are `VPCAttachment`s. `root.go`'s `runCmd`: @@ -512,7 +512,7 @@ since a rule's backends are `VPCAttachment`s. it being empty before `runCmd` was ever reached. 6. Construct real (not stubbed) `gateway.NodeQuotaEnforcer` and `gateway.PrometheusTelemetryEmitter`, wire `gateway.NewEngine`. -7. Register `NetworkGatewayReconciler` and `NetworkRuleReconciler`. +7. Register `NetworkGatewayReconciler` and `NetworkServiceReconciler`. 8. `mgr.Start(ctx)`. ### `cmd/galactic-gateway/gateway.go` — datapath setup @@ -646,11 +646,11 @@ separate pod spec entirely, not part of this manifest. ### RBAC `config/galactic-gateway/rbac.yaml`'s `ClusterRole` covers exactly what -`NetworkGatewayReconciler`/`NetworkRuleReconciler` touch: -`networkgateways`/`networkrules` (+ `/status`) read-write, +`NetworkGatewayReconciler`/`NetworkServiceReconciler` touch: +`networkgateways`/`networkservices` (+ `/status`) read-write, `bgpadvertisements` full CRUD, and read-only `get`/`list`/`watch` on both `bgprouters` (for `usidresolver.go` — see above), `vpcattachments` in -`cloud.datumapis.com` (a rule's backends, selected and watched +`galactic.datumapis.com` (a rule's backends, selected and watched cluster-wide) and `bgpvrfinstances` (`NetworkGatewayReconciler.SetupWithManager` also watches `BGPVRFInstance` to re-trigger reconciliation once a backend's owning VRF/advertisement data @@ -659,7 +659,7 @@ finishing `WaitForCacheSync`, silently, behind a `bgpvrfinstances ... is forbidden` reflector error loop). This was split out of `config/galactic-router/rbac.yaml`'s single `ClusterRole`, which used to grant one `galactic-router` identity both the BGP-family CRD verbs and -`networkgateways`/`networkrules` verbs when both reconciler sets lived in +`networkgateways`/`networkservices` verbs when both reconciler sets lived in the same binary — every *other* (non-gateway) node's `galactic-router` ServiceAccount now loses that access entirely, rather than every `galactic-router` pod in the cluster carrying it as before. @@ -680,8 +680,8 @@ pod on that node is not a supported configuration. | ------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | `internal/config` (`gateway.go`) | galactic-gateway | `GatewayConfig`: node name, ports, public interface, SRv6 encap-source address; three-tier CLI/env/default precedence via viper | No | | `internal/controller` (`networkgateway_controller.go`) | galactic-gateway | `NetworkGatewayReconciler`: desired-state assembly (with the deleted-rule drain in `networkgateway_drain.go`), BGP wiring, orphan-crash recovery | No | -| `internal/controller` (`networkrule_controller.go`) | galactic-gateway | `NetworkRuleReconciler`: finalizer-guarded teardown ordering, `Accepted`-condition maintenance (`updateAcceptedCondition`) | No | -| `internal/controller` (`usidresolver.go`) | galactic-gateway | `backendSIDIndex`: resolves a `NetworkRule` backend address to the SRv6 uSID of the node its `VPCAttachment` reports by matching against `BGPAdvertisement`/`BGPRouter`/`BGPVRFInstance` CRDs, verifying tenant ownership; `backendSID` adds the backend's slot | No | +| `internal/controller` (`networkservice_controller.go`) | galactic-gateway | `NetworkServiceReconciler`: finalizer-guarded teardown ordering, `Accepted`-condition maintenance (`updateAcceptedCondition`) | No | +| `internal/controller` (`usidresolver.go`) | galactic-gateway | `backendSIDIndex`: resolves a `NetworkService` backend address to the SRv6 uSID of the node its `VPCAttachment` reports by matching against `BGPAdvertisement`/`BGPRouter`/`BGPVRFInstance` CRDs, verifying tenant ownership; `backendSID` adds the backend's slot | No | | `internal/gateway` (`engine.go`) | galactic-gateway | `Engine`: mutex-guarded convergence loop ("apply everything in desired, remove everything not in desired"), mirroring `GoBGPRuntime`'s shape | Yes (active-rule map) | | `internal/gateway` (`types.go`) | galactic-gateway | `DesiredRule`/`DesiredBackend`/`EngineState`/`EngineStatus`/`RuleStatus` — the engine's own representation, assembled by the controllers above; `DesiredBackend` implements `internal/maglev.Backend` | No | | `internal/gateway` (`datapath.go`, `kerneldatapath.go`) | galactic-gateway | `Datapath`/`QuotaEnforcer`/`TelemetryEmitter` interfaces; `KernelDatapath`, the real `Datapath` backed by `edgemap.VIPTable` over a loaded `edgeprog.EdgedsrObjects`, building a `internal/maglev.Table` per rule; `NoopDatapath` for tests | Yes (`vipKeysByName` bookkeeping) | @@ -704,8 +704,8 @@ pod on that node is not a supported configuration. | Dependency | Version | Purpose | | -------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `github.com/cilium/ebpf` | v0.22.0 | XDP program load/attach/map bindings (`edgeprog`/`edgemap`/`edgeattach`) — the same library version `internal/plumbing/ebpf` (the CNI chain's TC-BPF uSID datapath, see [ARCHITECTURE-CNI.md](ARCHITECTURE-CNI.md)) uses, but a fully separate program/map/attach surface with no shared map layout | -| `go.datum.net/network` | bumped frequently | `NetworkGateway`/`NetworkRule` CRD API types, plus the BGP-family types this binary reads (`BGPAdvertisement`, `BGPRouter`, `BGPVRFInstance`) | -| `go.datum.net/cloud` | pseudo-version | `VPCAttachment` types a rule's `backendSelector` picks from. Galactic only reads them. datum-cloud/cloud's controllers write `status.vpc` and `status.node`, which backend selection waits on (#821) | +| `api/network/v1alpha1` (local) | in-tree | Galactic BGP, gateway, egress, and service API types | +| `api/cloud/v1alpha1` (local) | in-tree | VPC and VPCAttachment API types; the Galactic cell controller writes their status | | `sigs.k8s.io/controller-runtime` | v0.24.1 | Full manager + reconciler framework — like `galactic-router`, not a bare client like the CNI chain | | `github.com/prometheus/client_golang` | v1.24.1 | `PrometheusTelemetryEmitter`'s control-plane-drop counter, plus `edgemetrics`'s pull-based `vip_table`/`vip_stats_table`/`drop_reasons` collector | | `github.com/spf13/cobra` | v1.10.2 | CLI command/flag handling | @@ -781,7 +781,7 @@ pod on that node is not a supported configuration. that needs advertising as a node-reachability route. - **Quota/telemetry are real but deliberately coarse.** `NodeQuotaEnforcer` enforces two node-level admission caps entirely from control-plane state - already held by `Engine` (no eBPF map read required): max `NetworkRule`s + already held by `Engine` (no eBPF map read required): max `NetworkService`s per tenant, and total `vip_table` rows across every tenant vs. the map's fixed capacity. It deliberately does **not** do per-flow rate limiting — `vip_table`'s key carries no tenant dimension (by design, see above), and @@ -824,7 +824,7 @@ pod on that node is not a supported configuration. - **Generation-based crash recovery, not a Geneve-interface scan.** `Engine.ReconcileOrphans` delegates to `edgemap.VIPTable.Reconcile`'s `Generation`-cutoff mechanism: a caller must capture - `Engine.DatapathGeneration()` *before* listing the `NetworkRule` CRDs + `Engine.DatapathGeneration()` *before* listing the `NetworkService` CRDs that become the live set, so a rule created between the snapshot and the list is never mistaken for an orphan. `vip_table` state (not a kernel interface) is the only thing this design can leak on a crash — DSR keeps @@ -860,7 +860,7 @@ that code. | Layer | Command | Framework | Scope | | --------------- | -------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Unit | `task test:unit` | `go test -race` | `internal/config` (`gateway_test.go`), `internal/controller` (`networkgateway_controller_test.go`, `networkrule_controller_test.go`, `usidresolver_test.go`), `internal/gateway` (`engine_test.go`, `diff_test.go`, `kerneldatapath_test.go`, `quota_test.go`, `recovery_test.go`, `telemetry_test.go`), `internal/maglev` (`table_test.go`), `internal/plumbing/bond` (`bond_test.go`, faked `netlink.Link`s, no kernel required — shared bond-master/slave detection used by both this package's `edgeattach.ResolveTargets` and `internal/plumbing/ebpf/attach`'s CNI TC-BPF path), `internal/plumbing/ebpf/edgemap` (`viptable_test.go`, in-memory fake `Table`, no kernel required), `internal/plumbing/ebpf/edgemetrics` (`collector_test.go`), `internal/plumbing/ebpf/edgepreflight` (`preflight_test.go`, `kernel_prober_test.go`); `edgeattach.ResolveTargets` also has its own faked-netlink `TestResolveTargets` (non-bond passthrough, bond-expands-to-slaves-only, no-slaves error) | +| Unit | `task test:unit` | `go test -race` | `internal/config` (`gateway_test.go`), `internal/controller` (`networkgateway_controller_test.go`, `networkservice_controller_test.go`, `usidresolver_test.go`), `internal/gateway` (`engine_test.go`, `diff_test.go`, `kerneldatapath_test.go`, `quota_test.go`, `recovery_test.go`, `telemetry_test.go`), `internal/maglev` (`table_test.go`), `internal/plumbing/bond` (`bond_test.go`, faked `netlink.Link`s, no kernel required — shared bond-master/slave detection used by both this package's `edgeattach.ResolveTargets` and `internal/plumbing/ebpf/attach`'s CNI TC-BPF path), `internal/plumbing/ebpf/edgemap` (`viptable_test.go`, in-memory fake `Table`, no kernel required), `internal/plumbing/ebpf/edgemetrics` (`collector_test.go`), `internal/plumbing/ebpf/edgepreflight` (`preflight_test.go`, `kernel_prober_test.go`); `edgeattach.ResolveTargets` also has its own faked-netlink `TestResolveTargets` (non-bond passthrough, bond-expands-to-slaves-only, no-slaves error) | | Kernel-required | `task test:unit` (root/CAP_BPF gated) | `go test` + `BPF_PROG_TEST_RUN` | `internal/plumbing/ebpf/edgeprog/edgedsr_test.go` — exercises the compiled program directly, including the version-nibble/payload_len regression tests; `internal/plumbing/ebpf/edgeattach/attach_test.go` | | E2E | `task verify:gateway-ingress` (in `deploy/containerlab/`) | bash, live containerlab lab | Real TCP and UDP from the off-fabric host to each site's VIP, pinned through each gateway node in turn by a temporary `/128` on the transit router. Asserts the pinned node's `galactic_edge_rule_packets_total` rises, `edge_return` counts the replies, no gateway counts a drop or a `ctstate INVALID` drop, and the backend sees the client's own address. `verify:gateway-restart` restarts a node's gateway and its egress shard in turn while the other carries traffic (#710). `verify:gateway-detach`, outside `task verify`, turns one node's datapath off and confirms the ingress check fails. The lab's uplinks are LACP bonds of veths, which accept a native XDP attach and so do not reproduce the real igb/tg3 bond failure; the root-gated `TestResolveTargetsAndAttach_RealBondDevice` above covers that. | @@ -895,7 +895,7 @@ that tag into `config/galactic-gateway/base`. ## Known Constraints - **E2E coverage is lab-only.** `task verify:gateway-ingress` in `deploy/containerlab/` sends real traffic through every lab gateway node (see [Testing](#testing)). Before it, the `gatewayDatapathKeepAlive` incident described in [Entry Points](#cmd-galactic-gatewaygateway-go--datapath-setup) above was only discovered because ingress traffic silently stopped being intercepted. Nothing runs that check in CI, since it needs the containerlab lab. -- **Galactic does not check VPC ownership on `NetworkRule`.** `NetworkRule` is internal: the platform API that serves tenants verifies the tenant owns `vpcRef` before it writes the rule, and RBAC on `galactic-system` decides who else can write one. `updateAcceptedCondition` sets `Accepted=True` once gateway nodes exist for the namespace; it is not an ownership result. galactic-router used to carry an allow-all admission webhook for this, never deployed, removed in #366. The `go.datum.net/network` API's doc comments still call the CRD tenant-writable and define `OwnershipVerified`/`OwnershipDenied` reasons nothing sets. +- **Galactic does not check VPC ownership on `NetworkService`.** `NetworkService` is internal: the platform API that serves tenants verifies the tenant owns `vpcRef` before it writes the rule, and RBAC on `galactic-system` decides who else can write one. `updateAcceptedCondition` sets `Accepted=True` once gateway nodes exist for the namespace; it is not an ownership result. galactic-router used to carry an allow-all admission webhook for this, never deployed, removed in #366. The `api/network/v1alpha1` doc comments still call the CRD tenant-writable and define `OwnershipVerified`/`OwnershipDenied` reasons nothing sets. - **The uSID backend resolver's tenant-ownership check depends on `BGPVRFInstance` naming staying deterministic.** `verifyTenantOwnership` closes the ambiguous-match gap an earlier version of this resolver had, but it is only as strong as `crdnames.BGPVRFInstanceName(vpc, nodeName)` staying the exact name `galactic-bgp` writes — a divergence between the two would fail closed (a real backend never resolving) rather than open (a wrong-tenant resolve), which is the safer failure direction but still worth knowing about when either side of that naming contract changes. - **The derived SRv6 encap source is read once, at startup.** Changing a gateway node's `BGPRouter` locator or node ID does not move its encapsulation source until `galactic-gateway` restarts. See [SRv6 encap-source address](#srv6-encap-source-address) above. - **The uSID TC-BPF/XDP FIB-lookup PMTUD gap applies here too.** When `bpf_fib_lookup()` returns `BPF_FIB_LKUP_RET_FRAG_NEEDED`, `edgedsr.c` counts `DROP_REASON_FIB_FRAG_NEEDED` and drops rather than emitting an ICMPv6 Packet Too Big — the same gap `internal/plumbing/ebpf/prog/usid.c`'s `usid_ingress` has for its FIB lookup (see [ARCHITECTURE-CNI.md#known-constraints](ARCHITECTURE-CNI.md#known-constraints)). `usid_egress` and the egress shard both send Packet Too Big now; closing it here is #700. @@ -903,11 +903,11 @@ that tag into `config/galactic-gateway/base`. - **A bonded public uplink attaches per-slave, with per-slave FIB-lookup sysctls to match.** Native-mode XDP against a Linux bonding master is not reliable: confirmed failing outright with "operation not supported" on a real gateway node (802.3ad over an igb/tg3 slave pair). Not every kernel's bonding driver categorically lacks `ndo_bpf` — some do implement it by forwarding the attach to every slave — but that still requires each slave's own driver to support native XDP itself, which not every NIC driver does (tg3 is a commonly cited example that doesn't), so this codebase never relies on attaching to the master working, on any kernel. `edgeattach.ResolveTargets` expands a bonding-master `GALACTIC_GATEWAY_PUBLIC_INTERFACE` to its slave interfaces (never the master itself — see `internal/plumbing/bond`, shared with `internal/plumbing/ebpf/attach`'s TC-BPF path, which attaches to the master *and* its slaves instead), and `setupGatewayDatapath` attaches to and configures FIB-lookup sysctls on every one of them. This is not cosmetic: `edgedsr.c`'s `push_outer_header` calls `bpf_fib_lookup()` with `ctx->ingress_ifindex` — confirmed against the source, not assumed — which for a native XDP program attached to a bond slave is that slave's own ifindex, not the bond master's, so `net.ipv6.conf..forwarding` (not `net.ipv6.conf..forwarding`) is what the kernel actually checks. A bonding master with no resolvable slaves is a hard startup error, not a silent fallback to attaching the master (which would only risk repeating the same failure). `edgeattach.Attach` is all-or-nothing across every resolved slave in one call — if any single slave's driver can't accept a native XDP attach (a real possibility per the tg3 note above, not independently confirmed against that node specifically), the whole datapath startup fails rather than running in a degraded, missing-that-slave's-traffic state. The attach step itself lives in `internal/plumbing/ebpf/xdpattach`, shared with `natattach`: every target is checked for native XDP support through the netdev generic netlink family before any is touched, and each bond slave is waited back into its aggregate (MII up, plus LACP collecting|distributing on an 802.3ad bond) before the next is attached (#583); this has not been exercised against real igb/tg3 hardware, only veth in tests, so whether all of a real bonded pair's slaves actually accept native XDP on the affected class of hardware is still open. Attaching per-slave rather than to the bond as a whole means the slave set has to be kept current after startup: `watchTargets` (`cmd/galactic-gateway/gateway.go`) re-runs `ResolveTargets` on every netlink link or route change, and every 30s with none, and hands the result to an `xdpattach.Set` per program (#647). The set attaches a member enslaved or replaced (new ifindex under the same name) since startup through the same gate, one at a time, and skips one whose bounce would leave its bond with no member carrying traffic; it never detaches a member that still exists, since detaching bounces the link as attaching does. Unlike the startup attach, one member failing there does not undo the rest: it is retried on the next pass, and while any resolved target lacks the program the gateway's `readiness` gRPC health service (`config.GRPCReadinessService`, the readinessProbe's `service`) reports NOT_SERVING, without failing liveness. - **`XDP_REDIRECT` into a veth needs NAPI enabled on the *peer*, which is a lab-only concern.** `edgedsr.c` returns `XDP_REDIRECT` whenever the route to the backend egresses an interface other than the ingress one. In native mode that calls the egress device's `ndo_xdp_xmit`, and veth's implementation silently discards the frame unless the peer end has NAPI enabled — which for a veth means the peer runs its own XDP program or has GRO turned on. A containerlab peer inside an FRR/transit container has neither by default, so a redirect there can fail exactly the way this datapath's original bug did: no drop counter moves, because `bpf_redirect()` itself succeeded and the discard happens later in `xdp_do_redirect`. Both programs are affected, and `edge_return` more so: it attaches to compute-facing links, which are veth pairs in the lab and may be veth or a plain NIC in production. `bpftool prog tracelog` and the `xdp:xdp_redirect_err` tracepoint are the diagnostics; `ethtool -K gro on` is the lab fix. A production uplink (physical NIC, or a bond slave per the bullet above) implements `ndo_xdp_xmit` natively and has no peer to ask about. This is the redirect-side analogue of the `XDP_TX`-on-veth observability quirk noted above. - **The return path is opt-in per node and fails closed at startup, not silently.** `edge_return` attaches only where `GALACTIC_GATEWAY_INTERNAL_INTERFACES` names an interface; a node that needs it and does not set it drops every reply in `KUBE-FORWARD` with nothing to say why, exactly as before this program existed. Where it *is* set, an attach failure is fatal to `setupGatewayDatapath` and the pod crash-loops rather than running with the forward half working and replies dying — the same all-or-nothing choice `edgeattach.Attach` already makes across a bond's slaves. -- **A backend in another site replies through that site's edge node.** The gateway can pick a backend anywhere its `NetworkRule` reaches, and the reply then leaves through the edge node in front of *that* backend, whose own `vip_addr_table` must hold the same anycast VIP or the reply meets the unmodified kernel path and dies. Within one cluster the reconciler guarantees this; across clusters it depends on the same `NetworkRule` existing on both sides, which nothing in this repo enforces. -- **Backends depend on an attachment controller outside this repo.** `selectRuleBackends` skips a `VPCAttachment` whose `status.vpc` is not the rule's VPC, and keeps a selected one pending until its `status.node` is set. Galactic writes neither field; controllers in datum-cloud/cloud do, `status.node` from the attachment's `BGPAdvertisement`. Without them an attachment with no `status.vpc` is silently unselected, one with `status.vpc` but no `status.node` is pending with `status.node not set by datum-cloud/cloud`, and a rule with no other backend never loads; the containerlab lab patches the status by hand (`deploy/containerlab/scripts/publish-attachments.sh`). See #821. +- **A backend in another site replies through that site's edge node.** The gateway can pick a backend anywhere its `NetworkService` reaches, and the reply then leaves through the edge node in front of *that* backend, whose own `vip_addr_table` must hold the same anycast VIP or the reply meets the unmodified kernel path and dies. Within one cluster the reconciler guarantees this; across clusters it depends on the same `NetworkService` existing on both sides, which nothing in this repo enforces. +- **Backends depend on the Galactic cell attachment controller.** `selectRuleBackends` skips a `VPCAttachment` whose `status.vpc` is not the rule's VPC, and keeps a selected one pending until its `status.node` is set. The cell controller writes both fields, `status.node` from the attachment's `BGPAdvertisement`. Without them an attachment with no `status.vpc` is silently unselected, one with `status.vpc` but no `status.node` is pending with `status.node not set by the Galactic cell controller`, and a rule with no other backend never loads; the containerlab lab patches the status by hand (`deploy/containerlab/scripts/publish-attachments.sh`). See #821. - **IPv4 VIPs are accepted but not served.** A rule that predates the CRD's IPv6-only rule can still hold an IPv4 VIP, but `edge_lb` and `edge_return` parse only IPv6, `edgemap` refuses IPv4 keys, and backend nodes translate only IPv6 VIPs. So `buildDesiredRule` leaves IPv4 VIPs, plain or IPv4-mapped, out of the engine, no `-v4` advertisement is made, and each gateway node reports `/IPv4Served=False` (`Unsupported`) on the rule. The address never draws traffic that nothing answers, and the rule's IPv6 VIP keeps serving. #705 tracks serving IPv4 VIPs. - **`vip_table` has no active GC beyond crash-recovery reconcile.** By design (see Key Design Decisions above) — DSR keeps no flow state to leak in the first place. -- **Egress is out of this binary's scope, not unimplemented.** Egress translation is a separate, sharded tier (`galactic-nat`, its own binary — see `cmd/galactic-nat` and `internal/controller/egressshard_controller.go`). `NetworkRule`/this datapath remain ingress-only: external client → VIP → tenant backend. +- **Egress is out of this binary's scope, not unimplemented.** Egress translation is a separate, sharded tier (`galactic-nat`, its own binary — see `cmd/galactic-nat` and `internal/controller/egressshard_controller.go`). `NetworkService`/this datapath remain ingress-only: external client → VIP → tenant backend. --- @@ -918,10 +918,10 @@ that tag into `config/galactic-gateway/base`. | Concern | Start here | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | Node-scoped aggregate reconcile (desired-state assembly, BGP wiring, crash recovery) | `internal/controller/networkgateway_controller.go:Reconcile` | -| Per-object lifecycle (finalizer teardown ordering, `Accepted`-condition maintenance) | `internal/controller/networkrule_controller.go:Reconcile`, `updateAcceptedCondition`, `reconcileDelete` | +| Per-object lifecycle (finalizer teardown ordering, `Accepted`-condition maintenance) | `internal/controller/networkservice_controller.go:Reconcile`, `updateAcceptedCondition`, `reconcileDelete` | | Backend address → SRv6 uSID resolution (with tenant-ownership verification) | `internal/controller/usidresolver.go:buildBackendSIDIndex`, `resolveUSID`, `verifyTenantOwnership` | | Backend selection from `backendSelector` (shared with the binding writer) | `internal/controller/rulebackends.go:selectRuleBackends`, `ruleBackendOwners` | -| Backend-side `ServiceVIPBinding`s, generated per node | `internal/controller/networkrule_binding_controller.go` (runs in `galactic-router`) | +| Backend-side `ServiceVIPBinding`s, generated per node | `internal/controller/networkservice_binding_controller.go` (runs in `galactic-router`) | | SRv6 encap source (configured, or derived from this node's `BGPRouter`) | `cmd/galactic-gateway/encapsource.go:resolveEncapSource`, `srv6.NodeLocatorAddress` | | Engine convergence loop | `internal/gateway/engine.go:Reconcile`, `applyRuleLocked`, `removeRuleLocked` | | Real datapath implementation (vip_table read/write, Maglev table construction) | `internal/gateway/kerneldatapath.go:ApplyRule`, `RemoveRule`, `buildMaglevTable` | @@ -940,9 +940,9 @@ that tag into `config/galactic-gateway/base`. **Non-obvious patterns:** - `gatewayDatapathKeepAlive` (`cmd/galactic-gateway/gateway.go`) intentionally never calls `Close` on the loaded eBPF objects or the XDP `link.Link` — see that var's doc comment for the live incident this guards against (silent GC-triggered detach with every control-plane signal still looking healthy). -- Every gateway node reconciles every `NetworkGateway`/`NetworkRule` in the namespace — there is no leader election and no per-node filtering predicate on the watch itself; filtering happens inside `Reconcile` (`gw.Spec.TargetRef.Name != r.NodeName` early-return) and via `isGatewayNode`/broadcast watch mappers, not via `SetupWithManager` predicates. +- Every gateway node reconciles every `NetworkGateway`/`NetworkService` in the namespace — there is no leader election and no per-node filtering predicate on the watch itself; filtering happens inside `Reconcile` (`gw.Spec.TargetRef.Name != r.NodeName` early-return) and via `isGatewayNode`/broadcast watch mappers, not via `SetupWithManager` predicates. - `BGPAdvertisement` names for gateway-originated routes are node-qualified (`---v4`/`-v6`, built by `ruleAdvertisementName`) specifically because the anycast model means every gateway node computes the same rule independently — omitting the node qualifier caused two nodes to race to create/update one shared object, which would leave the second node failing with `AlreadyExists` forever and only the first node's route ever advertised. The hash is a 10-hex-character SHA-256 of `/`. Without it, rule `a-b` on node `c` and rule `a` on node `b-c` shared the name `a-b-c-v4`, so the two nodes overwrote one object and withdrawing either rule deleted the other's route (issue #762). Nothing rebuilds a name to find an advertisement: withdrawal and cleanup select by the `galactic.datum.net/network-rule` label and then check the name. Releases up to v0.5.3 used `--v4`/`-v6` and labelled those objects with the rule label alone. `applyBGPAdvertisements` deletes this node's old-named objects once their replacements exist (`pruneRuleAdvertisements`). - A deleted `NetworkGateway` reconcile can't just call `Engine.Stop()` unconditionally — every gateway node's process reconciles every `NetworkGateway` in the namespace, so a *sibling* node's deletion reaches this reconciler too. `isGatewayNode` re-checks whether *this* node still has its own `NetworkGateway` before stopping the engine (issue #364). - Advertisement failures during `Reconcile` are collected, not returned immediately — one bad rule's BGP-wiring failure must not stop the rest of the pass, but a node that converged its engine while failing to publish any route must still report `AdvertisementFailed`, not `EngineHealthy` (issue #365) — see `readyConditionFor`. - `withdrawNodeAdvertisements` selects a node's routes by the `galactic.datum.net/gateway-node` label `applyBGPAdvertisements` sets (and backfills) on every advertisement — the node name, or for a name over a label value's 63 characters a truncated prefix plus a short hash (`gatewayNodeLabelValue`) — then deletes only those named by `ruleAdvertisementName` (or the pre-#762 `--v4`/`-v6`) for their `galactic.datum.net/network-rule` label; the anycast model publishes no other per-node route (see the package doc comment in `networkgateway_controller.go`). It never matches by name suffix, which once let removing node `edge-1` withdraw node `east-edge-1`'s routes and CNI-written advertisements ending in `-edge-1-v4` (issue #714). An unlabelled advertisement left by a node that departed before running a release that backfills the label is not withdrawn automatically and needs deleting by hand. -- A deleting `NetworkRule` is not dropped from the engine on the pass that first sees its deletion timestamp. Doing so ran `Datapath.RemoveRule` before `reconcileDelete` had withdrawn the route, breaking the withdraw-first contract on `Datapath.RemoveRule` (issue #715). It drains instead: kept loaded while any labelled advertisement exists, then for `ruleDrainDelay` more. +- A deleting `NetworkService` is not dropped from the engine on the pass that first sees its deletion timestamp. Doing so ran `Datapath.RemoveRule` before `reconcileDelete` had withdrawn the route, breaking the withdraw-first contract on `Datapath.RemoveRule` (issue #715). It drains instead: kept loaded while any labelled advertisement exists, then for `ruleDrainDelay` more. diff --git a/docs/agents/ARCHITECTURE-ROUTER.md b/docs/agents/ARCHITECTURE-ROUTER.md index 3861196f..0df1c4b3 100644 --- a/docs/agents/ARCHITECTURE-ROUTER.md +++ b/docs/agents/ARCHITECTURE-ROUTER.md @@ -14,7 +14,7 @@ writes the CRDs this binary reconciles, and balancer (`galactic-gateway`, its own separate DaemonSet co-located on gateway-role nodes — not a `galactic-router` container in the same pod; that arrangement was retired when the gateway split into its own binary — -see that doc for the `NetworkGateway`/`NetworkRule` reconcilers, which run +see that doc for the `NetworkGateway`/`NetworkService` reconcilers, which run in `galactic-gateway`, not here, despite living in the same `internal/controller` package). This file, together with those two, supersedes the former monolithic `ARCHITECTURE.md` — see @@ -70,7 +70,7 @@ galactic/ │ ├── controller/ # controller-runtime reconcilers (BGPRouter, BGPPeer, │ │ # BGPAdvertisement, BGPVRFInstance, BGPPolicy, Secret, │ │ # Node, GC); field index registration; status helpers. -│ │ # (NetworkGatewayReconciler/NetworkRuleReconciler also +│ │ # (NetworkGatewayReconciler/NetworkServiceReconciler also │ │ # live in this package but run in galactic-gateway, not │ │ # galactic-router — see ARCHITECTURE-GATEWAY.md.) │ ├── reconcile/ # CRD → DesiredRouter translation (node targeting, @@ -122,7 +122,7 @@ See [docs/agent-startup.md](../agent-startup.md) for the router startup sequence | Component | Binary | Role | | ------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `internal/controller` | `galactic-router` | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Secret, Node, ServiceVIPBinding, NetworkRuleBinding, NodeDeparture, GC — not NetworkGateway/NetworkRule, see the note above); field index registration; CRD status helpers | +| `internal/controller` | `galactic-router` | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Secret, Node, ServiceVIPBinding, NetworkServiceBinding, NodeDeparture, GC — not NetworkGateway/NetworkService, see the note above); field index registration; CRD status helpers | | `internal/reconcile` | `galactic-router` | CRD → DesiredRouter translation | | `internal/runtime/gobgp` | `galactic-router` | Embedded GoBGP server (the only backend) | | `internal/model` | `galactic-router` | Internal BGP model types | @@ -172,7 +172,7 @@ lives in `root.go`'s `runCmd`: with the manager — see the peer-FSM design decision below. 9. Register the controllers: BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Secret, Node, ServiceVIPBinding, - NetworkRuleBinding, NodeDeparture, ServiceRoutePolicy (plain role only; + NetworkServiceBinding, NodeDeparture, ServiceRoutePolicy (plain role only; a route reflector has no service route maps to program and skips it), and GC (the GC controller also starts a ticker goroutine that waits for cache sync, then runs on `--gc-interval`, default 5m; NodeDeparture @@ -229,7 +229,7 @@ co-located `galactic-gateway` container's own health port on the same | Package | Binary | Responsibility | Owns state | | ----------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| `internal/controller` | galactic-router | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Node, Secret, ServiceVIPBinding, NetworkRuleBinding, NodeDeparture, GC); field index registration; CRD status helpers | No | +| `internal/controller` | galactic-router | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Node, Secret, ServiceVIPBinding, NetworkServiceBinding, NodeDeparture, GC); field index registration; CRD status helpers | No | | `internal/reconcile` | galactic-router | Translates BGPRouter + related CRDs into `model.DesiredRouter`; enforces node targeting, timer validation, AFI validation | No | | `internal/runtime` | galactic-router | `RouterRuntime` interface; `RuntimeManager` (keyed map of live runtimes, double-checked lock create) | Yes (runtime map) | | `internal/runtime/gobgp` | galactic-router | Embeds GoBGP v4; lazy-starts on first Apply; handles peer/VRF/EVPN-path/policy add/update/delete; tracks established timestamps; logs peer FSM transitions (`peer_monitor.go`); keeps BMP stations registered across server replacement and collector address changes (`bmp.go`) | Yes (per-router) | @@ -249,7 +249,7 @@ co-located `galactic-gateway` container's own health port on the same | Dependency | Version | Purpose | | -------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `github.com/osrg/gobgp/v4` | v4.9.0 | Embedded BGP server | -| `go.datum.net/network` | bumped frequently | BGP CRD API types (BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance) | +| `api/network/v1alpha1` (local) | in-tree | Galactic BGP, gateway, egress, and service API types | | `sigs.k8s.io/controller-runtime` | v0.24.1 | Full manager + reconciler framework: manager, field indexes, eight registered controllers (see Entry Points) | | `github.com/spf13/cobra` | v1.10.2 | CLI command/flag handling | | `github.com/spf13/viper` | v1.21.0 | Config resolution (flags/env/defaults) — unlike the CNI-chain binaries (see [ARCHITECTURE-CNI.md](ARCHITECTURE-CNI.md)), which resolve config themselves and don't import viper | @@ -274,8 +274,8 @@ co-located `galactic-gateway` container's own health port on the same - **Peer FSM transitions are logged via a dedicated GoBGP event watcher, not GoBGP's own logger — and also drive real-time BGPPeer Kubernetes Events.** `GALACTIC_ROUTER`'s `LogLevel` config only gates GoBGP's *internal* `slog` logger, whose handler is wired to `io.Discard` regardless of level (`internal/runtime/gobgp/server.go`'s `buildServerOptions`) — so none of GoBGP's own peer state log lines ever reach this process's output. Instead, `internal/runtime/gobgp/peer_monitor.go` runs a shared `BgpServer.WatchEvent(..., WatchPeer())` goroutine (the same one-goroutine-per-runtime pattern `monitor.go`'s `watchEVPNRIB` uses for EVPN best-path) that diffs each peer's FSM state against `lastPeerState` and logs a `slog.Info` line on every real transition, including `disconnectReason`/`disconnectMessage` when a peer drops out of Established. The same transition is also handed to an optional `model.PeerStateObserver` (set via `gobgp.NewRuntimeFactory`'s `observer` parameter): in production this is `internal/controller/peer_events.go`'s `PeerStateEventEmitter`, a `manager.Runnable` that resolves the peer's address to its `BGPPeer` object and raises a `SessionEstablished`/`SessionDown` Kubernetes Event on it — but only for transitions into/out of Established, not every intermediate FSM hop (see the emitter's own doc comments for why). This is independent of, and does not replace, the periodic (`peerStatusRequeue`, 30s) `BGPPeer.Status` update in `internal/controller/bgprouter_controller.go`'s `updatePeerStatuses`: that keeps the CRD's status current on a poll and can miss a transition that reverts between two polls; the `slog` line and the Event are both real-time and don't have that gap. See `docs/plans/router-bgp-peer-session-events.md` for the full design. - **Routes for route-target-less EVPN paths defer to the underlay.** A path with no route target (the gateway's anycast VIPs, and each egress shard's SID `/64` and masquerade address) becomes a plain kernel route toward its BGP next hop, with no encapsulation. These routes live in their own table, `srv6.PlainRouteTable` (`0xFFFE0000`), marked `proto 250`, and are reached through two policy rules ahead of the main table's own: `32000: lookup main suppress_prefixlength 0`, then `32001: lookup 0xFFFE0000`. An underlay route for the same destination, or for anything more specific than the default route, therefore always wins. Keeping them out of the main table is required, not cosmetic: zebra imports every main-table route it did not install as a `kernel` route with distance 0, so a plain route there stopped FRR installing its own BGP route for the same prefix (#670). The kernel cannot resolve an IPv6 next hop recursively, so each route is written with the link and next hop the gateway resolves to at the time. `internal/runtime/gobgp/plainroutes.go` re-resolves every route whenever the main table changes and every 30s, and retries one that cannot resolve yet. It removes owned routes no path asks for once a two-minute startup grace has passed, and deletes the unmarked main-table route an earlier version left for each prefix. `ip route show table all proto 250` lists every route it owns. XDP datapaths that call `bpf_fib_lookup` with `BPF_FIB_LOOKUP_DIRECT` (`natprog`, `edgeprog`) read the main table only and do not see these routes. - **A tenant VRF route that fails to install is retried, not dropped.** `processEVPNPath` writes a VRF-scoped path's route into `egress_route_table` through `internal/runtime/gobgp/vrfroutes.go`. `Register` resolves the SID's uplink and neighbor MAC at write time, which fails while an uplink is still coming up after a restart, and BGP never redelivers an unchanged best path, so before #676 such a route stayed missing until the path changed upstream. `vrfRoutes` keeps each failed route in a pending set and retries it whenever the main table changes (1s debounce) and every 30s, until it installs or its path is withdrawn; removing a VRF drops its pending routes. One mutex serializes every write to the map from this runtime, taken per route during a retry so an unresolvable neighbor (up to 2s) does not hold up the RIB watcher for the whole pass. `EgressRouteTable.Refresh` cannot cover this: it walks existing entries only. The pending count, and the plain-route reconciler's failing count, are exported as `galactic_router_route_install_failing{kind="vrf"|"plain"}`, and `GalacticRouterRoutesNotInstalled` fires when either stays above 0 for 10 minutes. -- **`ServiceVIPBinding`s are generated, one per node per backend.** `NetworkRuleBindingReconciler` (`internal/controller/networkrule_binding_controller.go`) runs on every node and writes a binding for each IPv6 VIP of each accepted `NetworkRule` and each backend the rule's `backendSelector` places on this node, through the same `selectRuleBackends` the gateway uses, so the gateway never sends a backend's flows to a node with no binding planned for it. Each node writes and deletes only its own bindings (labelled `galactic.datum.net/binding-node` and `app.kubernetes.io/managed-by: galactic-router`), so there is one writer per object and no leader election. A binding is owned by its rule, records the rule's `vpcRef` (which names the VRF its rows go in) and takes `egressKind` from the attachment's interface mode. `ServiceVIPBindingReconciler` programs a binding only when a `BGPAdvertisement` on this node's `BGPRouter` with that VRF's ID has a prefix containing `backendAddress`, and otherwise reports `BindFailed`; it watches advertisements on its own router so a binding written before its advertisement converges, and teardown skips the check. A backend serves only the oldest rule (by creation time, then namespace/name) selecting it on the same backend port and protocol, since its node translates the backend's replies back to only one VIP; both sides compute that owner with `ruleBackendOwners` over every rule in the namespace, so a newer rule gets no binding for the backend and no flows for it from the gateway, and every rule in a namespace is re-queued when one of them changes. Rules compete only within a VPC, so the per-VPC `VPCAttachment` fan-out still reaches every rule an attachment change can affect. The node summarizes its bindings on the rule as `/BackendsBound` (`BackendsClaimed` names a backend an older rule serves); a rule whose spec cannot be built into bindings (a second IPv6 VIP, or a selector that does not parse) keeps the node's existing bindings, the same as a rule that loses `Accepted`, so an invalid edit does not drop the backend nodes' `vip_xlat_table` rows. Only the nodes that hold bindings for such a rule report `InvalidRule`, since the gateways already report the error and a condition from every node would be a status write per node per edit. Each rule reconcile lists only its own VPC's attachments, through the VPCAttachment→`status.vpc` field index (`listVPCAttachmentsInVPC`), and skips the cache's deep copy because nothing it does writes to them. Several backends of one VIP can share a node: the gateway writes each backend's slot into the uSID and `usid.c` keys the ingress row on it (#799). -- **Surviving routers clean up after a departed node.** Only a node's own `NetworkRuleBindingReconciler` clears its `/BackendsBound` conditions and deletes its generated bindings, and only its own `ServiceVIPBindingReconciler` removes the teardown finalizer from bindings targeting it. So once a node's `Node` object is deleted, `NodeDepartureReconciler` (`internal/controller/node_departure_controller.go`) on every other router removes that node's `BackendsBound` condition from every `NetworkRule`, deletes its generated bindings and removes the finalizer from every binding whose `targetRef.name` names it, hand-written ones included; the kernel rows went with the node. Without it the conditions stayed forever and a deleted rule's bindings stayed `Terminating`. Every Node create and delete maps to one sweep key, so the sweep runs on the initial Node list (startup), on every Node delete and every `--gc-interval`, and a router down during the delete still converges. A node missing from the cached Node list counts as departed only once an uncached `Get` returns NotFound. Every router sweeps, with no leader election: status writes and finalizer patches retry on conflict (the patch carries the resourceVersion) and NotFound counts as done. +- **`ServiceVIPBinding`s are generated, one per node per backend.** `NetworkServiceBindingReconciler` (`internal/controller/networkservice_binding_controller.go`) runs on every node and writes a binding for each IPv6 VIP of each accepted `NetworkService` and each backend the rule's `backendSelector` places on this node, through the same `selectRuleBackends` the gateway uses, so the gateway never sends a backend's flows to a node with no binding planned for it. Each node writes and deletes only its own bindings (labelled `galactic.datum.net/binding-node` and `app.kubernetes.io/managed-by: galactic-router`), so there is one writer per object and no leader election. A binding is owned by its rule, records the rule's `vpcRef` (which names the VRF its rows go in) and takes `egressKind` from the attachment's interface mode. `ServiceVIPBindingReconciler` programs a binding only when a `BGPAdvertisement` on this node's `BGPRouter` with that VRF's ID has a prefix containing `backendAddress`, and otherwise reports `BindFailed`; it watches advertisements on its own router so a binding written before its advertisement converges, and teardown skips the check. A backend serves only the oldest rule (by creation time, then namespace/name) selecting it on the same backend port and protocol, since its node translates the backend's replies back to only one VIP; both sides compute that owner with `ruleBackendOwners` over every rule in the namespace, so a newer rule gets no binding for the backend and no flows for it from the gateway, and every rule in a namespace is re-queued when one of them changes. Rules compete only within a VPC, so the per-VPC `VPCAttachment` fan-out still reaches every rule an attachment change can affect. The node summarizes its bindings on the rule as `/BackendsBound` (`BackendsClaimed` names a backend an older rule serves); a rule whose spec cannot be built into bindings (a second IPv6 VIP, or a selector that does not parse) keeps the node's existing bindings, the same as a rule that loses `Accepted`, so an invalid edit does not drop the backend nodes' `vip_xlat_table` rows. Only the nodes that hold bindings for such a rule report `InvalidRule`, since the gateways already report the error and a condition from every node would be a status write per node per edit. Each rule reconcile lists only its own VPC's attachments, through the VPCAttachment→`status.vpc` field index (`listVPCAttachmentsInVPC`), and skips the cache's deep copy because nothing it does writes to them. Several backends of one VIP can share a node: the gateway writes each backend's slot into the uSID and `usid.c` keys the ingress row on it (#799). +- **Surviving routers clean up after a departed node.** Only a node's own `NetworkServiceBindingReconciler` clears its `/BackendsBound` conditions and deletes its generated bindings, and only its own `ServiceVIPBindingReconciler` removes the teardown finalizer from bindings targeting it. So once a node's `Node` object is deleted, `NodeDepartureReconciler` (`internal/controller/node_departure_controller.go`) on every other router removes that node's `BackendsBound` condition from every `NetworkService`, deletes its generated bindings and removes the finalizer from every binding whose `targetRef.name` names it, hand-written ones included; the kernel rows went with the node. Without it the conditions stayed forever and a deleted rule's bindings stayed `Terminating`. Every Node create and delete maps to one sweep key, so the sweep runs on the initial Node list (startup), on every Node delete and every `--gc-interval`, and a router down during the delete still converges. A node missing from the cached Node list counts as departed only once an uncached `Get` returns NotFound. Every router sweeps, with no leader election: status writes and finalizer patches retry on conflict (the patch carries the resourceVersion) and NotFound counts as done. - **Deleting a `ServiceVIPBinding` does not need its VRF on the node.** A binding's two `vip_xlat_table` rows are keyed on its VRF's uSID Argument. `ServiceVIPBindingReconciler` reads it from the `BGPVRFInstance` its `vpcRef` names on this node, and that is gone once the VPC leaves the node. Before #750, deletion re-derived it, failed, held the finalizer forever and left both rows in the map. Teardown now finds the rows by value with `vipxlatmap.UnregisterBinding`: since #764 each key carries the VIP or backend address, and since #799 the ingress key also carries the backend's slot, so the binding's ingress row (its VIP, port and slot, rewriting to its backend) and egress row (its backend and port, rewriting to its VIP) are found exactly, under any Argument. Each row is removed only while it still holds the binding's values, so a row another binding has since written stays. When the VRF context still resolves, `UnregisterBindingAt` applies the same checks at that location too. Neither pass deletes a row without first reading it, and a process-wide lock keeps a bind from landing between the read and the delete. A value check cannot tell two bindings with identical values apart, so while such a twin is live on the node, deleting one removes nothing. Rows exist only for IPv6 addresses, so an IPv4 or invalid address skips both passes instead of blocking deletion. Only a failed kernel delete keeps the finalizer. Rows left behind some other way, by a finalizer cleared by hand, a crash or a manual write, are removed by the periodic sweep below; to remove one sooner, use `galactic-router vip xlat list` and `vip xlat remove --protocol --vip --port --backend --backend-port` (`--slot` defaults to the backend's own), run in that node's `galactic-router` pod (`kubectl exec -- /galactic-router vip xlat list`). - **`vip_xlat_table` is swept on the GC ticker.** `controller.VIPXlatSweeper` (`internal/controller/vipxlat_sweep.go`) runs on every GC tick after the first, never in the initial pass at startup, so the `ServiceVIPBinding` reconciler has re-registered every live binding first. It takes the table's generation, lists every `ServiceVIPBinding` targeting this node (including ones being deleted, whose finalizer owns their rows), resolves each one's two rows the way the reconciler does, and calls `VipXlatTable.Reconcile`, which removes every row not in that set unless it was registered after the snapshot. The live set is by key, so a row is kept whatever value it holds. A binding whose VRF context does not resolve right now has no known Argument, so every row shaped like one of its rows (direction, slot, protocol, address and port, under any Block and Argument) is kept instead. A binding whose spec does not parse never wrote a row and protects nothing, and any error listing bindings or building the index skips the whole pass. This is what removes the slot-0 ingress rows a router built before #799 wrote: the current key carries the backend's slot, so nothing else ever matches them. `usid.c` has no slot-0 fallback, so `galactic-gateway` and `galactic-router` have to be upgraded together across #799; a node whose router has not yet rewritten its rows with slots drops the flows a new gateway sends it. @@ -341,7 +341,7 @@ own publish/image details. | GoBGP server lifecycle (start/reconfigure) | `internal/runtime/gobgp/server.go` | | SRv6 SID computation for BGP Prefix-SID | `internal/plumbing/srv6/usid.go:ComputeSID` | | Tap-kind ServiceVIPBinding / vip_xlat_table | `internal/controller/servicevipbinding_controller.go`, `internal/plumbing/ebpf/vipxlatmap` | -| Generated ServiceVIPBindings from NetworkRules | `internal/controller/networkrule_binding_controller.go`, `internal/controller/rulebackends.go` | +| Generated ServiceVIPBindings from NetworkServices | `internal/controller/networkservice_binding_controller.go`, `internal/controller/rulebackends.go` | | Departed-node binding and condition cleanup | `internal/controller/node_departure_controller.go` | **Stable vs. frequently changed:** @@ -358,4 +358,4 @@ own publish/image details. - GoBGP `Reconfigure()` calls `old.Stop()` then creates a fresh `BgpServer` — it does NOT call the BGP-level `StopBgp`/`StartBgp` on the old server, avoiding the v4 "Serve loop permanently dead" problem. - No binary's `cmdDel` deletes the VRF, veth/tap, routes, the eBPF `vrf_table` entry, or `BGPAdvertisement`/`BGPVRFInstance` CRDs — each CNI-chain binary's own DEL only handles its own per-container bookkeeping. Shared-resource cleanup is entirely this GC controller's job (`internal/gc`), to avoid racing a concurrent ADD during pod restarts. - Production images are published by `.github/workflows/publish.yaml` as separate per-binary images (`galactic-cni`, `galactic-router`, `galactic-gateway`), not one shared image — see CI/CD above. -- `internal/controller` also hosts `NetworkGatewayReconciler`/`NetworkRuleReconciler` — these register with `galactic-gateway`'s manager, not `galactic-router`'s (`usidresolver.go` and `rulebackends.go` are shared: `NetworkRuleBindingReconciler` and `ServiceVIPBindingReconciler` use them here too) (compare `cmd/galactic-router/root.go`'s controller registration list in Entry Points above against `cmd/galactic-gateway/root.go`'s — see [ARCHITECTURE-GATEWAY.md](ARCHITECTURE-GATEWAY.md)). Don't assume every reconciler type in this package runs in this binary. +- `internal/controller` also hosts `NetworkGatewayReconciler`/`NetworkServiceReconciler` — these register with `galactic-gateway`'s manager, not `galactic-router`'s (`usidresolver.go` and `rulebackends.go` are shared: `NetworkServiceBindingReconciler` and `ServiceVIPBindingReconciler` use them here too) (compare `cmd/galactic-router/root.go`'s controller registration list in Entry Points above against `cmd/galactic-gateway/root.go`'s — see [ARCHITECTURE-GATEWAY.md](ARCHITECTURE-GATEWAY.md)). Don't assume every reconciler type in this package runs in this binary. diff --git a/docs/agents/ARCHITECTURE.md b/docs/agents/ARCHITECTURE.md index 18fc36a5..30d8e9f8 100644 --- a/docs/agents/ARCHITECTURE.md +++ b/docs/agents/ARCHITECTURE.md @@ -14,7 +14,7 @@ their own architecture document: | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | [ARCHITECTURE-CNI.md](ARCHITECTURE-CNI.md) | The CNI attach chain — `galactic-cni` (installer), `galactic-veth`, `galactic-tap`, `galactic-ipam`, `galactic-bgp`, `galactic-route` | | [ARCHITECTURE-ROUTER.md](ARCHITECTURE-ROUTER.md) | The BGP/EVPN control plane — `galactic-router` | -| [ARCHITECTURE-GATEWAY.md](ARCHITECTURE-GATEWAY.md) | The edge XDP DSR load balancer — `galactic-gateway`, `NetworkGateway`/`NetworkRule` | +| [ARCHITECTURE-GATEWAY.md](ARCHITECTURE-GATEWAY.md) | The edge XDP DSR load balancer — `galactic-gateway`, `NetworkGateway`/`NetworkService` | | [ARCHITECTURE-FABRIC-API.md](ARCHITECTURE-FABRIC-API.md) | The fabric looking glass — `fabric-api` (`node` sidecar, cell `gateway`, `janitor`), `FabricQuery` | The fourth, `galactic-nat` (sharded stateful egress translation, `EgressShard`), diff --git a/docs/agents/CONVENTIONS.md b/docs/agents/CONVENTIONS.md index 91a0990e..e79b7e03 100644 --- a/docs/agents/CONVENTIONS.md +++ b/docs/agents/CONVENTIONS.md @@ -41,7 +41,7 @@ Use three groups, separated by blank lines: then `go.datum.net`-prefixed packages, then everything else (see `.golangci.yaml`). Alias imports use a short, descriptive name — typically the API group + version for generated clients (`metav1`, `corev1`, `bgpv1alpha1` for -`go.datum.net/network/api/v1alpha1`, `apierrors`, `clientgoscheme`, +`go.datum.net/galactic/api/network/v1alpha1`, `apierrors`, `clientgoscheme`, `utilruntime`) or a package-role name to disambiguate collisions (`vrfpkg`, `galacticruntime`). diff --git a/docs/api/cloud.md b/docs/api/cloud.md new file mode 100644 index 00000000..e6262484 --- /dev/null +++ b/docs/api/cloud.md @@ -0,0 +1,309 @@ +# API Reference + +## Packages +- [galactic.datumapis.com/v1alpha1](#galacticdatumapiscomv1alpha1) + + +## galactic.datumapis.com/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the galactic.datumapis.com/v1alpha1 API group. + +### Resource Types +- [NetworkFabricIdentity](#networkfabricidentity) +- [VPC](#vpc) +- [VPCAttachment](#vpcattachment) + + + +#### IPAddress + +_Underlying type:_ _string_ + +IPAddress is an IPv4 or IPv6 address with CIDR notation. + +_Validation:_ +- MaxLength: 64 + +_Appears in:_ +- [VPCAttachmentInterface](#vpcattachmentinterface) + + + +#### Network + +_Underlying type:_ _string_ + +Network is an IPv4 or IPv6 CIDR block (e.g., "10.0.0.0/24"). + +_Validation:_ +- MaxLength: 64 + +_Appears in:_ +- [VPCSpec](#vpcspec) + + + +#### NetworkFabricIdentity + + + +NetworkFabricIdentity tells a location what identity the fabric knows a +network by. + +There is one per network, not one per location. A VPC is the network's +realization at a single location and takes its identity from here, which is +what makes the locations of one network the same network on the fabric +instead of unrelated ones that happen to share a name. + +This is platform-internal. It is written centrally and carried to the cells +where the network is required; it never appears in a project control plane +and no consumer reads or writes one. The identity is a value the fabric acts +on directly, so it is kept to the platform rather than published beside the +network it belongs to. + +This object is managed for you. It follows the Network it was allocated for. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `NetworkFabricIdentity` | | | +| `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` _[NetworkFabricIdentitySpec](#networkfabricidentityspec)_ | Spec is the whole of this object. There is no status: federation carries
configuration to a cell and deliberately does not carry status, so
anything a cell has to read has to be here. | | | + + +#### NetworkFabricIdentityNetworkRef + + + +NetworkFabricIdentityNetworkRef identifies the network an identity was +allocated for. + + + +_Appears in:_ +- [NetworkFabricIdentitySpec](#networkfabricidentityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the network's name. | | Required: \{\}
| + + +#### NetworkFabricIdentitySpec + + + +NetworkFabricIdentitySpec carries the identity the fabric knows one network +by. + + + +_Appears in:_ +- [NetworkFabricIdentity](#networkfabricidentity) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `identity` _integer_ | Identity is what the fabric knows the network by, the same in every
location the network reaches. The Route Target is derived from it, which
is what makes two locations of one network import each other's routes
rather than behave as two networks that share a name. The VRF device is
named from it for the same reason.
It is an integer rather than an encoded string because a consumer builds
`ASN:` from it and encodes it for its own use. It is 32 bits
wide because that is what survives into the Route Target: the fabric
truncates, so a wider value would be uniqueness the platform believes it
has and the fabric does not.
It is never zero and never changes. The fabric embeds it in import policy
in every location the network reaches, so a network that changed identity
would be a different network to everything already carrying its traffic. | | Maximum: 4.294967295e+09
Minimum: 1
Required: \{\}
| +| `networkRef` _[NetworkFabricIdentityNetworkRef](#networkfabricidentitynetworkref)_ | NetworkRef names the network this identity belongs to.
It carries a name and no UID, deliberately. The identity is a permanent
property of a name in a namespace, not of one object's lifetime: a
network deleted and recreated under the same name inherits it. A UID here
would document the opposite of the rule. | | Required: \{\}
| + + +#### NetworkInterfaceRef + + + +NetworkInterfaceRef references a networking.datumapis.com NetworkInterface in +the same namespace. + + + +_Appears in:_ +- [VPCAttachmentSpec](#vpcattachmentspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the NetworkInterface. | | MinLength: 1
| + + +#### VPC + + + +VPC represents a virtual private cloud — an isolated Layer 2 domain backed +by one or more CIDR blocks. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `VPC` | | | +| `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` _[VPCSpec](#vpcspec)_ | Desired CIDR address space. | | | +| `status` _[VPCStatus](#vpcstatus)_ | Controller-observed state. | | | + + +#### VPCAttachment + + + +VPCAttachment is the Schema for the vpcattachments API + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `VPCAttachment` | | | +| `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` _[VPCAttachmentSpec](#vpcattachmentspec)_ | spec defines the desired state of VPCAttachment | | | +| `status` _[VPCAttachmentStatus](#vpcattachmentstatus)_ | status defines the observed state of VPCAttachment | | | + + +#### VPCAttachmentInterface + + + +VPCAttachmentInterface defines the network interface details. + + + +_Appears in:_ +- [VPCAttachmentSpec](#vpcattachmentspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the interface (e.g., eth0). | | | +| `mode` _[VPCAttachmentInterfaceMode](#vpcattachmentinterfacemode)_ | Mode is how the workload consumes the interface, resolved and written by
the attachment controller rather than by whoever runs the workload. | Netns | Enum: [Netns Hypervisor HypervisorDeclared]
| +| `addresses` _[IPAddress](#ipaddress) array_ | A list of IPv4 or IPv6 addresses associated with the interface. Empty when
the guest manages its own addressing. | | MaxItems: 16
MaxLength: 64
| + + +#### VPCAttachmentInterfaceMode + +_Underlying type:_ _string_ + +VPCAttachmentInterfaceMode is how the workload consumes the interface. It +describes the guest, not the data plane, so a change of implementation on the +data plane side does not move this API. + +_Validation:_ +- Enum: [Netns Hypervisor HypervisorDeclared] + +_Appears in:_ +- [VPCAttachmentInterface](#vpcattachmentinterface) + +| Field | Description | +| --- | --- | +| `Netns` | VPCAttachmentInterfaceModeNetns moves the interface into the workload's
network namespace, which is what a container consumes.
| +| `Hypervisor` | VPCAttachmentInterfaceModeHypervisor hands the interface to a hypervisor as
a device, which is what a virtual machine guest consumes.
| +| `HypervisorDeclared` | VPCAttachmentInterfaceModeHypervisorDeclared also hands the interface to a
hypervisor as a device. It differs from Hypervisor in who tells the
hypervisor that the device exists. Under Hypervisor the hypervisor finds
the device from what the node publishes. Under HypervisorDeclared the data
plane states the device, its addresses, and its MTU to the hypervisor
directly, which is what a guest whose hypervisor reads no node state
needs.
| + + +#### VPCAttachmentSpec + + + +VPCAttachmentSpec defines the desired state of VPCAttachment + + + +_Appears in:_ +- [VPCAttachment](#vpcattachment) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vpc` _[VPCRef](#vpcref)_ | VPC this attachment belongs to. | | | +| `interfaceRef` _[NetworkInterfaceRef](#networkinterfaceref)_ | NetworkInterface this attachment realizes. | | | +| `interface` _[VPCAttachmentInterface](#vpcattachmentinterface)_ | Interface defines the network interface configuration. | | | + + +#### VPCAttachmentStatus + + + +VPCAttachmentStatus defines the observed state of VPCAttachment. + +Every field but Conditions is optional: an identifier is recorded before a pod +attaches, and a guest managing its own addressing never reports a subnet. + + + +_Appears in:_ +- [VPCAttachment](#vpcattachment) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | | | | +| `vpc` _string_ | Base62-encoded VPC identifier. | | MaxLength: 16
MinLength: 1
| +| `vpcAttachment` _string_ | Base62-encoded VPCAttachment identifier. | | MaxLength: 16
MinLength: 1
| +| `node` _string_ | Kubernetes node name where the attachment lives. | | MinLength: 1
| +| `containerID` _string_ | Full container ID (46 hex characters). | | MaxLength: 46
MinLength: 46
| +| `podName` _string_ | Pod name. | | MinLength: 1
| +| `hostInterface` _string_ | Host-side veth or tap device name (e.g., "G000000010013H"). | | MinLength: 1
| +| `vrfInterface` _string_ | VRF device name, which is per-VPC (e.g., "G000000010V"). | | MinLength: 1
| +| `guestInterface` _string_ | Guest-side veth device name (e.g., "G000000010013G"). | | MinLength: 1
| +| `podSubnet` _string_ | Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80"). | | MinLength: 1
| +| `networkAttachmentDefinition` _string_ | NetworkAttachmentDefinition rendered for this attachment. | | MinLength: 1
| + + +#### VPCRef + + + +VPCRef references a VPC by name within the same namespace. + + + +_Appears in:_ +- [VPCAttachmentSpec](#vpcattachmentspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the VPC. | | MinLength: 1
| + + +#### VPCSpec + + + +VPCSpec defines the desired state of a VPC. It specifies the CIDR address space. + + + +_Appears in:_ +- [VPC](#vpc) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `networks` _[Network](#network) array_ | CIDR blocks that form the VPC address space. | | MaxItems: 64
MaxLength: 64
MinItems: 1
| + + +#### VPCStatus + + + +VPCStatus defines the observed state of a VPC, populated by the controller. + + + +_Appears in:_ +- [VPC](#vpc) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | | | | +| `vpc` _string_ | Base62-encoded VPC identifier. | | MaxLength: 16
MinLength: 1
| diff --git a/docs/api/network-bgp.md b/docs/api/network-bgp.md new file mode 100644 index 00000000..aaf9e05c --- /dev/null +++ b/docs/api/network-bgp.md @@ -0,0 +1,1785 @@ +# API Reference + +## Packages +- [galactic.datumapis.com/v1alpha1](#galacticdatumapiscomv1alpha1) + + +## galactic.datumapis.com/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the galactic.datumapis.com/v1alpha1 API group. + +### Resource Types +- [BGPAdvertisement](#bgpadvertisement) +- [BGPCommunitySet](#bgpcommunityset) +- [BGPPeer](#bgppeer) +- [BGPPolicy](#bgppolicy) +- [BGPRouter](#bgprouter) +- [BGPVRFInstance](#bgpvrfinstance) +- [EgressShard](#egressshard) +- [ServiceEndpoint](#serviceendpoint) +- [ServiceRoutePolicy](#serviceroutepolicy) +- [ServiceVIPBinding](#servicevipbinding) + + + +#### AFI + +_Underlying type:_ _string_ + +AFI is the Address Family Indicator for a BGP address family. + +_Validation:_ +- Enum: [ipv4 ipv6 l2vpn] + +_Appears in:_ +- [AddressFamily](#addressfamily) + +| Field | Description | +| --- | --- | +| `ipv4` | | +| `ipv6` | | +| `l2vpn` | | + + +#### ASPathFilter + + + +ASPathFilter matches BGP routes by AS path using a regex pattern. + + + +_Appears in:_ +- [BGPPolicyMatch](#bgppolicymatch) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `pattern` _string_ | Pattern is a regular expression matched against the AS path.
The AS path is represented as a space-separated string of ASNs. | | MaxLength: 256
MinLength: 1
| +| `matchType` _[ASPathMatchType](#aspathmatchtype)_ | MatchType determines whether the pattern must match the full path
or can match a substring. Default: "contains". | contains | Enum: [full contains]
| + + +#### ASPathMatchType + +_Underlying type:_ _string_ + +ASPathMatchType determines how an AS path filter pattern is applied. + +_Validation:_ +- Enum: [full contains] + +_Appears in:_ +- [ASPathFilter](#aspathfilter) + +| Field | Description | +| --- | --- | +| `full` | ASPathMatchFull requires the pattern to match the entire AS path.
| +| `contains` | ASPathMatchContains requires the pattern to match a substring of the AS path.
| + + +#### AddressClaimRef + + + +AddressClaimRef names the addressing-service claim an assigned address came +from. It is a record, not a lookup: the addressing service is an aggregated +API served on a project control-plane path and defines no CustomResourceDefinition, +so no claim object exists in the cluster a shard runs in and nothing here +resolves one. Reading it means addressing that project's control plane +directly, with a credential a translating node is deliberately not given. + +Every field is a plain string and this group depends on no addressing-service +type. The shape matches that service's own opaque cross-API reference, plus +the project, because a claim is namespaced within a project and a namespace +name alone does not identify one from outside. + +Populate it with the coordinates of the claim whose allocation produced the +address written alongside it: + + shardAddressIPv6: 2001:db8:f00d::100 + shardAddressIPv6ClaimRef: + project: datum-network-edge + namespace: egress + name: egress-shard-node-a-ipv6 + +The claim cannot record this relationship itself. The addressing service +overwrites a claim's spec.ownerRef on create with the identity that requested +it, so a claim made by a cell controller is attributed to that controller's +project rather than to the shard it was made for. Attribution therefore runs +in this direction only, and an operator asking which claim holds a shard's +address has this field or nothing. + + + +_Appears in:_ +- [EgressShardSpec](#egressshardspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiGroup` _string_ | APIGroup is the group of the claim resource. | ipam.miloapis.com | MinLength: 1
| +| `kind` _string_ | Kind is the kind of the claim resource. | IPClaim | MinLength: 1
| +| `project` _string_ | Project is the project whose control plane serves the claim. Required:
a claim is namespaced within a project, and this reference is read from
outside every project. | | MinLength: 1
Required: \{\}
| +| `namespace` _string_ | Namespace is the namespace holding the claim within Project. | | MinLength: 1
Required: \{\}
| +| `name` _string_ | Name is the name of the claim. The addressing service derives it from
the thing being addressed, so it is stable across a shard's lifetime and
is what a replacement finds again. | | MinLength: 1
Required: \{\}
| + + +#### AddressFamily + + + +AddressFamily is a BGP address family expressed as an AFI/SAFI pair. +Valid combinations: ipv4/unicast, ipv6/unicast, l2vpn/evpn. + + + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) +- [BGPPeerSpec](#bgppeerspec) +- [BGPPolicyMatch](#bgppolicymatch) +- [BGPRouterSpec](#bgprouterspec) +- [ResolvedRouterConfig](#resolvedrouterconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `afi` _[AFI](#afi)_ | AFI is the address family indicator. | | Enum: [ipv4 ipv6 l2vpn]
| +| `safi` _[SAFI](#safi)_ | SAFI is the subsequent address family indicator. | | Enum: [unicast evpn]
| + + +#### AdvertisementOriginateFrom + + + +AdvertisementOriginateFrom defines how routes are sourced from a local system +resource rather than from the static Prefixes list. + + + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _[AdvertisementOriginateType](#advertisementoriginatetype)_ | Type is the source from which routes are originated. | | Enum: [interface kernel]
| +| `interfaceName` _string_ | InterfaceName is the name of the interface to originate routes from.
Required when type is "interface". | | MinLength: 1
| + + +#### AdvertisementOriginateType + +_Underlying type:_ _string_ + +AdvertisementOriginateType is the source from which routes are originated. + +_Validation:_ +- Enum: [interface kernel] + +_Appears in:_ +- [AdvertisementOriginateFrom](#advertisementoriginatefrom) + +| Field | Description | +| --- | --- | +| `interface` | OriginateTypeInterface originates routes from local interface addresses.
| +| `kernel` | OriginateTypeKernel originates routes learned from the kernel routing table.
| + + +#### AdvertisementPolicyRef + + + +AdvertisementPolicyRef references a BGPPolicy by name to apply as a conditional +filter before advertisement. + + + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the BGPPolicy within the same namespace. | | MinLength: 1
| + + +#### AsPathSet + + + +AsPathSet defines AS path manipulation operations. + + + +_Appears in:_ +- [BGPPolicySetActions](#bgppolicysetactions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `prepend` _integer_ | Prepend adds an ASN to the AS path N times. | | Maximum: 10
Minimum: 1
| +| `asn` _integer_ | ASN is the AS number to prepend (used when prepend is set).
Defaults to the local ASN if not specified. | | | +| `replace` _integer array_ | Replace replaces the entire AS path with the given list.
Mutually exclusive with prepend. | | MaxItems: 32
| + + +#### BGPAdvertisement + + + +BGPAdvertisement defines routing information to advertise from a single BGPRouter. +Routes are originated from static Prefixes, redistributed routing table entries, +or local interface/kernel routes. Fan-out via routerSelector is intentionally not +supported to avoid ambiguous prefix attribution across multiple routers. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPAdvertisement` | | | +| `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` _[BGPAdvertisementSpec](#bgpadvertisementspec)_ | | | | +| `status` _[BGPAdvertisementStatus](#bgpadvertisementstatus)_ | | | | + + +#### BGPAdvertisementSpec + + + +BGPAdvertisementSpec defines the desired advertisement state. +At least one of prefixes, redistribute, or originateFrom must be specified. + + + +_Appears in:_ +- [BGPAdvertisement](#bgpadvertisement) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerRef` _[RouterRef](#routerref)_ | RouterRef targets a single BGPRouter by name. | | Required: \{\}
| +| `addressFamily` _[AddressFamily](#addressfamily)_ | AddressFamily defines the AFI/SAFI for this advertisement. | | Required: \{\}
| +| `prefixes` _[Prefix](#prefix) array_ | Prefixes is the list of CIDR prefixes to advertise.
At least one of Prefixes, Redistribute, or OriginateFrom must be specified. | | MaxItems: 256
MaxLength: 64
| +| `redistribute` _[RedistributeSource](#redistributesource) array_ | Redistribute defines routing table sources to redistribute into BGP.
Routes matching the source type are originated without requiring explicit CIDR entries.
At least one of Prefixes, Redistribute, or OriginateFrom must be specified. | | Enum: [static connected kernel]
MaxItems: 3
| +| `originateFrom` _[AdvertisementOriginateFrom](#advertisementoriginatefrom)_ | OriginateFrom defines how routes are sourced from a local interface or kernel table.
When set, routes are originated from the specified source in addition to any static Prefixes.
At least one of Prefixes, Redistribute, or OriginateFrom must be specified. | | | +| `policyRef` _[AdvertisementPolicyRef](#advertisementpolicyref)_ | PolicyRef references a BGPPolicy to apply as a conditional filter before advertisement.
Only routes that match the policy are originated. | | | +| `communities` _[Community](#community) array_ | Communities is the default list of BGP communities to attach to all advertised prefixes.
Per-prefix communities in Prefixes[n].communities replace this value for individual prefixes. | | MaxItems: 64
MaxLength: 32
| +| `localPreference` _integer_ | LocalPreference sets the default BGP LOCAL_PREF attribute for all advertised prefixes.
Per-prefix localPreference in Prefixes[n].localPreference overrides this value.
Only meaningful for iBGP sessions. | | Minimum: 0
| +| `vrfID` _integer_ | VRFID is the 16-bit PoP-local VRF identifier this advertisement's SRv6
SID resolves into, per RFC 9800 uSID Argument addressing. Required when
Function is set. | | Maximum: 65535
Minimum: 1
| +| `function` _[SRv6Function](#srv6function)_ | Function is the RFC 8986 SRv6 endpoint behavior applied when this
advertisement originates a compressed uSID. Required when VRFID is set. | | Enum: [End.DT4 End.DT6 End.DT46]
| + + +#### BGPAdvertisementStatus + + + +BGPAdvertisementStatus defines the observed state of BGPAdvertisement. + + + +_Appears in:_ +- [BGPAdvertisement](#bgpadvertisement) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `advertisedPrefixes` _integer_ | AdvertisedPrefixes is the count of prefixes currently being originated. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### BGPCommunitySet + + + +BGPCommunitySet defines a named list of BGP community values used by BGPPolicy +terms to match or set communities on routes. It is referenced by name from +BGPPolicyMatch.communitySetRef. + +A single BGPCommunitySet holds either standard or large communities — the type +is declared upfront and all entries are validated against it via CEL. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPCommunitySet` | | | +| `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` _[BGPCommunitySetSpec](#bgpcommunitysetspec)_ | | | | +| `status` _[BGPCommunitySetStatus](#bgpcommunitysetstatus)_ | | | | + + +#### BGPCommunitySetSpec + + + +BGPCommunitySetSpec defines the desired state of BGPCommunitySet. + + + +_Appears in:_ +- [BGPCommunitySet](#bgpcommunityset) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _[BGPCommunitySetType](#bgpcommunitysettype)_ | Type is the community format for all entries in this set. | | Enum: [standard large]
Required: \{\}
| +| `entries` _string array_ | Entries is the list of community values.
Standard: ASN:NN, IP:NN, or well-known names (graceful-shutdown, no-export,
no-advertise, blackhole).
Large: ASN:NN:NN. | | MaxItems: 256
MinItems: 1
| + + +#### BGPCommunitySetStatus + + + +BGPCommunitySetStatus defines the observed state of BGPCommunitySet. + + + +_Appears in:_ +- [BGPCommunitySet](#bgpcommunityset) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### BGPCommunitySetType + +_Underlying type:_ _string_ + +BGPCommunitySetType distinguishes standard from large BGP communities. + +_Validation:_ +- Enum: [standard large] + +_Appears in:_ +- [BGPCommunitySetSpec](#bgpcommunitysetspec) + +| Field | Description | +| --- | --- | +| `standard` | BGPCommunitySetTypeStandard covers ASN:NN, IP:NN, and well-known names
(graceful-shutdown, no-export, no-advertise, blackhole).
| +| `large` | BGPCommunitySetTypeLarge covers ASN:NN:NN large communities.
| + + +#### BGPMaximumPrefix + + + +BGPMaximumPrefix defines the maximum number of routes accepted from a peer +before triggering a shutdown action. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `limit` _integer_ | Limit is the maximum number of routes accepted from this peer. | | Minimum: 1
| +| `thresholdPercent` _integer_ | ThresholdPercent is the percentage threshold at which a warning is
logged (e.g., 75 means warn at 75% of Limit). Range: 1-100. | | Maximum: 100
Minimum: 1
| +| `shutdownAction` _[MaxPrefixShutdownAction](#maxprefixshutdownaction)_ | ShutdownAction is the action taken when the Limit is exceeded. | | Enum: [warning-only restart shutdown]
Required: \{\}
| + + +#### BGPOrigin + +_Underlying type:_ _string_ + +BGPOrigin is the BGP origin attribute per RFC 4271. + +_Validation:_ +- Enum: [igp egp incomplete] + +_Appears in:_ +- [BGPPolicySetActions](#bgppolicysetactions) + +| Field | Description | +| --- | --- | +| `igp` | BGPOriginIGP indicates the route was learned via an IGP.
| +| `egp` | BGPOriginEGP indicates the route was learned via the EGP protocol.
| +| `incomplete` | BGPOriginIncomplete indicates the route origin is unknown.
| + + +#### BGPPeer + + + +BGPPeer defines a BGP session to a remote peer. It binds to one or more +BGPRouter instances via routerRef or routerSelector. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPPeer` | | | +| `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` _[BGPPeerSpec](#bgppeerspec)_ | | | | +| `status` _[BGPPeerStatus](#bgppeerstatus)_ | | | | + + +#### BGPPeerAuthentication + + + +BGPPeerAuthentication defines authentication configuration for a BGP peer. + +Supports both secret-ref-based (MD5/TCP-AO) and plain-text passwords. +The plain-text password is primarily for non-production environments. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `password` _string_ | Password is a plain-text authentication password.
Prefer AuthSecretRef for production deployments. | | | + + +#### BGPPeerBFD + + + +BGPPeerBFD defines BFD (Bidirectional Forwarding Detection) parameters for a BGP peer. + +BFD provides sub-second failure detection for BGP sessions, enabling fast +convergence without relying on BGP hold timers (default 90s). + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled indicates whether BFD is enabled for this peer. | | | +| `minimumTx` _integer_ | MinimumTx is the minimum transmission interval in microseconds.
FRR / GoBGP use microseconds — the controller converts to milliseconds
when programming the runtime. Defaults to 300000 (300ms). | | | +| `minimumRx` _integer_ | MinimumRx is the minimum reception interval in microseconds.
Defaults to 300000 (300ms). | | | +| `detectMultiplier` _integer_ | DetectMultiplier is the number of missed packets before declaring the
BFD session down. Default is 3. | | Maximum: 50
Minimum: 3
| +| `multiHop` _boolean_ | MultiHop enables BFD for eBGP multi-hop sessions (RFC 5883).
Must be true when ebgpMultiHop is true. | | | + + +#### BGPPeerGracefulRestart + + + +BGPPeerGracefulRestart defines BGP graceful restart parameters for a peer. + +Graceful restart (RFC 4724) allows a BGP speaker to restart its control plane +without tearing down sessions. Routes are preserved during the restart period, +preventing MAC/IP flapping in EVPN deployments. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled indicates whether graceful restart is enabled for this peer. | | | +| `restartTime` _integer_ | RestartTime is the maximum time (in seconds) the peer expects the
local system to complete a graceful restart. Range: 1-1200.
Default: 120 seconds. | | Maximum: 1200
Minimum: 1
| + + +#### BGPPeerSpec + + + +BGPPeerSpec defines the desired state of BGPPeer. + + + +_Appears in:_ +- [BGPPeer](#bgppeer) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerRef` _[RouterRef](#routerref)_ | RouterRef targets a single BGPRouter by name.
Mutually exclusive with routerSelector. | | | +| `routerSelector` _[RouterSelector](#routerselector)_ | RouterSelector targets one or more BGPRouters by label.
Mutually exclusive with routerRef. | | | +| `peerASN` _integer_ | PeerASN is the remote AS number. | | Minimum: 1
Required: \{\}
| +| `address` _string_ | Address is the remote peer's IPv4 or IPv6 address. | | MinLength: 1
Required: \{\}
| +| `remotePort` _integer_ | RemotePort is the TCP port used to establish the BGP session with
this peer. Defaults to 179 (the IANA-assigned BGP port) if unset. | | Maximum: 65535
Minimum: 1
| +| `description` _string_ | Description is a human-readable label for this peer (e.g., "spine-1"). | | | +| `authSecretRef` _[LocalSecretRef](#localsecretref)_ | AuthSecretRef references a Secret in the same namespace containing the
MD5 TCP authentication password under the key "password". | | | +| `addressFamilies` _[AddressFamily](#addressfamily) array_ | AddressFamilies defines the address families negotiated on this session. | | MinItems: 1
| +| `holdTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#duration-v1-meta)_ | HoldTime is the BGP hold timer. Must be 0 (disabled) or >= 3s.
Defaults to 90s if unset. | | | +| `keepaliveTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#duration-v1-meta)_ | KeepaliveTime is the BGP keepalive interval. Must be <= HoldTime / 3.
Defaults to 30s if unset. Maximum is 30s; the controller enforces
the keepalive <= holdTime / 3 constraint with an Accepted=False condition. | | | +| `ebgpMultiHop` _boolean_ | EbgpMultiHop enables eBGP multihop when the peer is not directly connected.
When true, the TTL for BGP packets is set to 64 (vs. 1 for direct). | | | +| `bfd` _[BGPPeerBFD](#bgppeerbfd)_ | BFD configures Bidirectional Forwarding Detection for fast failure
detection (sub-500ms). Without BFD, convergence relies on BGP hold
timers (default 90s). | | | +| `gracefulRestart` _[BGPPeerGracefulRestart](#bgppeergracefulrestart)_ | GracefulRestart configures BGP graceful restart for this peer.
Prevents route flapping during control-plane restarts. | | | +| `multiSession` _boolean_ | MultiSession enables BFD over MP-BGP (RFC 8935). When true, each
AFI/SAFI negotiates its own BFD session independently. When false
(default), a single BFD session covers all AFI/SAFIs. | | | +| `routeMapIn` _string_ | RouteMapIn is the name of a BGPPolicy term set applied to routes
received from this peer (import direction). The name must match
a BGPPolicy resource in the same namespace. | | | +| `routeMapOut` _string_ | RouteMapOut is the name of a BGPPolicy term set applied to routes
advertised to this peer (export direction). The name must match
a BGPPolicy resource in the same namespace. | | | +| `nextHopSelf` _boolean_ | NextHopSelf controls next-hop behavior for iBGP sessions.
When true, the local router's IP is used as next-hop for all
advertised routes. Common in EVPN iBGP peering. | | | +| `removePrivateAS` _integer_ | RemovePrivateAS strips private AS numbers (64512-65535, 4200000000-4294967294)
from the AS path on eBGP export. If set to a non-zero value, private ASNs
are replaced with the specified value before export. | | Minimum: 0
| +| `updateSource` _string_ | UpdateSource is the local IP address or interface name used as the
source for the BGP TCP session. Useful when the local router has
multiple addresses or when pinning to a specific loopback. | | | +| `sendCommunity` _[SendCommunityType](#sendcommunitytype)_ | SendCommunity controls which BGP community attributes are propagated
to this peer. When unset, communities are not sent. | | Enum: [standard extended large both]
| +| `allowASIn` _integer_ | AllowASIn allows routes that contain the local ASN in the AS_PATH
to be accepted from this peer. The value specifies the maximum number
of times the local ASN may appear. Range: 1-10. | | Maximum: 10
Minimum: 1
| +| `maximumPrefix` _[BGPMaximumPrefix](#bgpmaximumprefix)_ | MaximumPrefix defines the maximum number of routes accepted from this
peer before triggering a shutdown action. | | | +| `defaultOriginRoute` _[OriginType](#origintype)_ | DefaultOriginRoute controls default route origination for this peer.
"igp" originates a default route with IGP origin.
"egp" originates a default route with EGP origin.
"incomplete" originates a default route with incomplete origin.
Empty or unset means no default route origination. | | Enum: [igp egp incomplete]
| +| `authentication` _[BGPPeerAuthentication](#bgppeerauthentication)_ | Authentication configures peer authentication.
AuthSecretRef takes precedence when both are set. | | | + + +#### BGPPeerState + +_Underlying type:_ _string_ + +BGPPeerState represents the BGP Finite State Machine state of a session. + + + +_Appears in:_ +- [BGPPeerStatus](#bgppeerstatus) + +| Field | Description | +| --- | --- | +| `Idle` | | +| `Connect` | | +| `Active` | | +| `OpenSent` | | +| `OpenConfirm` | | +| `Established` | | + + +#### BGPPeerStatus + + + +BGPPeerStatus defines the observed state of BGPPeer. + + + +_Appears in:_ +- [BGPPeer](#bgppeer) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `sessionState` _[BGPPeerState](#bgppeerstate)_ | SessionState is the current BGP FSM state of this session. | | | +| `lastEstablishedTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | LastEstablishedTime is the timestamp of the most recent Established transition. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### BGPPolicy + + + +BGPPolicy defines composable, ordered routing policy statements applied to a +BGPRouter in a specific direction (import or export). It binds to one or more +BGPRouter instances via routerRef or routerSelector. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPPolicy` | | | +| `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` _[BGPPolicySpec](#bgppolicyspec)_ | | | | +| `status` _[BGPPolicyStatus](#bgppolicystatus)_ | | | | + + +#### BGPPolicyAction + +_Underlying type:_ _string_ + +BGPPolicyAction is the disposition applied when a policy term matches. + +_Validation:_ +- Enum: [permit deny] + +_Appears in:_ +- [BGPPolicyTerm](#bgppolicyterm) +- [BGPPrefixListEntry](#bgpprefixlistentry) + +| Field | Description | +| --- | --- | +| `permit` | BGPPolicyActionPermit allows the route and optionally applies set actions.
| +| `deny` | BGPPolicyActionDeny drops the route. Set actions must not be specified.
| + + +#### BGPPolicyDirection + +_Underlying type:_ _string_ + +BGPPolicyDirection is the direction in which a BGPPolicy is applied. + +_Validation:_ +- Enum: [import export] + +_Appears in:_ +- [BGPPolicySpec](#bgppolicyspec) + +| Field | Description | +| --- | --- | +| `import` | BGPPolicyDirectionImport applies the policy to routes received from peers.
| +| `export` | BGPPolicyDirectionExport applies the policy to routes advertised to peers.
| + + +#### BGPPolicyMatch + + + +BGPPolicyMatch defines the conditions under which a policy term fires. + + + +_Appears in:_ +- [BGPPolicyTerm](#bgppolicyterm) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `any` _boolean_ | Any matches all routes. When true, all other match fields are ignored. | | | +| `addressFamilies` _[AddressFamily](#addressfamily) array_ | AddressFamilies constrains the match to specific AFI/SAFI combinations.
If empty, all address families are matched. | | MaxItems: 8
| +| `prefixList` _string array_ | PrefixList constrains the match to routes whose prefix matches one of
the given CIDR blocks. Each entry must be a valid IPv4 or IPv6 CIDR. | | MaxItems: 256
items:MaxLength: 43
| +| `asPathFilter` _[ASPathFilter](#aspathfilter)_ | ASPathFilter matches routes by AS path using a regex pattern.
The pattern is matched against the full AS path string (space-separated ASNs). | | | +| `communityMatch` _string array_ | CommunityMatch matches routes by BGP community.
Each entry is a community string in ASN:NN or IP:NN format. | | MaxItems: 32
items:MaxLength: 32
| +| `evpnRouteType` _[EVPNRouteType](#evpnroutetype) array_ | EVPNRouteType matches specific EVPN route types.
Only meaningful when l2vpn/evpn address family is configured. | | Enum: [inclusiveMulticastEthernetTag macIPAdvertisement iPPrefixAdvertisement stickyMACAddress iPv6PrefixAdvertisement]
MaxItems: 5
| +| `vni` _integer_ | VNI matches routes by VNI (VXLAN Network Identifier).
Range: 0–16777215 (24-bit VNI). | | Maximum: 1.6777215e+07
Minimum: 0
| +| `macAddress` _string_ | MACAddress matches MAC-IP routes (EVPN Type-2) by MAC address.
Format: colon-separated hex bytes (e.g., "aa:bb:cc:dd:ee:ff"). | | Pattern: `^([0-9a-fA-F]\{2\}:)\{5\}[0-9a-fA-F]\{2\}$`
| +| `ipPrefix` _string_ | IPPrefix matches routes by exact IP prefix (CIDR notation). | | MaxLength: 43
| +| `localPreference` _integer_ | LocalPreference matches routes by BGP LOCAL_PREF value. | | Minimum: 0
| +| `med` _integer_ | MED matches routes by Multi-Exit Discriminator value. | | Minimum: 0
| + + +#### BGPPolicySetActions + + + +BGPPolicySetActions defines mutations applied when a term matches with action "permit". + + + +_Appears in:_ +- [BGPPolicyTerm](#bgppolicyterm) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `communities` _[CommunitySet](#communityset)_ | Communities defines community add/remove operations. | | | +| `localPreference` _integer_ | LocalPreference sets the LOCAL_PREF attribute.
Only meaningful on import (iBGP) or export to iBGP peers. | | Minimum: 0
| +| `origin` _[BGPOrigin](#bgporigin)_ | Origin sets the BGP origin attribute. | | Enum: [igp egp incomplete]
| +| `asPath` _[AsPathSet](#aspathset)_ | AsPath manipulates the AS path (prepend or replace). | | | +| `nextHop` _[NextHopSet](#nexthopset)_ | NextHop overrides the next-hop attribute. | | | +| `extCommunities` _[ExtendedCommunitySet](#extendedcommunityset)_ | ExtCommunities defines extended community add/remove operations.
Each entry must be in a valid extended community format (ASN:NN, IP:NN,
or type-specific like "rt:65000:100"). | | | +| `metric` _integer_ | Metric sets the MED (Multi-Exit Discriminator) attribute. | | Minimum: 0
| +| `color` _integer_ | Color sets the SRv6 policy color for path selection. | | Minimum: 0
| + + +#### BGPPolicySpec + + + +BGPPolicySpec defines the desired route policy state. + + + +_Appears in:_ +- [BGPPolicy](#bgppolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerRef` _[RouterRef](#routerref)_ | RouterRef targets a single BGPRouter by name.
Mutually exclusive with routerSelector. | | | +| `routerSelector` _[RouterSelector](#routerselector)_ | RouterSelector targets one or more BGPRouters by label.
Mutually exclusive with routerRef. | | | +| `direction` _[BGPPolicyDirection](#bgppolicydirection)_ | Direction is the policy direction: import or export. | | Enum: [import export]
Required: \{\}
| +| `terms` _[BGPPolicyTerm](#bgppolicyterm) array_ | Terms is the ordered list of policy statements.
Evaluated from lowest to highest sequence number. | | MaxItems: 32
MinItems: 1
| + + +#### BGPPolicyStatus + + + +BGPPolicyStatus defines the observed state of BGPPolicy. + + + +_Appears in:_ +- [BGPPolicy](#bgppolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### BGPPolicyTerm + + + +BGPPolicyTerm is a single ordered policy statement with match conditions and an action. + + + +_Appears in:_ +- [BGPPolicySpec](#bgppolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `sequence` _integer_ | Sequence is the evaluation order. Lower values are evaluated first.
Must be unique within the policy. | | Maximum: 65535
Minimum: 1
| +| `match` _[BGPPolicyMatch](#bgppolicymatch)_ | Match defines the conditions under which this term fires. | | | +| `action` _[BGPPolicyAction](#bgppolicyaction)_ | Action is the disposition when this term matches. | | Enum: [permit deny]
| +| `set` _[BGPPolicySetActions](#bgppolicysetactions)_ | Set defines mutations applied when action is "permit".
Must not be set when action is "deny". | | | + + +#### BGPPrefixListEntry + + + +BGPPrefixListEntry defines a single entry in a named BGP prefix-list. +Entries are evaluated in ascending sequence order. + + + +_Appears in:_ +- [BGPPrefixListSpec](#bgpprefixlistspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `sequence` _integer_ | Sequence is the evaluation order for this entry. Entries with lower
sequence numbers are evaluated first. Must be unique within the list. | | Maximum: 4.294967295e+09
Minimum: 5
| +| `action` _[BGPPolicyAction](#bgppolicyaction)_ | Action is the disposition when a route's prefix matches this entry. | | Enum: [permit deny]
Required: \{\}
| +| `prefix` _string_ | Prefix is the IPv4 or IPv6 CIDR to match against. | | MinLength: 1
Required: \{\}
| +| `ge` _integer_ | GE is the minimum prefix-length to match (greater-than-or-equal).
For example, "10.0.0.0/8 ge 16" matches 10.0.0.0/16 through 10.0.0.0/32.
When unset, the prefix-length of Prefix is the lower bound. | | Maximum: 32
Minimum: 0
| +| `le` _integer_ | LE is the maximum prefix-length to match (less-than-or-equal).
For example, "10.0.0.0/8 le 24" matches 10.0.0.0/8 through 10.0.0.0/24.
When unset, the prefix-length of Prefix is the upper bound. | | Maximum: 32
Minimum: 0
| + + + + + + +#### BGPRouter + + + +BGPRouter defines a logical BGP routing context. It abstracts a processing +instance bound to a specific execution context (e.g., a VRF or network +namespace) on a target node and acts as the primary ownership boundary for +BGPPeer, BGPAdvertisement, and BGPPolicy resources. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPRouter` | | | +| `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` _[BGPRouterSpec](#bgprouterspec)_ | | | | +| `status` _[BGPRouterStatus](#bgprouterstatus)_ | | | | + + +#### BGPRouterPeerSummary + + + +BGPRouterPeerSummary summarizes BGP peer session counts. + + + +_Appears in:_ +- [BGPRouterStatus](#bgprouterstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `total` _integer_ | Total is the total number of configured peers. | | | +| `established` _integer_ | Established is the count of peers currently in the Established state. | | | + + +#### BGPRouterPhase + +_Underlying type:_ _string_ + +BGPRouterPhase describes the lifecycle phase of a BGPRouter. + + + +_Appears in:_ +- [BGPRouterStatus](#bgprouterstatus) + +| Field | Description | +| --- | --- | +| `Pending` | | +| `Ready` | | +| `Failed` | | + + +#### BGPRouterSpec + + + +BGPRouterSpec defines the desired state of a BGPRouter. + + + +_Appears in:_ +- [BGPRouter](#bgprouter) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this router executes on. | | Required: \{\}
| +| `localASN` _integer_ | LocalASN is the BGP Autonomous System Number for this router.
Must be a valid 2-byte or 4-byte ASN per RFC 6793. | | Minimum: 1
Required: \{\}
| +| `routerID` _string_ | RouterID is a unique 32-bit identifier expressed in IPv4 dotted-decimal notation.
In an IPv6-only underlay this is a logical identifier only. | | Format: ipv4
Required: \{\}
| +| `addressFamilies` _[AddressFamily](#addressfamily) array_ | AddressFamilies defines the address families this router activates. | | MinItems: 1
| +| `srv6Locator` _string_ | SRv6Locator is the SRv6 locator block this router owns, expressed as an
IPv6 CIDR (e.g. "2001:db8:ff01::/48"). Individual SRv6 endpoint SIDs are
host addresses within this block. | | | +| `nodeID` _integer_ | NodeID is this router's 16-bit slot within its PoP's SRv6Locator block,
used for RFC 9800 NEXT-CSID compression. Unique within the PoP.
The uSID carrier format reserves 0x0000 and 0xE000-0xFFFF (the latter is
Function space, not Node-ID space), leaving 0x0001-0xDFFF assignable. | | Maximum: 57343
Minimum: 1
| +| `listenPort` _integer_ | ListenPort is the TCP port this router listens on for incoming BGP
peer connections. Defaults to 179 (the IANA-assigned BGP port) if unset. | | Maximum: 65535
Minimum: 1
| + + +#### BGPRouterStatus + + + +BGPRouterStatus defines the observed state of a BGPRouter. + + + +_Appears in:_ +- [BGPRouter](#bgprouter) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `phase` _[BGPRouterPhase](#bgprouterphase)_ | Phase is the high-level lifecycle state of this router. | | | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `peers` _[BGPRouterPeerSummary](#bgprouterpeersummary)_ | Peers summarizes peer session counts. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### BGPVRFInstance + + + +BGPVRFInstance configures an L2VPN EVPN VRF on matched BGPRouters. +The referenced BGPRouter must have l2vpn-evpn in its addressFamilies. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `BGPVRFInstance` | | | +| `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` _[BGPVRFInstanceSpec](#bgpvrfinstancespec)_ | | | | +| `status` _[BGPVRFInstanceStatus](#bgpvrfinstancestatus)_ | | | | + + +#### BGPVRFInstanceSpec + + + +BGPVRFInstanceSpec defines the desired VRF configuration. + + + +_Appears in:_ +- [BGPVRFInstance](#bgpvrfinstance) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerRef` _[RouterRef](#routerref)_ | RouterRef targets a single BGPRouter by name.
Mutually exclusive with routerSelector. | | | +| `routerSelector` _[RouterSelector](#routerselector)_ | RouterSelector targets one or more BGPRouters by label.
Mutually exclusive with routerRef. | | | +| `vrfID` _integer_ | VRFID is the 16-bit PoP-local VRF identifier used for RFC 9800 uSID
Argument addressing and to derive the RFC 4364 Type 1 Route
Distinguisher ("routerID:vrfID"). Unique per (VPC, PoP). Value 0 is
reserved. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `importRouteTargets` _[RouteTarget](#routetarget) array_ | ImportRouteTargets is the list of BGP extended community route targets
used to import routes into this VRF. | | MaxItems: 32
MinItems: 1
| +| `exportRouteTargets` _[RouteTarget](#routetarget) array_ | ExportRouteTargets is the list of BGP extended community route targets
attached to routes exported from this VRF. | | MaxItems: 32
MinItems: 1
| +| `nptv6` _[NPTv6Spec](#nptv6spec)_ | NPTv6 configures stateless RFC 6296 Network Prefix Translation for
this VRF: backends presenting an address within ULAPrefix are
translated, checksum-neutrally and bidirectionally, to the
corresponding address in PublicPrefix. Unset means this VRF's traffic
crosses the SRv6 fabric with its own ULA source/destination
unmodified — the common case. Keyed by this VRFID, never by address,
specifically so two VRFs may configure the identical ULAPrefix (e.g.
both using the same private range) without collision — each VRF's
mapping is independent, looked up only after this VRF's own identity
is already resolved from the SRv6 uSID Argument, never derived from
address content alone. | | | + + +#### BGPVRFInstanceStatus + + + +BGPVRFInstanceStatus defines the observed state of BGPVRFInstance. + + + +_Appears in:_ +- [BGPVRFInstance](#bgpvrfinstance) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions are top-level conditions for this BGPVRFInstance. | | | +| `routers` _[RouterStatus](#routerstatus) array_ | Routers holds per-router reconciliation status. | | | + + +#### Community + +_Underlying type:_ _string_ + +Community is a BGP community in ASN:NN or IP:NN format. + +_Validation:_ +- MaxLength: 32 + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + + + +#### CommunitySet + + + +CommunitySet defines community add and remove operations. + + + +_Appears in:_ +- [BGPPolicySetActions](#bgppolicysetactions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `add` _string array_ | Add is a list of communities to attach. | | MaxItems: 32
items:MaxLength: 24
| +| `remove` _string array_ | Remove is a list of communities to strip. | | MaxItems: 32
items:MaxLength: 24
| + + +#### EVPNRouteType + +_Underlying type:_ _string_ + +EVPNRouteType is an EVPN route type per RFC 7432. + +_Validation:_ +- Enum: [inclusiveMulticastEthernetTag macIPAdvertisement iPPrefixAdvertisement stickyMACAddress iPv6PrefixAdvertisement] + +_Appears in:_ +- [BGPPolicyMatch](#bgppolicymatch) + +| Field | Description | +| --- | --- | +| `inclusiveMulticastEthernetTag` | EVPNRouteTypeInclusiveMulticastEthernetTag is Type-1: Inclusive Multicast Ethernet Tag route.
| +| `macIPAdvertisement` | EVPNRouteTypeMACIPAdvertisement is Type-2: MAC-IP Advertisement route.
| +| `iPPrefixAdvertisement` | EVPNRouteTypeIPPrefixAdvertisement is Type-3: IP Prefix Advertisement route.
| +| `stickyMACAddress` | EVPNRouteTypeStickyMACAddress is Type-4: Sticky MAC Address route.
| +| `iPv6PrefixAdvertisement` | EVPNRouteTypeIPv6PrefixAdvertisement is Type-5: IPv6 Prefix Advertisement route.
| + + +#### EgressShard + + + +EgressShard marks a single node as a member of the sharded, stateful egress +translation tier (galactic-nat) — a component deliberately kept off the +ingress load-balancer's own consistent-hash ring (see NetworkGateway): +tenant egress traffic (backend -> arbitrary internet destination) is a +different traffic pattern from ingress (fixed VIP, fixed backend pool) +and needs its own placement ring, own per-flow state, and its own +self-routing return path, entirely independent of any NetworkGateway node. + +A shard serves one or both address families. NAT66 (IPv6 -> IPv6) and NAT64 +(IPv6 -> IPv4, RFC 6146) are the same function — stateful egress PAT with a +VRF-scoped session table — over different families, so one shard object +describes both rather than there being a second, near-duplicate kind. +Spec.ShardAddressIPv6 and Spec.ShardAddressIPv4 are each assigned only for +the family this shard translates; a shard with no IPv4 address performs no +NAT64. + +Every shard owns a dedicated, publicly-routable address per family it +serves, and a flow's allocated masquerade port lives within it — so a reply +is delivered to the correct shard by ordinary unicast routing alone, with +no hashing or cross-shard lookup on the return path at all (the "any node +can determine the owning shard from the tuple alone" property, satisfied by +construction rather than by a replicated hash table). + +A shard is told which addresses to translate to; it does not choose them. +The controller that owns a cell claims one address per family from the +addressing service and writes it into this spec, which keeps the +addressing-service credential off every translating node and keeps the +allocation request out of the path that attaches a workload. Status reports +what the node's datapath is actually programmed with, so an unclaimed +address, a stale datapath, and a divergence between the two are each +distinguishable. + +A shard names nothing that selects it. Which shards serve which consumer +intent is decided entirely by label selectors evaluated on the selecting +side (see the LabelEgressShard* keys), so no consumer-facing API appears in +this group and no reference points from a datapath resource back at the +resource that placed traffic on it. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `EgressShard` | | | +| `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` _[EgressShardSpec](#egressshardspec)_ | | | | +| `status` _[EgressShardStatus](#egressshardstatus)_ | | | | + + +#### EgressShardSpec + + + +EgressShardSpec defines the desired state of an EgressShard. + +The address fields are written by the controller that owns the cell, not by +the shard and not by a consumer. Each is optional: an address the addressing +service has not yet handed out is absent rather than blank-but-required, so +a shard object exists from the moment its node is labelled and gains its +identity afterwards. + +Each is also write-once, and cannot be unassigned once assigned. The +datapath claims a reply by exact match against the address it translates to, +so reassigning one strands the return traffic of every flow already +established through it, with no drain and no dual-address grace period +available to cover the change. A shard holding the wrong address is deleted +and recreated instead, which breaks those flows at a moment someone chose. + +A claim reference alongside an address records where that address came from, +and is write-once on the same terms. It is deliberately not required with an +address, in either direction. Requiring an address before a claim reference +would forbid recording a claim whose allocation has not resolved yet, which +is the window a restarting controller most needs to see so that it finds the +claim it already made instead of making a second one. Requiring a claim +reference before an address would forbid an address allocated by hand, and +whether such an address may be recorded here at all is an open migration +question this validation would answer by fiat. An address with no reference +is therefore accepted and is unattributable, which is a fact about the +allocation rather than something a schema can repair. + + + +_Appears in:_ +- [EgressShard](#egressshard) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this shard executes on. | | Required: \{\}
| +| `shardSID` _string_ | ShardSID is this shard's own SRv6 uSID, the outer destination a tenant
VRF's egress route encapsulates toward. The shard claims a packet by its
Block and Node-ID alone, the Argument carrying the tenant, so those 64
bits must be reserved for this shard and disjoint from every BGPRouter
locator: a value another node already uses silently diverts that node's
traffic.
Write-once for the same reason the addresses are: every established
flow's reply is re-encapsulated from this SID, so changing it strands
them. Operator-chosen until the addressing service hands out identifiers
of this kind.
Empty means no SID is assigned yet, and the shard claims no packet. | | | +| `shardAddressIPv6` _string_ | ShardAddressIPv6 is the dedicated, publicly-routable IPv6 address this
shard translates to — every NAT66 masquerade port it allocates lives
within this address, so any node can route a reply to the owning shard
using ordinary unicast routing on it alone, with no per-flow state
lookup anywhere but that shard.
Empty means no IPv6 address is assigned to this shard. | | | +| `shardAddressIPv6ClaimRef` _[AddressClaimRef](#addressclaimref)_ | ShardAddressIPv6ClaimRef records the addressing-service claim
ShardAddressIPv6 came from — see AddressClaimRef. Set it in the same
write as the address whenever that address came from a claim.
Nothing here resolves it and no component reads it to program anything;
it is the only trail from a translating address back to the allocation
accountable for it. | | | +| `shardAddressIPv4` _string_ | ShardAddressIPv4 is the dedicated, publicly-routable IPv4 address this
shard translates to, and the source an IPv4-only destination sees.
Unlike ShardAddressIPv6, reachability for it is not established by a
BGPAdvertisement into the EVPN fabric: an IPv4 reply arrives from the
internet, so the underlay or an upstream announcement must attract this
address to this node.
Empty means no IPv4 address is assigned to this shard. | | | +| `shardAddressIPv4ClaimRef` _[AddressClaimRef](#addressclaimref)_ | ShardAddressIPv4ClaimRef records the addressing-service claim
ShardAddressIPv4 came from — see AddressClaimRef. Set it in the same
write as the address whenever that address came from a claim.
Nothing here resolves it and no component reads it to program anything;
it is the only trail from a translating address back to the allocation
accountable for it. | | | +| `nat64Prefix` _string_ | NAT64Prefix is the IPv6 prefix whose synthesized addresses this shard
translates to IPv4 — a Datum-operated Network-Specific Prefix, shared
fabric-wide, never per-tenant. TranslateWellKnownPrefix adds the RFC 6052
Well-Known Prefix alongside it. It must be the prefix the resolver
synthesizes into; a shard translating for a different one is a
blackhole with no symptom on either side.
Set together with ShardAddressIPv4 or not at all: an address with no
prefix has nothing to translate for, and a prefix with no address has
nothing to translate into. Write-once for the same reason the addresses
are: a shard that starts translating a different prefix blackholes every
destination the resolver already synthesized into the old one. | | | +| `translateWellKnownPrefix` _boolean_ | TranslateWellKnownPrefix additionally translates destinations inside the
RFC 6052 Well-Known Prefix 64:ff9b::/96 to IPv4 from ShardAddressIPv4, so
clients using a public DNS64 resolver reach IPv4 destinations. Requires
NAT64Prefix.
RFC 6052 §3.1 forbids the Well-Known Prefix from representing non-global
IPv4 addresses; the implementation must refuse such destinations.
Mutable, unlike NAT64Prefix. Disabling it blackholes every destination
clients already resolved into the Well-Known Prefix until their DNS TTLs
expire. | | | +| `drain` _boolean_ | Drain stops this shard from taking new tenants while it keeps serving
the ones it has. Compute nodes that spread egress across a cluster's
shards give a draining shard no share of new tenant addresses, but keep
sending it every flow already pinned to it until that flow goes idle, so
established sessions finish where their translation state lives.
It changes nothing on the shard itself: the shard keeps translating,
advertising its SID and receiving replies. A shard is safe to remove
once its session count has fallen to zero. Clearing it puts the shard
back into rotation.
Compute nodes that send each tenant to a single configured shard ignore
it. | | | + + +#### EgressShardStatus + + + +EgressShardStatus defines the observed state of an EgressShard. + +The address and prefix fields report what this node's datapath is programmed +with, not what it was asked for. A value here that disagrees with the spec is +a shard that has not converged; a value here with no counterpart in the spec +is a shard still translating to an address nothing assigns any more. + + + +_Appears in:_ +- [EgressShard](#egressshard) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `shardSID` _string_ | ShardSID is this shard's own uSID locator — a real SRv6 uSID (unlike
the ShardAddress fields, which are plain routable addresses),
advertised into BGP the same way any other node-reachability route is
(a /128 BGPAdvertisement, no VRFID/Function) so every other node learns
a kernel SEG6 route toward it before installing a tenant VRF's egress
route against it. One SID serves both families: which translation a
packet gets is decided from the inner destination, not from a second
SID.
Reports the SID this node's datapath is programmed with, from
Spec.ShardSID. | | | +| `shardAddressIPv6` _string_ | ShardAddressIPv6 is the IPv6 masquerade source this shard's datapath is
programmed with. Empty means it translates no IPv6 flow. | | | +| `shardAddressIPv4` _string_ | ShardAddressIPv4 is the IPv4 masquerade source this shard's datapath is
programmed with. Empty means it performs no NAT64. Publishing it is also
what makes the underlay reachability prerequisite in
Spec.ShardAddressIPv4 checkable. | | | +| `nat64Prefix` _string_ | NAT64Prefix is the prefix this shard's datapath is programmed to
translate. Empty whenever ShardAddressIPv4 is empty. | | | +| `translatesWellKnownPrefix` _boolean_ | TranslatesWellKnownPrefix reports whether this shard's datapath is
programmed to translate the RFC 6052 Well-Known Prefix 64:ff9b::/96, from
Spec.TranslateWellKnownPrefix. False whenever ShardAddressIPv4 is empty. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource,
including Programmed (see ConditionTypeProgrammed). | | | + + +#### ExtendedCommunitySet + + + +ExtendedCommunitySet defines extended community add and remove operations. + + + +_Appears in:_ +- [BGPPolicySetActions](#bgppolicysetactions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `add` _string array_ | Add is a list of extended communities to attach. | | MaxItems: 32
items:MaxLength: 64
| +| `remove` _string array_ | Remove is a list of extended communities to strip. | | MaxItems: 32
items:MaxLength: 64
| + + +#### LocalSecretRef + + + +LocalSecretRef references a Secret within the same namespace. +Cross-namespace references are not supported. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the Secret. | | MinLength: 1
| + + +#### MaxPrefixShutdownAction + +_Underlying type:_ _string_ + +MaxPrefixShutdownAction defines the action taken when a peer exceeds its +maximum-prefix limit. + +_Validation:_ +- Enum: [warning-only restart shutdown] + +_Appears in:_ +- [BGPMaximumPrefix](#bgpmaximumprefix) + +| Field | Description | +| --- | --- | +| `warning-only` | MaxPrefixShutdownActionWarningOnly logs a warning but keeps the session up.
| +| `restart` | MaxPrefixShutdownActionRestart resets the BGP session when the limit is exceeded.
| +| `shutdown` | MaxPrefixShutdownActionShutdown tears down the BGP session when the limit is exceeded.
| + + +#### NPTv6Spec + + + +NPTv6Spec is one VRF's stateless RFC 6296 prefix-translation mapping. +ULAPrefix and PublicPrefix must share the same prefix length — RFC 6296 +translation only ever rewrites the shared prefix, never the host bits. + + + +_Appears in:_ +- [BGPVRFInstanceSpec](#bgpvrfinstancespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ulaPrefix` _string_ | ULAPrefix is this VRF's tenant-facing IPv6 ULA prefix (e.g.
"fd20:60::/64"), as presented by backends inside the VRF. | | Required: \{\}
| +| `publicPrefix` _string_ | PublicPrefix is the externally-routable IPv6 prefix ULAPrefix
translates to/from, same prefix length as ULAPrefix. | | Required: \{\}
| + + +#### NextHopSet + + + +NextHopSet defines next-hop attribute overrides. + + + +_Appears in:_ +- [BGPPolicySetActions](#bgppolicysetactions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `self` _boolean_ | Self sets the next-hop to the local router's BGP peer address. | | | +| `address` _string_ | Address sets the next-hop to a specific IP address.
Mutually exclusive with self. | | MaxLength: 45
| + + +#### OriginType + +_Underlying type:_ _string_ + +OriginType defines the BGP origin attribute values. + +_Validation:_ +- Enum: [igp egp incomplete] + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | +| --- | --- | +| `igp` | | +| `egp` | | +| `incomplete` | | + + +#### Prefix + +_Underlying type:_ _string_ + +Prefix is an IPv4 or IPv6 CIDR prefix. + +_Validation:_ +- MaxLength: 64 + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + + + +#### RedistributeSource + +_Underlying type:_ _string_ + +RedistributeSource is a local routing table source to redistribute into BGP. + +_Validation:_ +- Enum: [static connected kernel] + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + +| Field | Description | +| --- | --- | +| `static` | RedistributeSourceStatic redistributes statically configured routes.
| +| `connected` | RedistributeSourceConnected redistributes directly connected interface routes.
| +| `kernel` | RedistributeSourceKernel redistributes routes from the kernel routing table.
| + + +#### ResolvedRouterConfig + + + +ResolvedRouterConfig holds the configuration resolved and applied to a specific router. + + + +_Appears in:_ +- [RouterStatus](#routerstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerID` _string_ | RouterID resolved for this router. | | | +| `asNumber` _integer_ | ASNumber is the AS number configured. | | | +| `addressFamilies` _[AddressFamily](#addressfamily) array_ | AddressFamilies configured. | | | + + +#### RouteTarget + + + +RouteTarget is a BGP extended community in "ASN:NN" or "IP:NN" format. + + + +_Appears in:_ +- [BGPVRFInstanceSpec](#bgpvrfinstancespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `value` _string_ | Value is the route target extended community string.
Format: "ASN:NN" (e.g. "65000:100") or "IP:NN" (e.g. "192.0.2.1:100"). | | MaxLength: 21
MinLength: 1
| + + +#### RouterRef + + + +RouterRef is a direct reference to a single BGPRouter by name within the same namespace. + + + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) +- [BGPPeerSpec](#bgppeerspec) +- [BGPPolicySpec](#bgppolicyspec) +- [BGPVRFInstanceSpec](#bgpvrfinstancespec) +- [RouterTarget](#routertarget) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the BGPRouter. | | MinLength: 1
| + + +#### RouterSelector + + + +RouterSelector selects one or more BGPRouter resources by label within the same namespace. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) +- [BGPPolicySpec](#bgppolicyspec) +- [BGPVRFInstanceSpec](#bgpvrfinstancespec) +- [RouterTarget](#routertarget) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `matchLabels` _object (keys:string, values:string)_ | MatchLabels is a map of key/value label pairs to match. | | | +| `matchExpressions` _[LabelSelectorRequirement](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselectorrequirement-v1-meta) array_ | MatchExpressions is a list of label selector requirements. | | | + + +#### RouterStatus + + + +RouterStatus holds per-router reconciliation status used by BGPVRFInstance. + + + +_Appears in:_ +- [BGPVRFInstanceStatus](#bgpvrfinstancestatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerName` _string_ | RouterName is the name of the BGPRouter this entry describes. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions are the per-router conditions. | | | +| `resolvedConfig` _[ResolvedRouterConfig](#resolvedrouterconfig)_ | ResolvedConfig holds the configuration that was actually applied. | | | + + +#### RouterTarget + + + +RouterTarget is embedded by resources that bind to one or more BGPRouters. +Exactly one of routerRef or routerSelector must be set. + + + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) +- [BGPPolicySpec](#bgppolicyspec) +- [BGPVRFInstanceSpec](#bgpvrfinstancespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `routerRef` _[RouterRef](#routerref)_ | RouterRef targets a single BGPRouter by name.
Mutually exclusive with routerSelector. | | | +| `routerSelector` _[RouterSelector](#routerselector)_ | RouterSelector targets one or more BGPRouters by label.
Mutually exclusive with routerRef. | | | + + +#### SAFI + +_Underlying type:_ _string_ + +SAFI is the Subsequent Address Family Indicator for a BGP address family. + +_Validation:_ +- Enum: [unicast evpn] + +_Appears in:_ +- [AddressFamily](#addressfamily) + +| Field | Description | +| --- | --- | +| `unicast` | | +| `evpn` | | + + +#### SRv6Function + +_Underlying type:_ _string_ + +SRv6Function is the RFC 8986 endpoint behavior applied to a decapsulated +SRv6 packet, addressed via the uSID Argument space per RFC 9800. + +_Validation:_ +- Enum: [End.DT4 End.DT6 End.DT46] + +_Appears in:_ +- [BGPAdvertisementSpec](#bgpadvertisementspec) + +| Field | Description | +| --- | --- | +| `End.DT4` | SRv6FunctionEndDT4 decapsulates and looks up the packet in an IPv4 VRF.
| +| `End.DT6` | SRv6FunctionEndDT6 decapsulates and looks up the packet in an IPv6 VRF.
| +| `End.DT46` | SRv6FunctionEndDT46 decapsulates and looks up the packet in an IPv4 or
IPv6 VRF based on the inner packet's address family.
| + + +#### SendCommunityType + +_Underlying type:_ _string_ + +SendCommunityType controls which BGP community attributes are propagated +to a peer. + +_Validation:_ +- Enum: [standard extended large both] + +_Appears in:_ +- [BGPPeerSpec](#bgppeerspec) + +| Field | Description | +| --- | --- | +| `standard` | SendCommunityTypeStandard sends only standard communities (ASN:NN).
| +| `extended` | SendCommunityTypeExtended sends only extended communities (Type:Length:Value).
| +| `large` | SendCommunityTypeLarge sends only large communities (ASN:NN:NN).
| +| `both` | SendCommunityTypeBoth sends both standard and extended communities.
| + + +#### ServiceEndpoint + + + +ServiceEndpoint publishes a platform service that can be reached through a +VPC attachment. The object describes the service contract; the controller +that owns the service is responsible for its health and implementation. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceEndpoint` | | | +| `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` _[ServiceEndpointSpec](#serviceendpointspec)_ | | | | + + +#### ServiceEndpointAttachmentReference + + + +ServiceEndpointAttachmentReference identifies the private attachment that +backs a platform service endpoint. + + + +_Appears in:_ +- [ServiceEndpointSpec](#serviceendpointspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `namespace` _string_ | Namespace is the namespace containing the Cloud API VPCAttachment. | | MinLength: 1
| +| `name` _string_ | Name is the Cloud API VPCAttachment name. | | MinLength: 1
| + + +#### ServiceEndpointDeliveryMode + +_Underlying type:_ _string_ + +ServiceEndpointDeliveryMode defines the placement contract for a service. + +_Validation:_ +- Enum: [NodeLocal PreferNodeLocal] + +_Appears in:_ +- [ServiceEndpointSpec](#serviceendpointspec) + +| Field | Description | +| --- | --- | +| `NodeLocal` | ServiceEndpointDeliveryModeNodeLocal requires the endpoint's backing
attachment to be present on each node where selected consumers run.
| +| `PreferNodeLocal` | ServiceEndpointDeliveryModePreferNodeLocal selects a backing attachment on
the consumer's node when possible and otherwise tunnels to a ready remote
attachment while preserving the consumer source address.
| + + +#### ServiceEndpointReference + + + +ServiceEndpointReference identifies a ServiceEndpoint in the policy's +namespace. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the ServiceEndpoint name. | | MinLength: 1
| + + +#### ServiceEndpointSpec + + + +ServiceEndpointSpec defines a platform service address and its scope. + + + +_Appears in:_ +- [ServiceEndpoint](#serviceendpoint) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceClass` _string_ | ServiceClass identifies the platform capability implemented by this
endpoint, for example "ipv6-egress-dns". | | MinLength: 1
Required: \{\}
| +| `address` _string_ | Address is the stable address that consumers use to reach the service. | | Required: \{\}
| +| `port` _integer_ | Port is the service port. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `deliveryMode` _[ServiceEndpointDeliveryMode](#serviceendpointdeliverymode)_ | DeliveryMode defines how a backing attachment is selected relative to a
consumer. PreferNodeLocal uses a ready attachment on the consumer's node
when one exists and otherwise falls back to a ready remote attachment.
The dataplane preserves the consumer source address. If consumers with
overlapping address space present an identical TCP or UDP five-tuple to the
same replica, it rejects the later ambiguous flow fail-closed rather than
translate the source or risk returning traffic to the wrong VPC. | | Enum: [NodeLocal PreferNodeLocal]
Required: \{\}
| +| `attachmentRef` _[ServiceEndpointAttachmentReference](#serviceendpointattachmentreference)_ | AttachmentRef identifies the Cloud API VPCAttachment that hosts this
endpoint when the service is delivered through a private VPC path.
The network API keeps this reference opaque; the consuming controller
resolves it against the Cloud API. | | | +| `attachmentSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | AttachmentSelector selects the Cloud API VPCAttachments that host replicas
of this endpoint. Labels used here must be assigned by the platform service
controller, not by tenants. The dataplane selects one ready replica for
each consumer according to DeliveryMode. | | | +| `region` _string_ | Region limits endpoint selection to a region. An empty value means the
endpoint is not region-scoped. | | | + + +#### ServiceRouteAuthorization + + + +ServiceRouteAuthorization bounds one network authorization episode. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `validUntil` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | ValidUntil is the absolute UTC deadline for requests and existing replies.
Node controllers reject deadlines more than two minutes in the future. | | | + + +#### ServiceRouteFrontend + + + +ServiceRouteFrontend declares the consumer-facing service address. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `address` _string_ | Address is translated to the ServiceEndpoint address for requests and is
restored as the source address for replies. | | | + + +#### ServiceRouteNodeStatus + + + +ServiceRouteNodeStatus acknowledges one node's current programmed path. + + + +_Appears in:_ +- [ServiceRoutePolicyStatus](#serviceroutepolicystatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `nodeName` _string_ | | | | +| `policyUID` _string_ | | | | +| `observedGeneration` _integer_ | | | | +| `inputDigest` _string_ | | | | +| `ready` _boolean_ | | | | +| `validUntil` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | | | | + + +#### ServiceRoutePolicy + + + +ServiceRoutePolicy selects VPC attachments that should receive a route to a +platform ServiceEndpoint. The selector is evaluated by the network control +plane; Galactic compiles the selected attachments into dataplane state. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceRoutePolicy` | | | +| `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` _[ServiceRoutePolicySpec](#serviceroutepolicyspec)_ | | | | +| `status` _[ServiceRoutePolicyStatus](#serviceroutepolicystatus)_ | | | | + + +#### ServiceRoutePolicySpec + + + +ServiceRoutePolicySpec defines service selection and attachment eligibility. + + + +_Appears in:_ +- [ServiceRoutePolicy](#serviceroutepolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceRef` _[ServiceEndpointReference](#serviceendpointreference)_ | ServiceRef identifies the ServiceEndpoint in this namespace. | | Required: \{\}
| +| `consumerVPCRef` _[ServiceRouteVPCReference](#serviceroutevpcreference)_ | ConsumerVPCRef pins authorization to the immutable lifetime of the VPC
selected by AttachmentSelector. Galactic resolves the name through the
Cloud API and requires both the live name and UID to match. | | | +| `frontend` _[ServiceRouteFrontend](#serviceroutefrontend)_ | Frontend is the address presented inside the consumer VPC. When set,
Galactic translates it to the ServiceEndpoint address and restores it on
replies. | | | +| `authorization` _[ServiceRouteAuthorization](#servicerouteauthorization)_ | Authorization bounds translated access independently of application leases.
Only trusted networking integration may renew this deadline. | | | +| `attachmentSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | AttachmentSelector selects eligible VPC attachments by authoritative
labels. Labels that grant access to platform services must be assigned by
the network control plane, not by tenants. | | Required: \{\}
| +| `protocolPorts` _[ServiceRouteProtocolPort](#servicerouteprotocolport) array_ | ProtocolPorts limits the traffic that the route is intended to carry.
An empty list means the endpoint's declared port and protocol are used. | | | +| `region` _string_ | Region limits selected attachments to a region. An empty value means the
policy applies in every region where the endpoint is available. | | | + + +#### ServiceRoutePolicyStatus + + + +ServiceRoutePolicyStatus contains policy conditions and fenced node reports. + + + +_Appears in:_ +- [ServiceRoutePolicy](#serviceroutepolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains durable acceptance and service-availability state. | | | +| `nodes` _[ServiceRouteNodeStatus](#serviceroutenodestatus) array_ | Nodes contains short-lived acknowledgments written by trusted node controllers.
Consumers require current UID, generation, input digest, and unexpired reports. | | | + + +#### ServiceRouteProtocolPort + + + +ServiceRouteProtocolPort identifies an allowed transport protocol and port. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `port` _integer_ | Port is the transport port. | | Maximum: 65535
Minimum: 1
Required: \{\}
| + + +#### ServiceRouteVPCReference + + + +ServiceRouteVPCReference identifies one immutable Cloud API VPC lifetime. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the VPC name in the policy namespace. | | MinLength: 1
| +| `uid` _string_ | UID is the Kubernetes UID of that VPC lifetime. | | MinLength: 1
| + + +#### ServiceVIPBinding + + + +ServiceVIPBinding drives one worker node's backend-side half of the +DSR/Maglev load-balancer datapath: it tells the node which service VIP a +specific local backend must be reachable on, so the backend can reply to +clients directly (the "Direct Server Return" this design depends on — +see NetworkGateway's doc comment). Generated, never written by users: +galactic-router on each worker node writes one object per (service, VIP, +backend) for every backend a NetworkService's BackendSelector places on that +node, owned by the service, and the same process's per-node reconciler +consumes it. + +EgressKind decides which of two backend mechanisms this object drives, +mirroring the same veth/tap fork the SRv6 uSID decap datapath already +has (internal/plumbing/ebpf/usidmap's egress_kind field). Both mechanisms +now converge on the same VIP-boundary substitution +(BackendAddress:BackendPort for VIPAddress:Port at the SRv6 uSID TC-BPF +boundary, usid_ingress's inbound half / usid_egress's outbound half) — +required for both, not just tap, since a decapsulated ingress packet is +delivered into the owning tenant's own VRF routing table, which has no +route to an address bound outside that VRF (found live: see galactic's +ServiceVIPBindingReconciler doc comment): + + - veth (container backend): the node ALSO binds VIPAddress on its own + galactic-vip0 dummy interface (internal/plumbing/vip's + Bind/Unbind/Verify in galactic) — this alone does not deliver + anything to the backend pod (galactic-vip0 lives in the node's root + namespace, not the tenant's VRF), but still lets the node itself + verifiably answer on the VIP. + - tap (VM backend): there is no guest-side configuration capability in + this repo by design (internal/cnitap's own doc comment), so the + substitution above is this kind's *only* delivery mechanism — the + guest OS never needs to know the VIP exists at all. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceVIPBinding` | | | +| `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` _[ServiceVIPBindingSpec](#servicevipbindingspec)_ | | | | +| `status` _[ServiceVIPBindingStatus](#servicevipbindingstatus)_ | | | | + + +#### ServiceVIPBindingEgressKind + +_Underlying type:_ _string_ + +ServiceVIPBindingEgressKind mirrors usidmap's EgressKindVeth/EgressKindTap +constants at the API layer — see ServiceVIPBinding's doc comment. + +_Validation:_ +- Enum: [veth tap] + +_Appears in:_ +- [ServiceVIPBindingSpec](#servicevipbindingspec) + +| Field | Description | +| --- | --- | +| `veth` | ServiceVIPBindingEgressKindVeth selects the netns-bind mechanism.
| +| `tap` | ServiceVIPBindingEgressKindTap selects the transparent tap-boundary
translation mechanism.
| + + +#### ServiceVIPBindingSpec + + + +ServiceVIPBindingSpec defines the desired VIP binding/translation state. + + + +_Appears in:_ +- [ServiceVIPBinding](#servicevipbinding) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this binding applies to. | | Required: \{\}
| +| `vpcRef` _string_ | VPCRef is the opaque identifier of the VPC the backend belongs to,
copied from the owning NetworkService. It names the tenant VRF the
binding's translation rows are written for, so two tenants using the
same backend address on one node never share a row. | | MinLength: 1
Required: \{\}
| +| `vipAddress` _string_ | VIPAddress is the service VIP the backend must be reachable on. | | Required: \{\}
| +| `port` _integer_ | Port is the VIP-facing port traffic arrives on. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `backendAddress` _string_ | BackendAddress is the backend's own real address (its pod-netns
address for a veth backend, or its actual guest-facing address for a
tap backend) — the VIP-boundary substitution target for both kinds
now (see EgressKind's own doc comment for why veth needs this too,
not just tap). +optional at the API level for the same reason
EgressKind itself carries no matching CEL requirement; the
reconciler validates it's set for either kind before doing anything. | | | +| `backendPort` _integer_ | BackendPort is the backend's own real port, paired with
BackendAddress — required for both kinds, see that field's doc
comment. | | Maximum: 65535
Minimum: 1
| +| `egressKind` _[ServiceVIPBindingEgressKind](#servicevipbindingegresskind)_ | EgressKind selects which backend mechanism this binding drives. | | Enum: [veth tap]
Required: \{\}
| + + +#### ServiceVIPBindingStatus + + + +ServiceVIPBindingStatus defines the observed state of a ServiceVIPBinding. + + + +_Appears in:_ +- [ServiceVIPBinding](#servicevipbinding) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource,
including Bound (set True once internal/plumbing/vip.Verify or the
equivalent tap-translation-table check confirms the backend is
actually reachable on VIPAddress, not merely that the bind/table-write
call itself returned nil). | | | + + +#### TargetRef + + + +TargetRef identifies the execution target for a BGPRouter. +Supported values for kind: Node. + + + +_Appears in:_ +- [BGPRouterSpec](#bgprouterspec) +- [EgressShardSpec](#egressshardspec) +- [ServiceVIPBindingSpec](#servicevipbindingspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `kind` _string_ | Kind is the target resource kind (e.g. Node). | | MinLength: 1
| +| `name` _string_ | Name is the name of the target resource. | | MinLength: 1
| diff --git a/docs/api/network-gateway.md b/docs/api/network-gateway.md new file mode 100644 index 00000000..8f1440e4 --- /dev/null +++ b/docs/api/network-gateway.md @@ -0,0 +1,834 @@ +# API Reference + +## Packages +- [galactic.datumapis.com/v1alpha1](#galacticdatumapiscomv1alpha1) + + +## galactic.datumapis.com/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the galactic.datumapis.com/v1alpha1 API group. + +### Resource Types +- [EgressShard](#egressshard) +- [NetworkEgressPolicy](#networkegresspolicy) +- [NetworkGateway](#networkgateway) +- [NetworkService](#networkservice) +- [ServiceEndpoint](#serviceendpoint) +- [ServiceRoutePolicy](#serviceroutepolicy) +- [ServiceVIPBinding](#servicevipbinding) + + + +#### AddressClaimRef + + + +AddressClaimRef names the addressing-service claim an assigned address came +from. It is a record, not a lookup: the addressing service is an aggregated +API served on a project control-plane path and defines no CustomResourceDefinition, +so no claim object exists in the cluster a shard runs in and nothing here +resolves one. Reading it means addressing that project's control plane +directly, with a credential a translating node is deliberately not given. + +Every field is a plain string and this group depends on no addressing-service +type. The shape matches that service's own opaque cross-API reference, plus +the project, because a claim is namespaced within a project and a namespace +name alone does not identify one from outside. + +Populate it with the coordinates of the claim whose allocation produced the +address written alongside it: + + shardAddressIPv6: 2001:db8:f00d::100 + shardAddressIPv6ClaimRef: + project: datum-network-edge + namespace: egress + name: egress-shard-node-a-ipv6 + +The claim cannot record this relationship itself. The addressing service +overwrites a claim's spec.ownerRef on create with the identity that requested +it, so a claim made by a cell controller is attributed to that controller's +project rather than to the shard it was made for. Attribution therefore runs +in this direction only, and an operator asking which claim holds a shard's +address has this field or nothing. + + + +_Appears in:_ +- [EgressShardSpec](#egressshardspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiGroup` _string_ | APIGroup is the group of the claim resource. | ipam.miloapis.com | MinLength: 1
| +| `kind` _string_ | Kind is the kind of the claim resource. | IPClaim | MinLength: 1
| +| `project` _string_ | Project is the project whose control plane serves the claim. Required:
a claim is namespaced within a project, and this reference is read from
outside every project. | | MinLength: 1
Required: \{\}
| +| `namespace` _string_ | Namespace is the namespace holding the claim within Project. | | MinLength: 1
Required: \{\}
| +| `name` _string_ | Name is the name of the claim. The addressing service derives it from
the thing being addressed, so it is stable across a shard's lifetime and
is what a replacement finds again. | | MinLength: 1
Required: \{\}
| + + +#### EgressShard + + + +EgressShard marks a single node as a member of the sharded, stateful egress +translation tier (galactic-nat) — a component deliberately kept off the +ingress load-balancer's own consistent-hash ring (see NetworkGateway): +tenant egress traffic (backend -> arbitrary internet destination) is a +different traffic pattern from ingress (fixed VIP, fixed backend pool) +and needs its own placement ring, own per-flow state, and its own +self-routing return path, entirely independent of any NetworkGateway node. + +A shard serves one or both address families. NAT66 (IPv6 -> IPv6) and NAT64 +(IPv6 -> IPv4, RFC 6146) are the same function — stateful egress PAT with a +VRF-scoped session table — over different families, so one shard object +describes both rather than there being a second, near-duplicate kind. +Spec.ShardAddressIPv6 and Spec.ShardAddressIPv4 are each assigned only for +the family this shard translates; a shard with no IPv4 address performs no +NAT64. + +Every shard owns a dedicated, publicly-routable address per family it +serves, and a flow's allocated masquerade port lives within it — so a reply +is delivered to the correct shard by ordinary unicast routing alone, with +no hashing or cross-shard lookup on the return path at all (the "any node +can determine the owning shard from the tuple alone" property, satisfied by +construction rather than by a replicated hash table). + +A shard is told which addresses to translate to; it does not choose them. +The controller that owns a cell claims one address per family from the +addressing service and writes it into this spec, which keeps the +addressing-service credential off every translating node and keeps the +allocation request out of the path that attaches a workload. Status reports +what the node's datapath is actually programmed with, so an unclaimed +address, a stale datapath, and a divergence between the two are each +distinguishable. + +A shard names nothing that selects it. Which shards serve which consumer +intent is decided entirely by label selectors evaluated on the selecting +side (see the LabelEgressShard* keys), so no consumer-facing API appears in +this group and no reference points from a datapath resource back at the +resource that placed traffic on it. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `EgressShard` | | | +| `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` _[EgressShardSpec](#egressshardspec)_ | | | | +| `status` _[EgressShardStatus](#egressshardstatus)_ | | | | + + +#### EgressShardSpec + + + +EgressShardSpec defines the desired state of an EgressShard. + +The address fields are written by the controller that owns the cell, not by +the shard and not by a consumer. Each is optional: an address the addressing +service has not yet handed out is absent rather than blank-but-required, so +a shard object exists from the moment its node is labelled and gains its +identity afterwards. + +Each is also write-once, and cannot be unassigned once assigned. The +datapath claims a reply by exact match against the address it translates to, +so reassigning one strands the return traffic of every flow already +established through it, with no drain and no dual-address grace period +available to cover the change. A shard holding the wrong address is deleted +and recreated instead, which breaks those flows at a moment someone chose. + +A claim reference alongside an address records where that address came from, +and is write-once on the same terms. It is deliberately not required with an +address, in either direction. Requiring an address before a claim reference +would forbid recording a claim whose allocation has not resolved yet, which +is the window a restarting controller most needs to see so that it finds the +claim it already made instead of making a second one. Requiring a claim +reference before an address would forbid an address allocated by hand, and +whether such an address may be recorded here at all is an open migration +question this validation would answer by fiat. An address with no reference +is therefore accepted and is unattributable, which is a fact about the +allocation rather than something a schema can repair. + + + +_Appears in:_ +- [EgressShard](#egressshard) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this shard executes on. | | Required: \{\}
| +| `shardSID` _string_ | ShardSID is this shard's own SRv6 uSID, the outer destination a tenant
VRF's egress route encapsulates toward. The shard claims a packet by its
Block and Node-ID alone, the Argument carrying the tenant, so those 64
bits must be reserved for this shard and disjoint from every BGPRouter
locator: a value another node already uses silently diverts that node's
traffic.
Write-once for the same reason the addresses are: every established
flow's reply is re-encapsulated from this SID, so changing it strands
them. Operator-chosen until the addressing service hands out identifiers
of this kind.
Empty means no SID is assigned yet, and the shard claims no packet. | | | +| `shardAddressIPv6` _string_ | ShardAddressIPv6 is the dedicated, publicly-routable IPv6 address this
shard translates to — every NAT66 masquerade port it allocates lives
within this address, so any node can route a reply to the owning shard
using ordinary unicast routing on it alone, with no per-flow state
lookup anywhere but that shard.
Empty means no IPv6 address is assigned to this shard. | | | +| `shardAddressIPv6ClaimRef` _[AddressClaimRef](#addressclaimref)_ | ShardAddressIPv6ClaimRef records the addressing-service claim
ShardAddressIPv6 came from — see AddressClaimRef. Set it in the same
write as the address whenever that address came from a claim.
Nothing here resolves it and no component reads it to program anything;
it is the only trail from a translating address back to the allocation
accountable for it. | | | +| `shardAddressIPv4` _string_ | ShardAddressIPv4 is the dedicated, publicly-routable IPv4 address this
shard translates to, and the source an IPv4-only destination sees.
Unlike ShardAddressIPv6, reachability for it is not established by a
BGPAdvertisement into the EVPN fabric: an IPv4 reply arrives from the
internet, so the underlay or an upstream announcement must attract this
address to this node.
Empty means no IPv4 address is assigned to this shard. | | | +| `shardAddressIPv4ClaimRef` _[AddressClaimRef](#addressclaimref)_ | ShardAddressIPv4ClaimRef records the addressing-service claim
ShardAddressIPv4 came from — see AddressClaimRef. Set it in the same
write as the address whenever that address came from a claim.
Nothing here resolves it and no component reads it to program anything;
it is the only trail from a translating address back to the allocation
accountable for it. | | | +| `nat64Prefix` _string_ | NAT64Prefix is the IPv6 prefix whose synthesized addresses this shard
translates to IPv4 — a Datum-operated Network-Specific Prefix, shared
fabric-wide, never per-tenant. TranslateWellKnownPrefix adds the RFC 6052
Well-Known Prefix alongside it. It must be the prefix the resolver
synthesizes into; a shard translating for a different one is a
blackhole with no symptom on either side.
Set together with ShardAddressIPv4 or not at all: an address with no
prefix has nothing to translate for, and a prefix with no address has
nothing to translate into. Write-once for the same reason the addresses
are: a shard that starts translating a different prefix blackholes every
destination the resolver already synthesized into the old one. | | | +| `translateWellKnownPrefix` _boolean_ | TranslateWellKnownPrefix additionally translates destinations inside the
RFC 6052 Well-Known Prefix 64:ff9b::/96 to IPv4 from ShardAddressIPv4, so
clients using a public DNS64 resolver reach IPv4 destinations. Requires
NAT64Prefix.
RFC 6052 §3.1 forbids the Well-Known Prefix from representing non-global
IPv4 addresses; the implementation must refuse such destinations.
Mutable, unlike NAT64Prefix. Disabling it blackholes every destination
clients already resolved into the Well-Known Prefix until their DNS TTLs
expire. | | | +| `drain` _boolean_ | Drain stops this shard from taking new tenants while it keeps serving
the ones it has. Compute nodes that spread egress across a cluster's
shards give a draining shard no share of new tenant addresses, but keep
sending it every flow already pinned to it until that flow goes idle, so
established sessions finish where their translation state lives.
It changes nothing on the shard itself: the shard keeps translating,
advertising its SID and receiving replies. A shard is safe to remove
once its session count has fallen to zero. Clearing it puts the shard
back into rotation.
Compute nodes that send each tenant to a single configured shard ignore
it. | | | + + +#### EgressShardStatus + + + +EgressShardStatus defines the observed state of an EgressShard. + +The address and prefix fields report what this node's datapath is programmed +with, not what it was asked for. A value here that disagrees with the spec is +a shard that has not converged; a value here with no counterpart in the spec +is a shard still translating to an address nothing assigns any more. + + + +_Appears in:_ +- [EgressShard](#egressshard) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `shardSID` _string_ | ShardSID is this shard's own uSID locator — a real SRv6 uSID (unlike
the ShardAddress fields, which are plain routable addresses),
advertised into BGP the same way any other node-reachability route is
(a /128 BGPAdvertisement, no VRFID/Function) so every other node learns
a kernel SEG6 route toward it before installing a tenant VRF's egress
route against it. One SID serves both families: which translation a
packet gets is decided from the inner destination, not from a second
SID.
Reports the SID this node's datapath is programmed with, from
Spec.ShardSID. | | | +| `shardAddressIPv6` _string_ | ShardAddressIPv6 is the IPv6 masquerade source this shard's datapath is
programmed with. Empty means it translates no IPv6 flow. | | | +| `shardAddressIPv4` _string_ | ShardAddressIPv4 is the IPv4 masquerade source this shard's datapath is
programmed with. Empty means it performs no NAT64. Publishing it is also
what makes the underlay reachability prerequisite in
Spec.ShardAddressIPv4 checkable. | | | +| `nat64Prefix` _string_ | NAT64Prefix is the prefix this shard's datapath is programmed to
translate. Empty whenever ShardAddressIPv4 is empty. | | | +| `translatesWellKnownPrefix` _boolean_ | TranslatesWellKnownPrefix reports whether this shard's datapath is
programmed to translate the RFC 6052 Well-Known Prefix 64:ff9b::/96, from
Spec.TranslateWellKnownPrefix. False whenever ShardAddressIPv4 is empty. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource,
including Programmed (see ConditionTypeProgrammed). | | | + + + + +#### NetworkEgressPolicy + + + +NetworkEgressPolicy enables internet egress for a single tenant +VPC/VPCAttachment, served by the sharded, stateful galactic-nat tier +(see EgressShard). Unlike NetworkService, 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 +NetworkService's vpcRef, vpcRef/vpcAttachmentRef are opaque string identifiers +because this kind carries identifiers from the VPC API in +api/cloud/v1alpha1 without a Kubernetes object reference. 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's VRF get an egress route toward the shared +translation tier at all) — unlike this type's original design (superseded), there is +no single "assigned gateway node" to compute or pin: any EgressShard may +serve any tenant's flow, chosen by the shard-placement consistent-hash +ring (internal/maglev, keyed on (tenant VRFID, backend, destination) — +see EgressShard's doc comment), not by a per-tenant node assignment stored +here. *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 VRFID +carried in the tenant's own SRv6 Argument, not by anything in this spec. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.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. | | | +| `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 + + + +NetworkGateway marks a single dedicated gateway-role node as running the +Maglev/DSR consistent-hash L4 load-balancer engine. Exactly one +NetworkGateway exists per gateway node (spec.targetRef.name is the +Kubernetes node name), mirroring the BGPRouter node-scoped root object +pattern. NetworkService resources are served by every NetworkGateway in the +namespace equally (anycast — see NetworkServiceStatus's doc comment); this +object's only job is to identify which nodes participate at all and +surface each node's engine health via Conditions. + +This design does no address rewriting on the load-balancing path at all +(DSR — Direct Server Return): the gateway's XDP program picks a backend +via consistent hashing on the flow's 5-tuple and pushes an SRv6 uSID outer +header addressed to the backend's worker node directly, untouched +otherwise. The backend node answers the client directly (see +ServiceVIPBinding) — reply traffic never re-enters this gateway node, so +unlike the Full-NAT design this type originally described, a gateway node +has no SNAT source address of its own to publish and nothing analogous to +sRv6Address/egressAddress/egressSID belongs on this status anymore. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `NetworkGateway` | | | +| `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` _[NetworkGatewaySpec](#networkgatewayspec)_ | | | | +| `status` _[NetworkGatewayStatus](#networkgatewaystatus)_ | | | | + + +#### NetworkGatewaySpec + + + +NetworkGatewaySpec defines the desired state of a NetworkGateway. + + + +_Appears in:_ +- [NetworkGateway](#networkgateway) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this gateway engine executes on. | | Required: \{\}
| + + +#### NetworkGatewayStatus + + + +NetworkGatewayStatus defines the observed state of a NetworkGateway. + + + +_Appears in:_ +- [NetworkGateway](#networkgateway) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### NetworkService + + + +NetworkService defines ingress load-balancing for a single tenant VPC, served +by every NetworkGateway node identically (anycast Direct Server Return — +see NetworkGateway's doc comment). It is namespaced (deployed to +galactic-system) and tenant-writable; vpcRef is an opaque string identifier +because this kind carries identifiers from the VPC API in +api/cloud/v1alpha1 without a Kubernetes object reference. An admission webhook (implemented by the consuming controller) must +verify the requester is authorized for vpcRef before a service is accepted — +see the Accepted condition. + +Unlike the earlier Full-NAT design this type originally described, there +is no primary/secondary gateway node for a service: every NetworkGateway +advertises every accepted service's vipAddresses at equal BGP preference, +consistent-hashes the same backend list to the same backend for the same +flow (internal/maglev), and forwards without rewriting anything — +backend selection never needs a single "owning" node the way Full-NAT's +SNAT-source model did. + +Backends are not listed. BackendSelector picks the VPCAttachments that +serve the service, so the backend set follows attachments as they are +created, deleted or moved to another node, and galactic-router on each +node generates the ServiceVIPBinding its backends need from the same +selection. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `NetworkService` | | | +| `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` _[NetworkServiceSpec](#networkservicespec)_ | | | | +| `status` _[NetworkServiceStatus](#networkservicestatus)_ | | | | + + +#### NetworkServiceProtocol + +_Underlying type:_ _string_ + +NetworkServiceProtocol is the transport protocol matched by a NetworkService's +ingress VIP. + +_Validation:_ +- Enum: [tcp udp] + +_Appears in:_ +- [NetworkServiceSpec](#networkservicespec) +- [ServiceEndpointSpec](#serviceendpointspec) +- [ServiceRouteProtocolPort](#servicerouteprotocolport) +- [ServiceVIPBindingSpec](#servicevipbindingspec) + +| Field | Description | +| --- | --- | +| `tcp` | NetworkServiceProtocolTCP matches TCP traffic.
| +| `udp` | NetworkServiceProtocolUDP matches UDP traffic.
| + + +#### NetworkServiceSpec + + + +NetworkServiceSpec defines the desired ingress load-balancing state for a +tenant VPC. + + + +_Appears in:_ +- [NetworkService](#networkservice) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vpcRef` _string_ | VPCRef is the opaque identifier of the target VPC this service 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 service is accepted. | | MinLength: 1
Required: \{\}
| +| `vipAddresses` _string array_ | VIPAddresses is the list of ingress VIP addresses this service provisions
on the assigned gateway node(s).
Only IPv6 addresses are admitted until the gateway datapath serves IPv4
VIPs (datum-cloud/galactic#705). A service created with an IPv4 VIP before
this restriction keeps it while the list is unchanged, since the API
server ratchets validation of an unchanged field on Kubernetes 1.30 and
later; any edit to the list must drop it, and on an older API server any
update to such a service is refused. The gateways do not load or advertise
an IPv4 VIP. | | MaxItems: 8
MinItems: 1
Required: \{\}
items:MaxLength: 45
| +| `protocol` _[NetworkServiceProtocol](#networkserviceprotocol)_ | Protocol is the transport protocol matched by VIPAddresses/Port. | | Enum: [tcp udp]
Required: \{\}
| +| `port` _integer_ | Port is the ingress port on VIPAddresses that this service load-balances. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `backendSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | BackendSelector selects the VPCAttachments (galactic.datumapis.com/v1alpha1,
in any namespace) that serve this service, matched against each
VPCAttachment object's own labels. Only attachments whose status.vpc
equals VPCRef are candidates, whatever their labels, so a selector cannot
reach into another tenant's VPC. Each selected attachment contributes the
IPv6 addresses in its spec.interface.addresses as backends, on
BackendPort; the DSR datapath carries IPv6 backends only. An attachment
with no status.node or no IPv6 address is not a backend until it has
both. An empty selector is rejected rather than read as "every attachment
in the VPC". | | Required: \{\}
| +| `backendPort` _integer_ | BackendPort is the destination port on every selected backend. | | Maximum: 65535
Minimum: 1
Required: \{\}
| + + +#### NetworkServiceStatus + + + +NetworkServiceStatus defines the observed state of a NetworkService. + + + +_Appears in:_ +- [NetworkService](#networkservice) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource. | | | + + +#### ServiceEndpoint + + + +ServiceEndpoint publishes a platform service that can be reached through a +VPC attachment. The object describes the service contract; the controller +that owns the service is responsible for its health and implementation. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceEndpoint` | | | +| `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` _[ServiceEndpointSpec](#serviceendpointspec)_ | | | | + + +#### ServiceEndpointAttachmentReference + + + +ServiceEndpointAttachmentReference identifies the private attachment that +backs a platform service endpoint. + + + +_Appears in:_ +- [ServiceEndpointSpec](#serviceendpointspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `namespace` _string_ | Namespace is the namespace containing the Cloud API VPCAttachment. | | MinLength: 1
| +| `name` _string_ | Name is the Cloud API VPCAttachment name. | | MinLength: 1
| + + +#### ServiceEndpointDeliveryMode + +_Underlying type:_ _string_ + +ServiceEndpointDeliveryMode defines the placement contract for a service. + +_Validation:_ +- Enum: [NodeLocal PreferNodeLocal] + +_Appears in:_ +- [ServiceEndpointSpec](#serviceendpointspec) + +| Field | Description | +| --- | --- | +| `NodeLocal` | ServiceEndpointDeliveryModeNodeLocal requires the endpoint's backing
attachment to be present on each node where selected consumers run.
| +| `PreferNodeLocal` | ServiceEndpointDeliveryModePreferNodeLocal selects a backing attachment on
the consumer's node when possible and otherwise tunnels to a ready remote
attachment while preserving the consumer source address.
| + + +#### ServiceEndpointReference + + + +ServiceEndpointReference identifies a ServiceEndpoint in the policy's +namespace. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the ServiceEndpoint name. | | MinLength: 1
| + + +#### ServiceEndpointSpec + + + +ServiceEndpointSpec defines a platform service address and its scope. + + + +_Appears in:_ +- [ServiceEndpoint](#serviceendpoint) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceClass` _string_ | ServiceClass identifies the platform capability implemented by this
endpoint, for example "ipv6-egress-dns". | | MinLength: 1
Required: \{\}
| +| `address` _string_ | Address is the stable address that consumers use to reach the service. | | Required: \{\}
| +| `port` _integer_ | Port is the service port. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `protocol` _[NetworkServiceProtocol](#networkserviceprotocol)_ | Protocol is the transport protocol accepted by the endpoint. | | Enum: [tcp udp]
Required: \{\}
| +| `deliveryMode` _[ServiceEndpointDeliveryMode](#serviceendpointdeliverymode)_ | DeliveryMode defines how a backing attachment is selected relative to a
consumer. PreferNodeLocal uses a ready attachment on the consumer's node
when one exists and otherwise falls back to a ready remote attachment.
The dataplane preserves the consumer source address. If consumers with
overlapping address space present an identical TCP or UDP five-tuple to the
same replica, it rejects the later ambiguous flow fail-closed rather than
translate the source or risk returning traffic to the wrong VPC. | | Enum: [NodeLocal PreferNodeLocal]
Required: \{\}
| +| `attachmentRef` _[ServiceEndpointAttachmentReference](#serviceendpointattachmentreference)_ | AttachmentRef identifies the Cloud API VPCAttachment that hosts this
endpoint when the service is delivered through a private VPC path.
The network API keeps this reference opaque; the consuming controller
resolves it against the Cloud API. | | | +| `attachmentSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | AttachmentSelector selects the Cloud API VPCAttachments that host replicas
of this endpoint. Labels used here must be assigned by the platform service
controller, not by tenants. The dataplane selects one ready replica for
each consumer according to DeliveryMode. | | | +| `region` _string_ | Region limits endpoint selection to a region. An empty value means the
endpoint is not region-scoped. | | | + + +#### ServiceRouteAuthorization + + + +ServiceRouteAuthorization bounds one network authorization episode. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `validUntil` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | ValidUntil is the absolute UTC deadline for requests and existing replies.
Node controllers reject deadlines more than two minutes in the future. | | | + + +#### ServiceRouteFrontend + + + +ServiceRouteFrontend declares the consumer-facing service address. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `address` _string_ | Address is translated to the ServiceEndpoint address for requests and is
restored as the source address for replies. | | | + + +#### ServiceRouteNodeStatus + + + +ServiceRouteNodeStatus acknowledges one node's current programmed path. + + + +_Appears in:_ +- [ServiceRoutePolicyStatus](#serviceroutepolicystatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `nodeName` _string_ | | | | +| `policyUID` _string_ | | | | +| `observedGeneration` _integer_ | | | | +| `inputDigest` _string_ | | | | +| `ready` _boolean_ | | | | +| `validUntil` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | | | | + + +#### ServiceRoutePolicy + + + +ServiceRoutePolicy selects VPC attachments that should receive a route to a +platform ServiceEndpoint. The selector is evaluated by the network control +plane; Galactic compiles the selected attachments into dataplane state. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceRoutePolicy` | | | +| `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` _[ServiceRoutePolicySpec](#serviceroutepolicyspec)_ | | | | +| `status` _[ServiceRoutePolicyStatus](#serviceroutepolicystatus)_ | | | | + + +#### ServiceRoutePolicySpec + + + +ServiceRoutePolicySpec defines service selection and attachment eligibility. + + + +_Appears in:_ +- [ServiceRoutePolicy](#serviceroutepolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceRef` _[ServiceEndpointReference](#serviceendpointreference)_ | ServiceRef identifies the ServiceEndpoint in this namespace. | | Required: \{\}
| +| `consumerVPCRef` _[ServiceRouteVPCReference](#serviceroutevpcreference)_ | ConsumerVPCRef pins authorization to the immutable lifetime of the VPC
selected by AttachmentSelector. Galactic resolves the name through the
Cloud API and requires both the live name and UID to match. | | | +| `frontend` _[ServiceRouteFrontend](#serviceroutefrontend)_ | Frontend is the address presented inside the consumer VPC. When set,
Galactic translates it to the ServiceEndpoint address and restores it on
replies. | | | +| `authorization` _[ServiceRouteAuthorization](#servicerouteauthorization)_ | Authorization bounds translated access independently of application leases.
Only trusted networking integration may renew this deadline. | | | +| `attachmentSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | AttachmentSelector selects eligible VPC attachments by authoritative
labels. Labels that grant access to platform services must be assigned by
the network control plane, not by tenants. | | Required: \{\}
| +| `protocolPorts` _[ServiceRouteProtocolPort](#servicerouteprotocolport) array_ | ProtocolPorts limits the traffic that the route is intended to carry.
An empty list means the endpoint's declared port and protocol are used. | | | +| `region` _string_ | Region limits selected attachments to a region. An empty value means the
policy applies in every region where the endpoint is available. | | | + + +#### ServiceRoutePolicyStatus + + + +ServiceRoutePolicyStatus contains policy conditions and fenced node reports. + + + +_Appears in:_ +- [ServiceRoutePolicy](#serviceroutepolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains durable acceptance and service-availability state. | | | +| `nodes` _[ServiceRouteNodeStatus](#serviceroutenodestatus) array_ | Nodes contains short-lived acknowledgments written by trusted node controllers.
Consumers require current UID, generation, input digest, and unexpired reports. | | | + + +#### ServiceRouteProtocolPort + + + +ServiceRouteProtocolPort identifies an allowed transport protocol and port. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `protocol` _[NetworkServiceProtocol](#networkserviceprotocol)_ | Protocol is the transport protocol. | | Enum: [tcp udp]
Required: \{\}
| +| `port` _integer_ | Port is the transport port. | | Maximum: 65535
Minimum: 1
Required: \{\}
| + + +#### ServiceRouteVPCReference + + + +ServiceRouteVPCReference identifies one immutable Cloud API VPC lifetime. + + + +_Appears in:_ +- [ServiceRoutePolicySpec](#serviceroutepolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the VPC name in the policy namespace. | | MinLength: 1
| +| `uid` _string_ | UID is the Kubernetes UID of that VPC lifetime. | | MinLength: 1
| + + +#### ServiceVIPBinding + + + +ServiceVIPBinding drives one worker node's backend-side half of the +DSR/Maglev load-balancer datapath: it tells the node which service VIP a +specific local backend must be reachable on, so the backend can reply to +clients directly (the "Direct Server Return" this design depends on — +see NetworkGateway's doc comment). Generated, never written by users: +galactic-router on each worker node writes one object per (service, VIP, +backend) for every backend a NetworkService's BackendSelector places on that +node, owned by the service, and the same process's per-node reconciler +consumes it. + +EgressKind decides which of two backend mechanisms this object drives, +mirroring the same veth/tap fork the SRv6 uSID decap datapath already +has (internal/plumbing/ebpf/usidmap's egress_kind field). Both mechanisms +now converge on the same VIP-boundary substitution +(BackendAddress:BackendPort for VIPAddress:Port at the SRv6 uSID TC-BPF +boundary, usid_ingress's inbound half / usid_egress's outbound half) — +required for both, not just tap, since a decapsulated ingress packet is +delivered into the owning tenant's own VRF routing table, which has no +route to an address bound outside that VRF (found live: see galactic's +ServiceVIPBindingReconciler doc comment): + + - veth (container backend): the node ALSO binds VIPAddress on its own + galactic-vip0 dummy interface (internal/plumbing/vip's + Bind/Unbind/Verify in galactic) — this alone does not deliver + anything to the backend pod (galactic-vip0 lives in the node's root + namespace, not the tenant's VRF), but still lets the node itself + verifiably answer on the VIP. + - tap (VM backend): there is no guest-side configuration capability in + this repo by design (internal/cnitap's own doc comment), so the + substitution above is this kind's *only* delivery mechanism — the + guest OS never needs to know the VIP exists at all. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `galactic.datumapis.com/v1alpha1` | | | +| `kind` _string_ | `ServiceVIPBinding` | | | +| `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` _[ServiceVIPBindingSpec](#servicevipbindingspec)_ | | | | +| `status` _[ServiceVIPBindingStatus](#servicevipbindingstatus)_ | | | | + + +#### ServiceVIPBindingEgressKind + +_Underlying type:_ _string_ + +ServiceVIPBindingEgressKind mirrors usidmap's EgressKindVeth/EgressKindTap +constants at the API layer — see ServiceVIPBinding's doc comment. + +_Validation:_ +- Enum: [veth tap] + +_Appears in:_ +- [ServiceVIPBindingSpec](#servicevipbindingspec) + +| Field | Description | +| --- | --- | +| `veth` | ServiceVIPBindingEgressKindVeth selects the netns-bind mechanism.
| +| `tap` | ServiceVIPBindingEgressKindTap selects the transparent tap-boundary
translation mechanism.
| + + +#### ServiceVIPBindingSpec + + + +ServiceVIPBindingSpec defines the desired VIP binding/translation state. + + + +_Appears in:_ +- [ServiceVIPBinding](#servicevipbinding) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `targetRef` _[TargetRef](#targetref)_ | TargetRef identifies the Node this binding applies to. | | Required: \{\}
| +| `vpcRef` _string_ | VPCRef is the opaque identifier of the VPC the backend belongs to,
copied from the owning NetworkService. It names the tenant VRF the
binding's translation rows are written for, so two tenants using the
same backend address on one node never share a row. | | MinLength: 1
Required: \{\}
| +| `vipAddress` _string_ | VIPAddress is the service VIP the backend must be reachable on. | | Required: \{\}
| +| `port` _integer_ | Port is the VIP-facing port traffic arrives on. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `protocol` _[NetworkServiceProtocol](#networkserviceprotocol)_ | Protocol is the transport protocol this binding applies to. | | Enum: [tcp udp]
Required: \{\}
| +| `backendAddress` _string_ | BackendAddress is the backend's own real address (its pod-netns
address for a veth backend, or its actual guest-facing address for a
tap backend) — the VIP-boundary substitution target for both kinds
now (see EgressKind's own doc comment for why veth needs this too,
not just tap). +optional at the API level for the same reason
EgressKind itself carries no matching CEL requirement; the
reconciler validates it's set for either kind before doing anything. | | | +| `backendPort` _integer_ | BackendPort is the backend's own real port, paired with
BackendAddress — required for both kinds, see that field's doc
comment. | | Maximum: 65535
Minimum: 1
| +| `egressKind` _[ServiceVIPBindingEgressKind](#servicevipbindingegresskind)_ | EgressKind selects which backend mechanism this binding drives. | | Enum: [veth tap]
Required: \{\}
| + + +#### ServiceVIPBindingStatus + + + +ServiceVIPBindingStatus defines the observed state of a ServiceVIPBinding. + + + +_Appears in:_ +- [ServiceVIPBinding](#servicevipbinding) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the .metadata.generation this status was computed from. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions contains the standard conditions for this resource,
including Bound (set True once internal/plumbing/vip.Verify or the
equivalent tap-translation-table check confirms the backend is
actually reachable on VIPAddress, not merely that the bind/table-write
call itself returned nil). | | | + + +#### TargetRef + + + +TargetRef identifies the execution target for a BGPRouter. +Supported values for kind: Node. + + + +_Appears in:_ +- [EgressShardSpec](#egressshardspec) +- [NetworkGatewaySpec](#networkgatewayspec) +- [ServiceVIPBindingSpec](#servicevipbindingspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `kind` _string_ | Kind is the target resource kind (e.g. Node). | | MinLength: 1
| +| `name` _string_ | Name is the name of the target resource. | | MinLength: 1
| diff --git a/docs/architecture/containers.puml b/docs/architecture/containers.puml index cb23292e..b40705d9 100644 --- a/docs/architecture/containers.puml +++ b/docs/architecture/containers.puml @@ -5,7 +5,7 @@ LAYOUT_WITH_LEGEND() title Galactic — Container Diagram (the four deployable applications) -Person(tenant, "Tenant / Developer", "Deploys pods/VMs; authors NetworkRule specs") +Person(tenant, "Tenant / Developer", "Deploys pods/VMs; authors NetworkService specs") Person_Ext(client, "External Client") System_Boundary(galactic, "Galactic") { @@ -15,15 +15,15 @@ System_Boundary(galactic, "Galactic") { Container(router, "galactic-router", "Go, controller-runtime", "Reconciles BGPRouter/BGPPeer/BGPAdvertisement/BGPPolicy/BGPVRFInstance CRDs; runs orphaned-CRD/VRF GC; own DaemonSet on every compute *and* gateway-role node") Container(gobgp, "Embedded GoBGP", "osrg/gobgp v4 (in-process library)", "Tenant-role BGP speaker; EVPN path origination, per-node RIB (ephemeral)") - Container(gateway, "galactic-gateway", "Go, controller-runtime", "Reconciles NetworkGateway/NetworkRule CRDs; anycast VIP advertisement; own single-container DaemonSet on gateway-role nodes") + Container(gateway, "galactic-gateway", "Go, controller-runtime", "Reconciles NetworkGateway/NetworkService CRDs; anycast VIP advertisement; own single-container DaemonSet on gateway-role nodes") Container(edgebpf, "Edge XDP DSR datapath", "XDP (cilium/ebpf)", "DSR (Direct Server Return) load-balancing over a Maglev consistent-hash ring on the gateway node's public interface") Container(nat66, "galactic-nat", "Go, controller-runtime", "Reconciles EgressShard CRD; publishes a BGPAdvertisement for this shard's SID (its covering /64, one destination per tenant Argument) + public address (/128); own DaemonSet on every compute node") Container(nat66bpf, "NAT66 egress XDP datapath", "XDP (cilium/ebpf)", "Sharded, stateful SNAT/un-SNAT for tenant egress traffic on the shard's uplink interface") } -Container_Ext(crds, "BGP / Gateway / NAT66 CRDs", "go.datum.net/network API", "BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance, NetworkGateway, NetworkRule, EgressShard") -Container_Ext(vpccrds, "VPC / VPCAttachment CRDs", "go.datum.net/cloud API", "Owned by the companion VPC operator") +Container_Ext(crds, "BGP / Gateway / NAT66 CRDs", "galactic.datumapis.com API", "BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance, NetworkGateway, NetworkService, EgressShard") +Container_Ext(vpccrds, "VPC / VPCAttachment CRDs", "galactic.datumapis.com API", "Owned by the companion VPC operator") Container_Ext(fabric, "fabric-router", "FRR DaemonSet", "eBGP underlay reachability") System_Ext(k8sapi, "Kubernetes API Server") @@ -40,7 +40,7 @@ Rel(router, usidbpf, "GC sweeps stale vrf_table entries", "cilium/ebpf") Rel(gobgp, fabric, "Peers over eBGP underlay", "eBGP") ' Gateway internals -Rel(gateway, crds, "Reconciles NetworkGateway/NetworkRule; reads BGPRouter/BGPAdvertisement/BGPVRFInstance for uSID resolution; writes per-rule BGPAdvertisement", "controller-runtime manager") +Rel(gateway, crds, "Reconciles NetworkGateway/NetworkService; reads BGPRouter/BGPAdvertisement/BGPVRFInstance for uSID resolution; writes per-rule BGPAdvertisement", "controller-runtime manager") Rel(gateway, edgebpf, "Loads/attaches XDP program; writes vip_table / encap_config_table", "cilium/ebpf") Rel(client, edgebpf, "External ingress traffic to VIP:port", "IPv6") Rel(gateway, router, "Co-located on the same gateway-role node -- separate pod/DaemonSet, not a sidecar", "no direct RPC") diff --git a/docs/architecture/context.puml b/docs/architecture/context.puml index e2526e71..9e2b6a4c 100644 --- a/docs/architecture/context.puml +++ b/docs/architecture/context.puml @@ -6,7 +6,7 @@ LAYOUT_WITH_LEGEND() title Galactic — System Context Person(operator, "Platform Operator", "Deploys Galactic DaemonSets; labels control-plane/gateway-role nodes") -Person(tenant, "Tenant / Developer", "Deploys pods/VMs onto VPC networks; authors NetworkRule ingress specs") +Person(tenant, "Tenant / Developer", "Deploys pods/VMs onto VPC networks; authors NetworkService ingress specs") Person_Ext(client, "External Client", "Sends traffic to a NetworkGateway VIP:port") System(galactic, "Galactic", "The SRv6 data plane at the center of Datum Cloud's multi-cloud VPC networking — every other product surface's traffic ultimately moves over Galactic") @@ -16,12 +16,12 @@ System_Ext(connectors, "Connectors", "Datum Cloud interconnect / multi-site peer System_Ext(gateways, "Gateways", "Datum Cloud ingress load-balancing product") System_Ext(k8s, "Kubernetes API Server", "Source of truth: BGP/Gateway CRDs, Node, Secret objects") -System_Ext(vpcop, "VPC Operator", "go.datum.net/cloud — owns VPC/VPCAttachment CRD lifecycle") +System_Ext(vpcop, "VPC Operator", "galactic-controller — owns VPC/VPCAttachment CRD lifecycle") System_Ext(fabric, "Underlay Fabric", "FRR eBGP DaemonSet (fabric-router) providing node-to-node IP reachability") System_Ext(rr, "iBGP Route Reflector / External Peers", "Distributes EVPN paths between nodes and clusters") Rel(operator, galactic, "Deploys / configures", "kubectl apply -k, Kustomize") -Rel(tenant, galactic, "Attaches workloads to VPCs; authors NetworkRule specs", "Kubernetes API") +Rel(tenant, galactic, "Attaches workloads to VPCs; authors NetworkService specs", "Kubernetes API") Rel(client, galactic, "Connects to a NetworkGateway VIP:port", "IPv6") Rel(compute, galactic, "Relies on for pod/VM VPC-attached networking", "SRv6, EVPN") diff --git a/docs/enhancements/networking/interface-traffic-policy/README.md b/docs/enhancements/networking/interface-traffic-policy/README.md index e18df01a..4ee157df 100644 --- a/docs/enhancements/networking/interface-traffic-policy/README.md +++ b/docs/enhancements/networking/interface-traffic-policy/README.md @@ -110,15 +110,13 @@ These come from the product brief and are not open to redesign here. ### API ownership -Existing networking kinds live in `network.datumapis.com` (the network -repository) and `cloud.datumapis.com` (the cloud repository). Both repositories -are being folded into Galactic under `galactic.datumapis.com/v1alpha1`, with -every kind in `api/v1alpha1` of the Galactic module. New kinds therefore start -there. Adding them to the network module would deepen a dependency that is -being removed, and would need a companion release for every change. - -VPC and VPCAttachment are owned by the cloud operator, which writes both the -spec and the status of every VPCAttachment. Galactic does not write either. +The cloud and network API types now live in `api/cloud/v1alpha1` and +`api/network/v1alpha1` of the Galactic module. All of these types use +`galactic.datumapis.com/v1alpha1`. TrafficPolicy and TrafficPolicyBinding +remain in `api/v1alpha1` under the same group. + +VPC and VPCAttachment are owned by the Galactic cell controller, which writes both the +spec and the status of every VPCAttachment. The node controller does not write either. Bindings are therefore a separate Galactic kind rather than new fields on VPCAttachment. diff --git a/docs/enhancements/networking/private-service-connect/README.md b/docs/enhancements/networking/private-service-connect/README.md index 6729ff81..8183980a 100644 --- a/docs/enhancements/networking/private-service-connect/README.md +++ b/docs/enhancements/networking/private-service-connect/README.md @@ -204,7 +204,7 @@ through platform-managed labels, including producers in the DNS service's edge namespace. Each eligible producer must serve this destination and DNS context. ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceEndpoint metadata: name: context-a-dns-udp @@ -255,7 +255,7 @@ the VPC named `application-vpc` in `consumer-a` to its live edge UID. The select narrows eligible attachments; it does not replace the VPC identity check. ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceRoutePolicy metadata: name: application-vpc-dns-udp diff --git a/docs/fabric-api/api.md b/docs/fabric-api/api.md index 1a39d76f..944f5dab 100644 --- a/docs/fabric-api/api.md +++ b/docs/fabric-api/api.md @@ -20,7 +20,7 @@ cluster, and its status is written by that cell's gateway and returned by Karmad | Property | Value | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group, version | `network.datumapis.com/v1alpha1` | +| Group, version | `galactic.datumapis.com/v1alpha1` | | Kind | `FabricQuery`, list kind `FabricQueryList` | | Scope | Namespaced (the project's mapped namespace on the hub) | | Short name | `fq` | @@ -34,8 +34,8 @@ ships the kind; `config/fabric-api/crd/` is a generated copy for the lab and tes only, and infra owns the CRD in every cell. Regenerate with `task generate:crd`. NSO sets two labels on every object so a janitor could find requests without -reading specs: `network.datumapis.com/fabric-request-id` (the request ID) and -`network.datumapis.com/fabric-expires-at` (the expiration, Unix seconds). The cell +reading specs: `galactic.datumapis.com/fabric-request-id` (the request ID) and +`galactic.datumapis.com/fabric-expires-at` (the expiration, Unix seconds). The cell gateway and janitor in this repository read `spec`, not the labels. ## Spec @@ -421,14 +421,14 @@ A route lookup for a prefix in the `dfw` cell, as NSO would write it on the hub. Names, addresses and ASNs are illustrative. ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: FabricQuery metadata: name: 6f1c2e8a-9b3d-4a57-8e10-2c4d5a6b7c8d-dfw namespace: project-example labels: - network.datumapis.com/fabric-request-id: 6f1c2e8a-9b3d-4a57-8e10-2c4d5a6b7c8d-dfw - network.datumapis.com/fabric-expires-at: "1791396120" + galactic.datumapis.com/fabric-request-id: 6f1c2e8a-9b3d-4a57-8e10-2c4d5a6b7c8d-dfw + galactic.datumapis.com/fabric-expires-at: "1791396120" spec: requestID: 6f1c2e8a-9b3d-4a57-8e10-2c4d5a6b7c8d-dfw source: diff --git a/docs/fabric-api/configuration.md b/docs/fabric-api/configuration.md index 940faf56..ccf2c5a9 100644 --- a/docs/fabric-api/configuration.md +++ b/docs/fabric-api/configuration.md @@ -41,7 +41,7 @@ None of these are created by this repository's manifests. per-cell fabric-api CA. Not a general cluster CA: trust is per cell. Infra owns it in production; the lab's stand-in is `deploy/containerlab/resources/fabric-api/pki/pki.yaml`. -3. **The `FabricQuery` CRD** (`fabricqueries.network.datumapis.com`). Infra owns +3. **The `FabricQuery` CRD** (`fabricqueries.galactic.datumapis.com`). Infra owns its lifecycle in every cell. `config/fabric-api/crd/` is a copy for the lab and tests only; do not apply it where infra manages the CRD. 4. **A per-cell `fabric-api` ConfigMap** in `galactic-system` (see diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f1a4b579..33f0f04b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -80,7 +80,7 @@ This kustomization covers exactly two resources — `config/galactic-gateway/serviceaccount.yaml` (the `galactic-gateway` ServiceAccount in `galactic-system`) and `config/galactic-gateway/rbac.yaml` (a `ClusterRole`/`ClusterRoleBinding` pair granting that ServiceAccount -`get`/`list`/`watch`/`update`/`patch` on `networkgateways`/`networkrules` +`get`/`list`/`watch`/`update`/`patch` on `networkgateways`/`networkservices` (+`/status`), full CRUD on `bgpadvertisements`, and read-only `get`/`list`/`watch` on both `bgprouters` and `bgpvrfinstances` — the latter so `NetworkGatewayReconciler`'s `BGPVRFInstance` watch has an informer it's @@ -273,7 +273,7 @@ deploy/containerlab/resources/galactic-gateway/ ├── iad-worker2/ # " ├── dfw/ │ ├── kustomization.yaml # composes this site's edge nodes + its rules -│ └── networkrules.yaml # ns60-tcp and ns60-udp, port 80, selecting ns60's backends +│ └── networkservices.yaml # ns60-tcp and ns60-udp, port 80, selecting ns60's backends ├── sjc/ # same shape, one edge node └── iad/ # " ``` @@ -371,7 +371,7 @@ the rest of the router config, in and `bgppeer-dfw-worker2.yaml`: ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPRouter metadata: name: galactic-router-dfw-worker2 @@ -388,7 +388,7 @@ spec: - afi: l2vpn safi: evpn --- -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: BGPPeer metadata: name: galactic-control-dfw-worker2 @@ -412,7 +412,7 @@ gateway-specific. ### `networkgateway.yaml` — register this node as a gateway node ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: NetworkGateway metadata: name: dfw-worker2 @@ -429,10 +429,10 @@ after the node it targets. `NetworkGatewayReconciler` matches this against `--node-name`/`GALACTIC_GATEWAY_NODE_NAME` to decide whether it owns this object. -## Configuring `NetworkGateway` and `NetworkRule` +## Configuring `NetworkGateway` and `NetworkService` `galactic-gateway` reconciles exactly two CRDs -(`go.datum.net/network`'s `api/v1alpha1` package): +(`api/network/v1alpha1`): ### `NetworkGateway` — one per gateway node @@ -445,23 +445,23 @@ There is deliberately **no** self-address or primary-node field on this status — DSR has nothing analogous to publish. Create one object per gateway node, named after that node (see the worked example above). -### `NetworkRule` — internal ingress load-balancing spec +### `NetworkService` — internal ingress load-balancing spec | Field | Required | Type | Description | | ---------------------- | -------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `spec.vpcRef` | Yes | `string` | Opaque VPC identifier (owned by the companion operator, not validated here beyond non-emptiness). Only attachments whose `status.vpc` equals it can be backends. | +| `spec.vpcRef` | Yes | `string` | Opaque VPC identifier (owned by the Galactic cell controller, not validated here beyond non-emptiness). Only attachments whose `status.vpc` equals it can be backends. | | `spec.vipAddresses` | Yes | `[]string` (1–8) | Ingress VIP addresses this rule provisions. IPv4 VIPs are accepted but not served yet (#705); see below. | | `spec.protocol` | Yes | `tcp` \| `udp` | Transport protocol matched by `vipAddresses`/`port`. | | `spec.port` | Yes | `int32` (1–65535) | Ingress port on `vipAddresses` this rule load-balances. | -| `spec.backendSelector` | Yes | `metav1.LabelSelector` | Selects the `VPCAttachment`s (`cloud.datumapis.com`, any namespace) that serve the rule. Each contributes its IPv6 interface addresses as backends. Must not be empty. | +| `spec.backendSelector` | Yes | `metav1.LabelSelector` | Selects the `VPCAttachment`s (`galactic.datumapis.com`, any namespace) that serve the rule. Each contributes its IPv6 interface addresses as backends. Must not be empty. | | `spec.backendPort` | Yes | `int32` (1–65535) | Destination port on every selected backend. | | `status.conditions` | — | — | `Accepted` (set `True` once gateway nodes exist in the namespace; not an ownership check, see below), `/Programmed` per gateway node, `/IPv4Served` per gateway node for a rule with an IPv4 VIP, and `/BackendsBound` per node hosting a backend. | -Example, the TCP rule from `deploy/containerlab/resources/galactic-gateway/iad/networkrules.yaml`: +Example, the TCP rule from `deploy/containerlab/resources/galactic-gateway/iad/networkservices.yaml`: ```yaml -apiVersion: network.datumapis.com/v1alpha1 -kind: NetworkRule +apiVersion: galactic.datumapis.com/v1alpha1 +kind: NetworkService metadata: name: ns60-tcp namespace: galactic-system @@ -512,20 +512,19 @@ it could not answer. Until then it appears in the rule's a backend whose node advertises no route for it yet. A rule with no backend at all is not loaded. -Galactic never writes an attachment's status. -[datum-cloud/cloud](https://github.com/datum-cloud/cloud)'s controllers do. +The Galactic cell controllers write attachment status. One of them reads the `BGPAdvertisement` that `galactic-router` publishes for the attachment and copies the node of that advertisement's `BGPRouter` into `status.node`. Without them, an attachment with no `status.vpc` is never selected, so the rule reports no backends and no pending entry for it. An attachment whose `status.vpc` is set but whose `status.node` is not stays -pending with `status.node not set by datum-cloud/cloud`. A rule whose +pending with `status.node not set by the Galactic cell controller`. A rule whose attachments are all pending or unselected does not load. The containerlab lab runs none of these controllers, so `deploy/containerlab/scripts/publish-attachments.sh` patches `status.vpc` and `status.node` in by hand. -Every accepted, non-deleting `NetworkRule` in the namespace is served by +Every accepted, non-deleting `NetworkService` in the namespace is served by **every** `NetworkGateway` node identically — there is no `status.primaryNode`/placement field to set, and no per-node subset to target. `galactic-gateway` resolves each backend to the SRv6 uSID of the @@ -543,32 +542,32 @@ without an edit. See [ARCHITECTURE-GATEWAY.md's Data Flow section](../agents/ARCHITECTURE-GATEWAY.md#data-flow) for the full mechanics. -Deleting a `NetworkRule` withdraws its VIP routes before any gateway node +Deleting a `NetworkService` withdraws its VIP routes before any gateway node stops serving it. The rule's finalizer deletes its `BGPAdvertisement`s, and each node keeps the rule loaded until they are gone plus a 5-second drain delay, so traffic already on its way to the VIP is not dropped. -> **Note:** `NetworkRule` is not a tenant API. The platform API that serves +> **Note:** `NetworkService` is not a tenant API. The platform API that serves > tenants writes it, after checking that the tenant owns the VPC `vpcRef` > names. Galactic does not repeat that check: `Accepted` means only that > gateway nodes exist in the namespace. Anyone RBAC allows to write a -> `NetworkRule` in `galactic-system` can provision ingress for any `vpcRef`, +> `NetworkService` in `galactic-system` can provision ingress for any `vpcRef`, > so grant that access only to platform controllers. ### `ServiceVIPBinding` — the backend-side half, generated by `galactic-router` -A `NetworkRule` configures the gateway side of DSR. For a backend to +A `NetworkService` configures the gateway side of DSR. For a backend to **answer the client directly** on the VIP, its own node also needs a `ServiceVIPBinding` for each `(VIP, backend)` pair. Nobody writes these by -hand: `galactic-router` on every node runs `NetworkRuleBindingReconciler` -(`internal/controller/networkrule_binding_controller.go`), which writes one +hand: `galactic-router` on every node runs `NetworkServiceBindingReconciler` +(`internal/controller/networkservice_binding_controller.go`), which writes one for each IPv6 VIP of each accepted rule and each selected backend whose attachment reports that node, and deletes the ones that no longer apply. `ServiceVIPBindingReconciler`, in the same process, then programs the node's `vip_xlat_table` from it. Example of a generated binding: ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceVIPBinding metadata: name: ns60-tcp-iad-worker-3f0c9a1d6b2e4f57 @@ -578,8 +577,8 @@ metadata: galactic.datum.net/network-rule: ns60-tcp galactic.datum.net/binding-node: iad-worker ownerReferences: - - apiVersion: network.datumapis.com/v1alpha1 - kind: NetworkRule + - apiVersion: galactic.datumapis.com/v1alpha1 + kind: NetworkService name: ns60-tcp spec: targetRef: @@ -667,17 +666,17 @@ Confirm the DaemonSet and CRDs exist and are healthy: ```sh kubectl get daemonset -n galactic-system -l app.kubernetes.io/name=galactic-gateway -kubectl get networkgateways,networkrules -n galactic-system +kubectl get networkgateways,networkservices -n galactic-system kubectl get pods -n galactic-system -l app.kubernetes.io/name=galactic-gateway -o wide ``` Inspect a specific `NetworkGateway`'s `Ready` condition (`EngineHealthy`, `AdvertisementFailed`, or `Terminating` — see the field reference above) -and a `NetworkRule`'s `Accepted` condition: +and a `NetworkService`'s `Accepted` condition: ```sh kubectl get networkgateway -n galactic-system -o yaml -kubectl get networkrule -n galactic-system -o yaml +kubectl get networkservice -n galactic-system -o yaml ``` Check both pods' logs — `galactic-gateway`'s own gRPC health check only diff --git a/docs/metrics.md b/docs/metrics.md index 55df1a3f..d68874d4 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -314,7 +314,7 @@ so there is no connection table to report. See `unknown_` for an index the binary has no name for. The port also serves controller-runtime's metrics for the -`NetworkGateway`/`NetworkRule` reconcilers. +`NetworkGateway`/`NetworkService` reconcilers. ```promql # Requests per second per VIP and port diff --git a/docs/migration/galactic-api-consolidation.md b/docs/migration/galactic-api-consolidation.md new file mode 100644 index 00000000..cd12ce57 --- /dev/null +++ b/docs/migration/galactic-api-consolidation.md @@ -0,0 +1,62 @@ +# Consolidating the cloud and network APIs + +This change moves the `cloud` and `network` main-branch API types and the cloud +controllers into Galactic. The served API group for VPC, BGP, gateway, traffic +policy, and FabricQuery resources is `galactic.datumapis.com/v1alpha1`. +`NetworkRule` is now `NetworkService`, matching the network main branch. + +The source revisions are cloud `55c38b5`, network `5a51626`, and Galactic +`c1769cb`. The cloud cell reconcilers run in `galactic-controller --role=cell` +with the traffic-policy reconcilers. The central fabric identity reconciler +runs in the same binary with `--role=fabric` and a separate deployment because +its hub and IPAM credentials do not belong in each cell. + +## Cutover sequence + +1. Export the existing `cloud.datumapis.com` and `network.datumapis.com` + resources, including their status, before changing any CRD or controller. + Keep the old CRDs installed throughout cutover. Kubernetes treats each new + group as a different resource; installing the Galactic CRDs does not copy + objects or convert owner references. +2. Apply `config/crd` on the hub and every cell that serves these kinds. + Apply `config/fabric-api/crd/galactic.datumapis.com_fabricqueries.yaml` + wherever FabricQuery is served. Install the `galactic-controller` cell + RBAC, webhook certificate, and both webhook configurations before routing + requests to the new controller. +3. Deploy `config/galactic-controller/fabric` centrally, with a hub credential + bound to `config/galactic-controller/fabric/hub-rbac.yaml` on the hub and an + IPAM credential for the platform project. Confirm the new + `NetworkFabricIdentity` objects have the same identity values as the old + objects and have propagated to their cells before stopping the old fabric + identity controller. The claim names are stable, so the allocator reuses + the retained IPAM claims. +4. Move the desired BGP, gateway, egress, and service resources to the new + group before replacing their node controllers. Use the new API schemas; + `NetworkRule` becomes `NetworkService`. Copy specs and selected labels and + annotations, not Kubernetes UIDs, resource versions, or status. The old + objects do not become visible to the new controllers automatically. +5. Stop the old cloud cell controller before starting the new cell role, so + only one controller writes each NetworkAttachmentDefinition and NSO + `NetworkInterface` status. Start `galactic-controller --role=cell` with the + correct `--attachment-mode` for the cell. It reuses the legacy NAD's VPC and + attachment IDs, transfers its owner reference to the new attachment, and + updates `NetworkInterface.status.attachmentRef.apiGroup` to + `galactic.datumapis.com`. On group change it sets `Programmed=Unknown` until + the new-group BGP advertisement proves programming again. +6. Reattach or roll existing workloads to republish their CNI-created + `BGPAdvertisement` objects under the new group. Keep old routing state + available until their new advertisements are present and the NSO interface + and claim conditions have reconverged. Verify every live interface reports + the Galactic attachment group and `Programmed=True`, and check gateway + and BGP node status before retiring old CRDs and controllers. + +NSO's `NetworkInterfaceAttachmentRef.APIGroup` is a free-form string written +by the provider. NSO preserves that field and the provider-owned `Programmed` +condition across reconcile, release, and rebind. Its regression test now +covers the change from the old cloud group to the Galactic group; no NSO +controller code change is required for that reference transition. + +The old group's VPC finalizers require the old controller to finish deletion, +or an operator-reviewed cleanup after the old objects are no longer serving. +Do not remove old CRDs while objects still carry finalizers or while a node +still depends on their advertisements. diff --git a/docs/nat/active-active-rollout.md b/docs/nat/active-active-rollout.md index 11dae7d2..9c5105dc 100644 --- a/docs/nat/active-active-rollout.md +++ b/docs/nat/active-active-rollout.md @@ -25,7 +25,7 @@ are deployed by infra. pruning the field. - [ ] `galactic-cni`'s ClusterRole grants `get`, `list` and `watch` on `egressshards`: - `kubectl auth can-i watch egressshards.network.datumapis.com -n galactic-system --as=system:serviceaccount:galactic-system:galactic-cni` + `kubectl auth can-i watch egressshards.galactic.datumapis.com -n galactic-system --as=system:serviceaccount:galactic-system:galactic-cni` answers `yes`. - [ ] Every shard is `Ready` and `Programmed`, and has its own SID locator, its own `shardAddressIPv6` and, for NAT64, its own `shardAddressIPv4`. diff --git a/docs/nat/configuration.md b/docs/nat/configuration.md index 5db8fc34..ffdaf1b4 100644 --- a/docs/nat/configuration.md +++ b/docs/nat/configuration.md @@ -241,7 +241,7 @@ under `/sys/fs/bpf/galactic-nat`. In dispatch mode it also pins the node's XDP dispatcher under `/sys/fs/bpf/galactic-xdp`, which the same `/sys/fs/bpf` mount covers. -## `EgressShard` CRD (`network.datumapis.com/v1alpha1`) +## `EgressShard` CRD (`galactic.datumapis.com/v1alpha1`) One object per shard node, in the `galactic-system` namespace. The spec assigns the shard's identity; the `galactic-nat` process on the target node @@ -364,7 +364,7 @@ expire. Tenants reach the prefix only once Example: ```yaml -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: EgressShard metadata: name: dfw-worker2-egress @@ -489,7 +489,7 @@ hashed mode with more than three NAT64 prefixes in must print `false`. - **`galactic-cni` may watch `EgressShard`s.** `config/galactic-cni/rbac.yaml` grants `get`, `list` and `watch` on `egressshards`; - `kubectl auth can-i watch egressshards.network.datumapis.com -n galactic-system --as=system:serviceaccount:galactic-system:galactic-cni` + `kubectl auth can-i watch egressshards.galactic.datumapis.com -n galactic-system --as=system:serviceaccount:galactic-system:galactic-cni` must answer `yes`. - **Every shard has its own identity and return route.** Each shard needs a distinct `shardSID` locator, a distinct `shardAddressIPv6` and, for NAT64, diff --git a/docs/router/private-service-routes.md b/docs/router/private-service-routes.md index 75ef2ebe..bfce4814 100644 --- a/docs/router/private-service-routes.md +++ b/docs/router/private-service-routes.md @@ -23,9 +23,7 @@ or neighbor discovery cannot deliver the VIP to that interface. Galactic treats an attachment as ready only when its status has `observedGeneration` equal to its generation, non-empty `node`, `vpc` and -`vpcAttachment`, and `Ready` and `Programmed` both `True`. Galactic writes none -of these. The controllers in -[datum-cloud/cloud](https://github.com/datum-cloud/cloud) write them, partly +`vpcAttachment`, and `Ready` and `Programmed` both `True`. The Galactic cell controllers in `internal/cloud/controller` write these, partly from the `BGPAdvertisement` that `galactic-router` publishes for the attachment. Without them no attachment is ready and no route is programmed. diff --git a/docs/traffic-policy/controller.md b/docs/traffic-policy/controller.md index 5d0aeffe..2a081e27 100644 --- a/docs/traffic-policy/controller.md +++ b/docs/traffic-policy/controller.md @@ -53,14 +53,13 @@ the API reference is [docs/api/galactic.md](../api/galactic.md). Apply, in order: -1. the `galactic.datumapis.com` CRDs, `kubectl apply -k config/crd`, and the - cloud operator's `VPCAttachment` CRD, which edge cells already carry: the - controller watches it and does not become ready without it; +1. the `galactic.datumapis.com` CRDs, `kubectl apply -k config/crd`, including the `VPCAttachment` CRD; the controller watches it and + does not become ready without it; 2. cert-manager, if the cell does not have it; 3. the ServiceAccount and RBAC, `kubectl apply -k config/galactic-controller`; 4. the controller, `kubectl apply -k config/galactic-controller/base`, which includes the self-signed serving certificate and the - ValidatingWebhookConfiguration. + ValidatingWebhookConfiguration and pod MutatingWebhookConfiguration. The webhook fails closed, so install it only with the Deployment it points at. With no replica serving, every TrafficPolicy write is refused; the diff --git a/go.mod b/go.mod index a61d7f23..bbbf38b1 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-iptables v0.8.0 github.com/go-logr/logr v1.4.4 github.com/google/uuid v1.6.0 + github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 github.com/kenshaw/baseconv v0.1.1 github.com/lorenzosaino/go-sysctl v0.3.1 github.com/mdlayher/genetlink v1.4.0 @@ -22,8 +23,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.2-0.20261002201547-1d1c62507e3c - go.datum.net/cloud v0.1.2 - go.datum.net/network v0.4.1 + go.datum.net/compute v0.8.0 + go.datum.net/network-services-operator v0.27.0 + go.miloapis.com/ipam v0.4.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 @@ -38,6 +40,7 @@ require ( k8s.io/api v0.36.3 k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/yaml v1.6.0 ) @@ -85,7 +88,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/ginkgo/v2 v2.28.2 // indirect - github.com/onsi/gomega v1.39.1 // indirect github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -100,6 +102,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + go.miloapis.com/locations v0.0.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect @@ -125,7 +128,7 @@ require ( k8s.io/apiextensions-apiserver v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect - k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect + sigs.k8s.io/gateway-api v1.5.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/knftables v0.0.18 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index fda06dcf..e69fdde9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -29,8 +29,8 @@ github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -99,8 +99,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= @@ -113,6 +113,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k-sone/critbitgo v1.4.0 h1:l71cTyBGeh6X5ATh6Fibgw3+rtNT80BA0uNNWgkPrbE= github.com/k-sone/critbitgo v1.4.0/go.mod h1:7E6pyoyADnFxlUBEKcnfS49b7SUAQGMK+OAp/UQvo0s= +github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 h1:z4P744DR+PIpkjwXSEc6TvN3L6LVzmUquFgmNm8wSUc= +github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7/go.mod h1:CM7HAH5PNuIsqjMN0fGc1ydM74Uj+0VZFhob620nklw= github.com/kenshaw/baseconv v0.1.1 h1:oAu/C7ipUT2PqT9DT0mZDGDg4URIglizZMjPv9oCu0E= github.com/kenshaw/baseconv v0.1.1/go.mod h1:yy9zGmnnR6vgOxOQb702nVdAG30JhyYZpj/5/m0siRI= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= @@ -145,8 +147,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/osrg/gobgp/v4 v4.10.0 h1:9yoWSFo7mR9byOBRDg/daWh0yBlv2PfWkb1YkCZZww8= @@ -202,10 +204,14 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.datum.net/cloud v0.1.2 h1:Bs8qR+ZCxf70bZGvjscyXeqoIIy8uNo2zjfdrWzIlGE= -go.datum.net/cloud v0.1.2/go.mod h1:HTeaepXL0EqiqNsQ13K4/kGEwv+VLJ//BGrUfVIFfEA= -go.datum.net/network v0.4.1 h1:SreFO3bMZBLSwsum+mYljYyB6g0BUXrZA6GXzHNxO3w= -go.datum.net/network v0.4.1/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU= +go.datum.net/compute v0.8.0 h1:/v1lni/oO4KwthPeXhhS0+VFRZjWYbU6CXiF3RgCMzY= +go.datum.net/compute v0.8.0/go.mod h1:u7YIQX4+Wgts5XJwtvlz5NMs23g7+R/4TkAmv3DRycw= +go.datum.net/network-services-operator v0.27.0 h1:LYCUjc6i0/f3c5YXSgisnbV8gB3R8emDwNfiYapdCAo= +go.datum.net/network-services-operator v0.27.0/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= +go.miloapis.com/ipam v0.4.0 h1:U+mg3RMFXj0c2eQ0Lm15CI0bYBPFDqy8IsGLN8v/eGs= +go.miloapis.com/ipam v0.4.0/go.mod h1:Jj7xg4lJi9psE0+4PuOg/GQOG8rG13h112xYoM994rc= +go.miloapis.com/locations v0.0.1 h1:voJKqBzyLX5x96M3+5y/ga7fgq9/+vypTizd+bBvqUY= +go.miloapis.com/locations v0.0.1/go.mod h1:gzfAfHhSMwl/N68k/uSNYXOKK3IOBJCdXYaBgCE3gdE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= @@ -309,6 +315,8 @@ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0x k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= +sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/knftables v0.0.18 h1:6Duvmu0s/HwGifKrtl6G3AyAPYlWiZqTgS8bkVMiyaE= diff --git a/hack/returnpath-lab/main.go b/hack/returnpath-lab/main.go index e71e1df7..78f8d243 100644 --- a/hack/returnpath-lab/main.go +++ b/hack/returnpath-lab/main.go @@ -47,6 +47,7 @@ import ( "github.com/containernetworking/plugins/pkg/ns" "github.com/vishvananda/netlink" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/prog" @@ -54,7 +55,6 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/intf" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) type config struct { diff --git a/internal/cloud/controller/bgpadvertisement_controller.go b/internal/cloud/controller/bgpadvertisement_controller.go new file mode 100644 index 00000000..c116ba8b --- /dev/null +++ b/internal/cloud/controller/bgpadvertisement_controller.go @@ -0,0 +1,167 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" + "go.datum.net/galactic/internal/cloud/galactic" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// IndexVPCAttachmentIdentity indexes a VPCAttachment by the "-" +// pair galactic names its BGPAdvertisement after. +const IndexVPCAttachmentIdentity = "status.identity" + +// BGPAdvertisementReconciler projects what the data plane published onto the +// Datum API. galactic-router sets Advertised from live GoBGP runtime state, so +// consuming its BGPAdvertisement keeps Datum types off the CNI ADD path and +// keeps galactic free of Datum APIs. +type BGPAdvertisementReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=bgpadvertisements;bgprouters,verbs=get;list;watch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcattachments/status,verbs=get;update;patch + +func (r *BGPAdvertisementReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var advertisement bgpv1alpha1.BGPAdvertisement + if err := r.Get(ctx, req.NamespacedName, &advertisement); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + vpc, attachmentID, ok := galactic.SplitAdvertisementName(advertisement.Name) + if !ok { + return ctrl.Result{}, nil + } + + var attachments cloudv1alpha1.VPCAttachmentList + if err := r.List(ctx, &attachments, + client.MatchingFields{IndexVPCAttachmentIdentity: galactic.AdvertisementName(vpc, attachmentID)}); err != nil { + return ctrl.Result{}, fmt.Errorf("list VPC attachments for advertisement %s: %w", advertisement.Name, err) + } + if len(attachments.Items) == 0 { + return ctrl.Result{}, nil + } + + node, err := r.nodeForRouter(ctx, advertisement.Namespace, advertisement.Spec.RouterRef.Name) + if err != nil { + return ctrl.Result{}, err + } + programmed := programmedCondition(&advertisement) + subnets := galactic.AllocatedSubnets(advertisement.Annotations) + + for i := range attachments.Items { + attachment := &attachments.Items[i] + attachment.Status.Node = node + attachment.Status.HostInterface = galactic.HostInterfaceName(vpc, attachmentID) + attachment.Status.VRFInterface = galactic.VRFInterfaceName(vpc) + if len(subnets) == 1 { + // One live pod per interface at a time, so a single recorded subnet + // is this attachment's; several means a container is still being + // collected and neither is unambiguously current. + attachment.Status.PodSubnet = subnets[0] + } + meta.SetStatusCondition(&attachment.Status.Conditions, programmed) + if err := r.Status().Update(ctx, attachment); err != nil { + return ctrl.Result{}, fmt.Errorf("update VPC attachment %s status: %w", + client.ObjectKeyFromObject(attachment), err) + } + if err := r.projectOntoInterface(ctx, attachment, programmed); err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// projectOntoInterface closes the Programmed condition NSO deliberately leaves +// for whoever realizes the interface. +func (r *BGPAdvertisementReconciler) projectOntoInterface( + ctx context.Context, attachment *cloudv1alpha1.VPCAttachment, programmed metav1.Condition, +) error { + if attachment.Spec.InterfaceRef == nil { + return nil + } + var networkInterface networkingv1alpha.NetworkInterface + key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Spec.InterfaceRef.Name} + if err := r.Get(ctx, key, &networkInterface); err != nil { + return client.IgnoreNotFound(err) + } + + interfaceProgrammed := programmed + interfaceProgrammed.Type = networkingv1alpha.NetworkInterfaceProgrammed + interfaceProgrammed.ObservedGeneration = networkInterface.Generation + meta.SetStatusCondition(&networkInterface.Status.Conditions, interfaceProgrammed) + if err := r.Status().Update(ctx, &networkInterface); err != nil { + return fmt.Errorf("update network interface %s status: %w", key, err) + } + return nil +} + +// nodeForRouter resolves the node a BGPRouter executes on. +func (r *BGPAdvertisementReconciler) nodeForRouter(ctx context.Context, namespace, name string) (string, error) { + if name == "" { + return "", nil + } + var router bgpv1alpha1.BGPRouter + if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &router); err != nil { + return "", client.IgnoreNotFound(err) + } + return router.Spec.TargetRef.Name, nil +} + +// programmedCondition translates the data plane's Advertised condition. +func programmedCondition(advertisement *bgpv1alpha1.BGPAdvertisement) metav1.Condition { + advertised := meta.FindStatusCondition(advertisement.Status.Conditions, galactic.ConditionAdvertised) + condition := metav1.Condition{ + Type: cloudv1alpha1.ConditionTypeProgrammed, + Status: metav1.ConditionUnknown, + Reason: "AwaitingDataPlane", + Message: "the data plane has not reported on this attachment yet", + } + if advertised != nil { + condition.Status = advertised.Status + condition.Reason = advertised.Reason + condition.Message = advertised.Message + } + if advertised != nil && advertised.Status == metav1.ConditionTrue && + advertisement.Annotations[galactic.AnnotationNoAddressing] == labelTrue { + condition.Message = "attachment advertised; the guest manages its own addressing" + } + return condition +} + +// SetupWithManager registers the reconciler with the manager. +func (r *BGPAdvertisementReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bgpv1alpha1.BGPAdvertisement{}). + Named("bgpadvertisement"). + Complete(r) +} diff --git a/internal/cloud/controller/doc.go b/internal/cloud/controller/doc.go new file mode 100644 index 00000000..16075276 --- /dev/null +++ b/internal/cloud/controller/doc.go @@ -0,0 +1,28 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package controller reconciles VPC and VPCAttachment in a POP cell: it turns a +// NetworkContext into a VPC identity, creates the attachment and the +// NetworkAttachmentDefinition when a NetworkInterface claim is fulfilled, +// reports Prepared, and projects what the galactic data plane published back +// onto the Datum API. +package controller + +// Leader election is what serializes identifier allocation, so the lease and +// event permissions below are load-bearing rather than boilerplate. +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch diff --git a/internal/cloud/controller/networkcontext_controller.go b/internal/cloud/controller/networkcontext_controller.go new file mode 100644 index 00000000..812ee561 --- /dev/null +++ b/internal/cloud/controller/networkcontext_controller.go @@ -0,0 +1,251 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "fmt" + "slices" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/identifier" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const reasonAwaitingFabricIdentity = "AwaitingFabricIdentity" + +// NetworkContextReconciler gives a network's presence in one location its +// data-plane identity: one VPC per NetworkContext, carrying the base62 VPC +// identifier the whole galactic fabric keys on. +type NetworkContextReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts,verbs=get;list;watch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=networkfabricidentities,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs/status,verbs=get;update;patch + +func (r *NetworkContextReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var networkContext networkingv1alpha.NetworkContext + if err := r.Get(ctx, req.NamespacedName, &networkContext); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !networkContext.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + networks, err := r.networksForContext(ctx, &networkContext) + if err != nil { + return ctrl.Result{}, err + } + if len(networks) == 0 { + // The VPC address space comes from the Subnets IPAM allocated for this + // location, and VPCSpec is immutable once written. + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + vpc := &cloudv1alpha1.VPC{ + ObjectMeta: metav1.ObjectMeta{ + Name: networkContext.Name, + Namespace: networkContext.Namespace, + }, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, vpc, func() error { + if vpc.CreationTimestamp.IsZero() { + vpc.Spec.Networks = networks + } + return controllerutil.SetControllerReference(&networkContext, vpc, r.Scheme) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("reconcile VPC %s: %w", vpc.Name, err) + } + + // Everything below is conditioned on the VPC not having an identifier yet. A + // VPC that already has one keeps it: the VRF device is named from it and the + // Route Target derives from it, so renumbering a live VPC would rename its + // interface and change its routes under running traffic. + if vpc.Status.VPC == "" { + identity, found, err := r.fabricIdentity(ctx, &networkContext) + if err != nil { + return ctrl.Result{}, err + } + if !found { + return ctrl.Result{}, r.markAwaitingFabricIdentity(ctx, vpc) + } + encoded, err := identifier.VPCBase62(uint64(identity)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("encode fabric identity %d for VPC %s: %w", identity, vpc.Name, err) + } + vpc.Status.VPC = encoded + } + vpc.Status.ObservedGeneration = vpc.Generation + meta.SetStatusCondition(&vpc.Status.Conditions, metav1.Condition{ + Type: cloudv1alpha1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "IdentifierAllocated", + Message: fmt.Sprintf("VPC identifier %s allocated", vpc.Status.VPC), + ObservedGeneration: vpc.Generation, + }) + if err := r.Status().Update(ctx, vpc); err != nil { + return ctrl.Result{}, fmt.Errorf("update VPC %s status: %w", vpc.Name, err) + } + + return ctrl.Result{}, nil +} + +// fabricIdentity reads the identity carried to this cell for the context's +// network. It is one object per network, named after the network, in the +// network's namespace. A missing one is an ordinary answer, not a failure. +// +// The identity is allocated centrally, once for the whole network, and carried +// to each cell the network reaches. Deriving the VPC identifier from it is what +// makes two locations of one network the same network on the fabric; drawing a +// random value per location, which is what this used to do, made them two. +// +// A VPC whose identity has not arrived waits, and there is deliberately nothing +// else it can do. The identifier is immutable, so a random value taken while +// waiting is permanent, and a network whose locations then disagree is worse +// than one with no VPC at all: a NetworkService spanning them binds no VRF and +// fails every request, including through healthy members. Waiting ends the +// moment the identity lands. A random draw never ends. +func (r *NetworkContextReconciler) fabricIdentity( + ctx context.Context, networkContext *networkingv1alpha.NetworkContext, +) (int64, bool, error) { + networkName := networkContext.Spec.Network.Name + if networkName == "" { + return 0, false, nil + } + + var identity cloudv1alpha1.NetworkFabricIdentity + key := client.ObjectKey{Namespace: networkContext.Namespace, Name: networkName} + if err := r.Get(ctx, key, &identity); err != nil { + if apierrors.IsNotFound(err) { + return 0, false, nil + } + return 0, false, fmt.Errorf("read the fabric identity for network %q: %w", networkName, err) + } + if identity.Spec.Identity == 0 { + return 0, false, nil + } + return identity.Spec.Identity, true, nil +} + +// markAwaitingFabricIdentity says out loud that the VPC has no identifier yet +// and why, so a network stuck waiting on a central allocation is visible as +// that rather than as a VPC that silently never became ready. +func (r *NetworkContextReconciler) markAwaitingFabricIdentity( + ctx context.Context, vpc *cloudv1alpha1.VPC, +) error { + vpc.Status.ObservedGeneration = vpc.Generation + meta.SetStatusCondition(&vpc.Status.Conditions, metav1.Condition{ + Type: cloudv1alpha1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: reasonAwaitingFabricIdentity, + Message: "Waiting for the identity the fabric knows this network by", + ObservedGeneration: vpc.Generation, + }) + if err := r.Status().Update(ctx, vpc); err != nil { + return fmt.Errorf("update VPC %s status: %w", vpc.Name, err) + } + return nil +} + +// networksForContext collects the CIDRs IPAM allocated for this location. +func (r *NetworkContextReconciler) networksForContext( + ctx context.Context, networkContext *networkingv1alpha.NetworkContext, +) ([]cloudv1alpha1.Network, error) { + var subnets networkingv1alpha.SubnetList + if err := r.List(ctx, &subnets, client.InNamespace(networkContext.Namespace)); err != nil { + return nil, fmt.Errorf("list subnets: %w", err) + } + + networks := make([]cloudv1alpha1.Network, 0, len(subnets.Items)) + for _, subnet := range subnets.Items { + if subnet.Spec.NetworkContext.Name != networkContext.Name { + continue + } + start, prefixLength, ok := subnetRange(&subnet) + if !ok { + continue + } + networks = append(networks, cloudv1alpha1.Network( + fmt.Sprintf("%s/%d", start, prefixLength))) + } + slices.Sort(networks) + return networks, nil +} + +// subnetRange reads a subnet's allocated range, preferring status and falling +// back to spec. A location's copy arrives by propagation, which carries spec +// and never status. +func subnetRange(subnet *networkingv1alpha.Subnet) (string, int32, bool) { + if subnet.Status.StartAddress != nil && subnet.Status.PrefixLength != nil { + return *subnet.Status.StartAddress, *subnet.Status.PrefixLength, true + } + if subnet.Spec.StartAddress != "" && subnet.Spec.PrefixLength != 0 { + return subnet.Spec.StartAddress, subnet.Spec.PrefixLength, true + } + return "", 0, false +} + +// SetupWithManager registers the reconciler with the manager. +func (r *NetworkContextReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha.NetworkContext{}). + Owns(&cloudv1alpha1.VPC{}). + Watches(&cloudv1alpha1.NetworkFabricIdentity{}, + handler.EnqueueRequestsFromMapFunc(r.contextsForFabricIdentity)). + Named("networkcontext"). + Complete(r) +} + +// contextsForFabricIdentity wakes a network's presences the moment its identity +// reaches this cell, so a VPC waiting on one takes it immediately instead of +// sitting out its poll interval. +func (r *NetworkContextReconciler) contextsForFabricIdentity( + ctx context.Context, object client.Object, +) []reconcile.Request { + var contexts networkingv1alpha.NetworkContextList + if err := r.List(ctx, &contexts, client.InNamespace(object.GetNamespace())); err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(contexts.Items)) + for i := range contexts.Items { + if contexts.Items[i].Spec.Network.Name != object.GetName() { + continue + } + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&contexts.Items[i]), + }) + } + return requests +} diff --git a/internal/cloud/controller/networkcontext_controller_test.go b/internal/cloud/controller/networkcontext_controller_test.go new file mode 100644 index 00000000..98ee196f --- /dev/null +++ b/internal/cloud/controller/networkcontext_controller_test.go @@ -0,0 +1,244 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// A location's Subnet arrives by propagation, which carries spec and never +// status, so the allocated range has to be readable from either. +func TestSubnetRangePrefersStatusAndFallsBackToSpec(t *testing.T) { + start := "fd00::" + var length int32 = 48 + + withStatus := &networkingv1alpha.Subnet{ + Spec: networkingv1alpha.SubnetSpec{StartAddress: "fd20::", PrefixLength: 64}, + Status: networkingv1alpha.SubnetStatus{StartAddress: &start, PrefixLength: &length}, + } + gotStart, gotLength, ok := subnetRange(withStatus) + if !ok || gotStart != "fd00::" || gotLength != 48 { + t.Fatalf("status should win when present, got %q/%d ok=%v", gotStart, gotLength, ok) + } + + specOnly := &networkingv1alpha.Subnet{ + Spec: networkingv1alpha.SubnetSpec{StartAddress: "fd20::", PrefixLength: 64}, + } + gotStart, gotLength, ok = subnetRange(specOnly) + if !ok || gotStart != "fd20::" || gotLength != 64 { + t.Fatalf("a propagated copy carries spec only, got %q/%d ok=%v", gotStart, gotLength, ok) + } + + if _, _, ok := subnetRange(&networkingv1alpha.Subnet{}); ok { + t.Fatal("an unallocated subnet should yield nothing") + } +} + +const ( + vpcTestNamespace = "ns-project" + vpcTestNetwork = "prod" + vpcTestLocation = "us-central-1" + + // The identity the fabric knows the test network by, and the identifier a + // VPC derives from it. + vpcTestIdentity = 16 + vpcTestIdentifier = "g" +) + +type vpcFixture struct { + t *testing.T + ctx context.Context + client client.Client + reconciler *NetworkContextReconciler +} + +// newVPCFixture stands up one network present at one location, with the subnet +// that gives the VPC an address space to be created for. +func newVPCFixture(t *testing.T, objects ...client.Object) *vpcFixture { + t.Helper() + + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatalf("build the networking scheme: %v", err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("build the cloud scheme: %v", err) + } + + presence := &networkingv1alpha.NetworkContext{} + presence.Namespace = vpcTestNamespace + presence.Name = vpcTestNetwork + "-" + vpcTestLocation + presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: vpcTestNetwork} + presence.Spec.Location = networkingv1alpha.LocationReference{Name: vpcTestLocation} + + subnet := &networkingv1alpha.Subnet{} + subnet.Namespace = vpcTestNamespace + subnet.Name = presence.Name + subnet.Spec.NetworkContext = networkingv1alpha.LocalNetworkContextRef{Name: presence.Name} + subnet.Spec.StartAddress = "fd00::" + subnet.Spec.PrefixLength = 48 + + all := append([]client.Object{presence, subnet}, objects...) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&cloudv1alpha1.VPC{}). + WithObjects(all...).Build() + + return &vpcFixture{ + t: t, + ctx: context.Background(), + client: fakeClient, + reconciler: &NetworkContextReconciler{Client: fakeClient, Scheme: scheme}, + } +} + +func (f *vpcFixture) reconcile() ctrl.Result { + f.t.Helper() + result, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{NamespacedName: types.NamespacedName{ + Namespace: vpcTestNamespace, + Name: vpcTestNetwork + "-" + vpcTestLocation, + }}) + if err != nil { + f.t.Fatalf("reconcile: %v", err) + } + return result +} + +func (f *vpcFixture) vpc() *cloudv1alpha1.VPC { + f.t.Helper() + vpc := &cloudv1alpha1.VPC{} + key := types.NamespacedName{Namespace: vpcTestNamespace, Name: vpcTestNetwork + "-" + vpcTestLocation} + if err := f.client.Get(f.ctx, key, vpc); err != nil { + f.t.Fatalf("read the VPC: %v", err) + } + return vpc +} + +func fabricIdentityObject() *cloudv1alpha1.NetworkFabricIdentity { + object := &cloudv1alpha1.NetworkFabricIdentity{} + object.Namespace = vpcTestNamespace + object.Name = vpcTestNetwork + object.Spec.Identity = vpcTestIdentity + object.Spec.NetworkRef = cloudv1alpha1.NetworkFabricIdentityNetworkRef{Name: vpcTestNetwork} + return object +} + +// The whole point: every location of one network reaches the same identifier, +// because every one of them reads the same centrally allocated identity. +func TestVPCTakesItsIdentifierFromTheNetworksFabricIdentity(t *testing.T) { + fixture := newVPCFixture(t, fabricIdentityObject()) + + fixture.reconcile() + + vpc := fixture.vpc() + if vpc.Status.VPC != vpcTestIdentifier { + t.Fatalf("VPC identifier: got %q, want %q", vpc.Status.VPC, vpcTestIdentifier) + } + if condition := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady); condition == nil || + condition.Status != metav1.ConditionTrue { + t.Fatalf("a VPC with an identifier should be ready, got %+v", condition) + } +} + +// A network whose identity has not reached this cell yet waits, and writes +// nothing. The identifier is immutable, so a value taken while waiting is +// permanent, and locations that disagree bind no VRF at all. +func TestVPCWaitsForAnIdentityThatHasNotArrived(t *testing.T) { + fixture := newVPCFixture(t) + + if result := fixture.reconcile(); result.RequeueAfter != 0 { + t.Fatalf("the identity's arrival is the trigger, not a poll, got %v", result) + } + + vpc := fixture.vpc() + if vpc.Status.VPC != "" { + t.Fatalf("no identifier should be written while waiting, got %q", vpc.Status.VPC) + } + condition := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady) + if condition == nil || condition.Status != metav1.ConditionFalse || + condition.Reason != "AwaitingFabricIdentity" { + t.Fatalf("the wait should be visible on the VPC, got %+v", condition) + } +} + +// The identity landing is what ends the wait, and the identifier that follows +// is the derived one rather than a random draw. +func TestVPCTakesTheIdentityOnceItArrives(t *testing.T) { + fixture := newVPCFixture(t) + + fixture.reconcile() + if got := fixture.vpc().Status.VPC; got != "" { + t.Fatalf("no identifier should be written while waiting, got %q", got) + } + + if err := fixture.client.Create(fixture.ctx, fabricIdentityObject()); err != nil { + t.Fatalf("publish the identity: %v", err) + } + fixture.reconcile() + + if got := fixture.vpc().Status.VPC; got != vpcTestIdentifier { + t.Fatalf("VPC identifier: got %q, want %q", got, vpcTestIdentifier) + } +} + +// Renumbering a live VPC would rename its VRF device and change its Route +// Target under running traffic, so an identifier already written stays written +// even when it disagrees with the identity that later arrived. +func TestVPCKeepsAnIdentifierItAlreadyHas(t *testing.T) { + existing := &cloudv1alpha1.VPC{} + existing.Namespace = vpcTestNamespace + existing.Name = vpcTestNetwork + "-" + vpcTestLocation + existing.Status.VPC = "R2POk4jT" + + fixture := newVPCFixture(t, existing, fabricIdentityObject()) + + fixture.reconcile() + + if got := fixture.vpc().Status.VPC; got != "R2POk4jT" { + t.Fatalf("an allocated VPC must keep its identifier, got %q", got) + } +} + +// An identity reaching the cell has to wake the presences waiting on it, or a +// VPC would sit out its poll interval for a value already there. +func TestFabricIdentityWakesTheNetworksPresences(t *testing.T) { + fixture := newVPCFixture(t) + + requests := fixture.reconciler.contextsForFabricIdentity(fixture.ctx, fabricIdentityObject()) + + if len(requests) != 1 || requests[0].Name != vpcTestNetwork+"-"+vpcTestLocation { + t.Fatalf("the network's presence should be enqueued, got %v", requests) + } + + other := fabricIdentityObject() + other.Name = "unrelated" + if requests := fixture.reconciler.contextsForFabricIdentity(fixture.ctx, other); len(requests) != 0 { + t.Fatalf("another network's identity should enqueue nothing, got %v", requests) + } +} diff --git a/internal/cloud/controller/networkfabricidentity_controller.go b/internal/cloud/controller/networkfabricidentity_controller.go new file mode 100644 index 00000000..fc87309b --- /dev/null +++ b/internal/cloud/controller/networkfabricidentity_controller.go @@ -0,0 +1,553 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/fabricidentity" + "go.datum.net/galactic/internal/cloud/ipam" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + labelTrue = "true" + fabricIdentityKind = "NetworkFabricIdentity" + policySpecField = "spec" + policyResourceSelectorsField = "resourceSelectors" + policyAPIVersionField = "apiVersion" + policyKindField = "kind" + policyLabelSelectorField = "labelSelector" + // fabricIdentityFieldManager owns every placement policy this writes. + fabricIdentityFieldManager = "cloud-fabric-identity" + + // FabricIdentityPolicyLabel marks the placement policies this controller + // owns, so nothing else is ever rewritten or removed by it. + FabricIdentityPolicyLabel = "galactic.datumapis.com/fabric-identity-policy" + + // servingLocationTopologyLabel is the cluster label a cell carries to claim + // the location it serves. Placement selects on it. + servingLocationTopologyLabel = "topology.datum.net/location" + + // fabricIdentityLocationLabelPrefix records that an identity is required at + // one location, with the location in the key rather than the value: a label + // key holds one value, and one network is required in several locations at + // once. Placement no longer selects on it -- see place -- but it stays as + // the readable record of where an identity is needed. + fabricIdentityLocationLabelPrefix = "galactic.datumapis.com/location-" +) + +// LocationLabel is the label marking an identity as required at one location. +func LocationLabel(location string) string { + return fabricIdentityLocationLabelPrefix + location +} + +var clusterPropagationPolicyGVK = schema.GroupVersionKind{ + Group: "policy.karmada.io", + Version: "v1alpha1", + Kind: "ClusterPropagationPolicy", +} + +// NetworkFabricIdentityReconciler gives each network the identity the fabric +// knows it by, once, and carries it to the locations that need it. +// +// It runs centrally, not in a cell. A network spans locations, so the one thing +// that must be the same in all of them cannot be decided in any one of them — +// which is exactly the defect this replaces, where each location drew its own +// value and two locations of one network were two networks on the fabric. +type NetworkFabricIdentityReconciler struct { + // Networks reads the Networks identities are allocated for, and the + // NetworkContexts saying where each one is required. + // + // Both arrive on the hub as copies published by the operator that owns + // them, because reaching every consumer's project control plane needs a + // multi-cluster provider this component does not have. A copy is a + // different object from its source and does not carry the source's UID, so + // nothing here reads one. + // + // Kept separate from Hub even though a deployment points both at the hub: + // it is what lets a failed read be exercised on its own, and every + // withdrawal in this controller is conditioned on a read having succeeded. + Networks client.Client + + // Hub is where the identity and its placement are written, and from where + // federation carries them to the cells. + Hub client.Client + + // HubCluster is the hub as a cluster rather than a client, and is what the + // watches are sourced from. + // + // The manager itself runs against the cluster this is scheduled on, not + // against the hub. Leases on the hub would put a constant write load on a + // control plane everything else federates through, and would tie this + // component's leadership to that plane being reachable: a hub hiccup would + // churn leadership and restart the controller. Keeping the lease local + // means an unreachable hub costs this component its work, which cannot be + // avoided, and not its identity, which can. + HubCluster cluster.Cluster + + // IPAM reaches the identifier space. + IPAM ipam.ClientFactory + + // IdentityClass is the IPClass that hands out identities. + IdentityClass string + + // IdentityNamespace is the namespace in the platform's own tenancy that + // identity claims are written to. + IdentityNamespace string +} + +// Reconcile is keyed by a network. The network decides whether the identity +// exists at all; its NetworkContexts decide only where that identity is +// carried. +// +// Reading the network is what separates "deleted" from "required nowhere". A +// network that exists but has no context anywhere keeps its identity: it is a +// network the consumer still has, and the next context to appear must find the +// value the fabric already knows it by rather than wait for one to be drawn +// again. +func (r *NetworkFabricIdentityReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + network := &networkingv1alpha.Network{} + err := r.Networks.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, network) + switch { + case apierrors.IsNotFound(err): + // The one thing that collects an identity. + return ctrl.Result{}, r.collect(ctx, req.Namespace, req.Name) + case err != nil: + // A read that failed says nothing about whether the network is still + // there. Changing nothing is the only safe answer. + return ctrl.Result{}, err + } + + // A network on its way out still has an identity, and deliberately keeps it + // until it is actually gone. The VRF device is named from the identity and + // the Route Target derives from it, so tearing a network down needs it as + // much as standing it up did; collecting on the deletion timestamp would + // pull it out from under a teardown still in flight. The cost is that a + // network wedged on a stuck finalizer holds its object indefinitely, which + // is a leak, and a leak is recoverable where a torn-down data plane is not. + + presences, err := r.presences(ctx, req.Namespace, req.Name) + if err != nil { + // A list that failed says nothing about where the network is required. + return ctrl.Result{}, err + } + + locations := locationsFrom(presences) + + if err := r.publish(ctx, req.Namespace, req.Name, locations); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, r.place(ctx, req.Namespace, req.Name, locations) +} + +// presences reads the contexts declaring the network is required somewhere. +func (r *NetworkFabricIdentityReconciler) presences( + ctx context.Context, + namespace string, + networkName string, +) ([]networkingv1alpha.NetworkContext, error) { + var contexts networkingv1alpha.NetworkContextList + if err := r.Networks.List(ctx, &contexts, client.InNamespace(namespace)); err != nil { + return nil, fmt.Errorf("read the presences of network %q: %w", networkName, err) + } + + matching := make([]networkingv1alpha.NetworkContext, 0, len(contexts.Items)) + for i := range contexts.Items { + presence := &contexts.Items[i] + if presence.Spec.Network.Name != networkName { + continue + } + // A presence on its way out is still a presence. The location keeps the + // identity until the context is actually gone, because the traffic it + // carries is still there while it drains. + matching = append(matching, *presence) + } + return matching, nil +} + +func locationsFrom(presences []networkingv1alpha.NetworkContext) []string { + seen := map[string]struct{}{} + locations := make([]string, 0, len(presences)) + for i := range presences { + location := presences[i].Spec.Location.Name + if location == "" { + continue + } + if _, ok := seen[location]; ok { + continue + } + seen[location] = struct{}{} + locations = append(locations, location) + } + sort.Strings(locations) + return locations +} + +// publish allocates the identity if it does not have one yet, and marks it as +// required at each location the network reaches. +func (r *NetworkFabricIdentityReconciler) publish( + ctx context.Context, + namespace string, + networkName string, + locations []string, +) error { + key := client.ObjectKey{Namespace: namespace, Name: networkName} + + published := &cloudv1alpha1.NetworkFabricIdentity{} + err := r.Hub.Get(ctx, key, published) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("read the published identity for network %q: %w", networkName, err) + } + + identity := published.Spec.Identity + if identity == 0 { + identity, err = r.claim(ctx, namespace, networkName) + if err != nil { + return err + } + } + + object := &cloudv1alpha1.NetworkFabricIdentity{} + object.Namespace = key.Namespace + object.Name = key.Name + + if _, err := controllerutil.CreateOrUpdate(ctx, r.Hub, object, func() error { + // Never moved once set. A network that changed identity would be a + // different network to every location already carrying its traffic, and + // the API refuses the write in any case. + if object.Spec.Identity == 0 { + object.Spec.Identity = identity + } + object.Spec.NetworkRef = cloudv1alpha1.NetworkFabricIdentityNetworkRef{Name: networkName} + setLocationLabels(object, locations) + return nil + }); err != nil { + return fmt.Errorf("publish the fabric identity for network %q: %w", networkName, err) + } + return nil +} + +// setLocationLabels marks the identity as required at exactly these locations. +// +// A label is dropped only because a location is absent from a set built from a +// successful read of the presences. Every failure returns before reaching here, +// so a context that was briefly unreadable, or a cell that went quiet, never +// withdraws an identity. That matters because the fabric keys a VRF on it: +// withdrawing it under live traffic tears the data plane down at that location. +func setLocationLabels(object *cloudv1alpha1.NetworkFabricIdentity, locations []string) { + if object.Labels == nil { + object.Labels = map[string]string{} + } + required := make(map[string]struct{}, len(locations)) + for _, location := range locations { + key := LocationLabel(location) + required[key] = struct{}{} + object.Labels[key] = labelTrue + } + for key := range object.Labels { + if !strings.HasPrefix(key, fabricIdentityLocationLabelPrefix) { + continue + } + if _, ok := required[key]; !ok { + delete(object.Labels, key) + } + } +} + +func (r *NetworkFabricIdentityReconciler) claim( + ctx context.Context, + networkNamespace string, + networkName string, +) (int64, error) { + ipamClient, err := r.IPAM.ClientForPlatform() + if err != nil { + return 0, fmt.Errorf("reach the platform identifier space: %w", err) + } + + identity, err := fabricidentity.Claim(ctx, ipamClient, fabricidentity.Request{ + ClassName: r.IdentityClass, + Namespace: r.IdentityNamespace, + NetworkNamespace: networkNamespace, + NetworkName: networkName, + }) + if err != nil { + // An unusable answer is a wait on an operator, not on the service: + // retrying reaches the same block. Fail closed either way — a network + // given an ambiguous identity is worse than one given none. + var unusable *fabricidentity.UnusableError + if errors.As(err, &unusable) { + log.FromContext(ctx).Error(err, "the identifier space handed out a block no identity can be read from", + "network", networkName) + } + return 0, fmt.Errorf("allocate a fabric identity for network %q: %w", networkName, err) + } + return identity, nil +} + +// collect removes the published identity once the network it belongs to is +// gone. +// +// The IPAM claim is deliberately not released. A Route Target still installed +// in a remote location's import policy would silently merge a new network into +// a dead one's routes, so the allocation is retained forever and the identity +// is never reissued. Only the object goes; the per-location policies are shared +// and stay. +func (r *NetworkFabricIdentityReconciler) collect( + ctx context.Context, + namespace string, + networkName string, +) error { + object := &cloudv1alpha1.NetworkFabricIdentity{} + object.Namespace = namespace + object.Name = networkName + + if err := r.Hub.Delete(ctx, object); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("collect the fabric identity for network %q: %w", networkName, err) + } + // The policy names this one identity, so nothing else can be left holding + // it and it would otherwise outlive the network forever. + return r.unplace(ctx, namespace, networkName) +} + +// placeLocations keeps one policy per location, not one per network. +// +// A policy per network would put the policy count on the order of the number of +// networks, and federation evaluates its policy set against candidate +// resources, so that cost is paid by everything else propagating through the +// same hub rather than by this alone. One policy per location selects every +// identity required there by label, so the object count stays per network while +// the policy count falls to the number of locations. +// +// Placing it fleet-wide is not an option: the identity is capability-like, and +// what holds it can name a network's forwarding state. +// place carries one identity to every location it is required at, with a +// single policy that names that one object. +// +// One policy per identity, not one per location: Karmada binds a resource to +// exactly one policy, so per-location policies all selecting the same identity +// by label compete for it, and only the winner's placement takes effect. An +// identity required in two locations then reaches one of them, silently, which +// is exactly the split this whole mechanism exists to prevent. +func (r *NetworkFabricIdentityReconciler) place( + ctx context.Context, + namespace string, + networkName string, + locations []string, +) error { + if len(locations) == 0 { + return r.unplace(ctx, namespace, networkName) + } + + policy := &unstructured.Unstructured{Object: map[string]any{ + policySpecField: map[string]any{ + "conflictResolution": "Overwrite", + policyResourceSelectorsField: []any{ + map[string]any{ + policyAPIVersionField: cloudv1alpha1.GroupVersion.String(), + policyKindField: fabricIdentityKind, + "namespace": namespace, + "name": networkName, + }, + }, + "placement": map[string]any{ + "clusterAffinity": map[string]any{ + policyLabelSelectorField: map[string]any{ + "matchExpressions": []any{ + map[string]any{ + "key": servingLocationTopologyLabel, + "operator": "In", + "values": locationValues(locations), + }, + }, + }, + }, + }, + }, + }} + policy.SetGroupVersionKind(clusterPropagationPolicyGVK) + policy.SetName(FabricIdentityPolicyName(namespace, networkName)) + policy.SetLabels(map[string]string{FabricIdentityPolicyLabel: labelTrue}) + + if err := r.Hub.Patch(ctx, policy, client.Apply, //nolint:staticcheck // SA1019: the typed Apply API needs a generated ApplyConfiguration this unstructured policy has none of + client.FieldOwner(fabricIdentityFieldManager), client.ForceOwnership); err != nil { + return fmt.Errorf("place the fabric identity for network %q: %w", networkName, err) + } + return nil +} + +// unplace removes the policy for an identity required nowhere. The identity +// itself stays: the next context to appear must find the value the fabric +// already knows the network by. +func (r *NetworkFabricIdentityReconciler) unplace(ctx context.Context, namespace, networkName string) error { + policy := &unstructured.Unstructured{} + policy.SetGroupVersionKind(clusterPropagationPolicyGVK) + policy.SetName(FabricIdentityPolicyName(namespace, networkName)) + + if err := r.Hub.Delete(ctx, policy); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("unplace the fabric identity for network %q: %w", networkName, err) + } + return nil +} + +// sweepLegacyPlacements removes the per-location policies this controller used +// to write. They select identities by label, so they keep competing with the +// per-identity policies that replaced them for as long as they exist, and a +// resource Karmada binds to the wrong one reaches the wrong cells. +// +// Identified by shape rather than by name: a policy this controller owns whose +// selectors carry no resource name is selecting by label, which only the old +// form did. +// It is deliberately best-effort and never returns an error. It runs as a +// manager runnable, where an error stops the manager, and a leftover policy is +// a correctness problem worth logging loudly where a controller that will not +// start reconciles nothing at all. +func (r *NetworkFabricIdentityReconciler) sweepLegacyPlacements(ctx context.Context) error { + logger := ctrl.LoggerFrom(ctx) + + var policies unstructured.UnstructuredList + policies.SetGroupVersionKind(clusterPropagationPolicyGVK.GroupVersion().WithKind("ClusterPropagationPolicyList")) + if err := r.Hub.List(ctx, &policies, client.MatchingLabels{FabricIdentityPolicyLabel: labelTrue}); err != nil { + logger.Error(err, "could not read the fabric identity placement policies to sweep") + return nil + } + + for i := range policies.Items { + policy := &policies.Items[i] + if !selectsByLabel(policy) { + continue + } + if err := r.Hub.Delete(ctx, policy); err != nil && !apierrors.IsNotFound(err) { + logger.Error(err, "could not remove a legacy per-location placement policy, it will keep competing for identities", + "policy", policy.GetName()) + continue + } + logger.Info("removed a legacy per-location placement policy", "policy", policy.GetName()) + } + return nil +} + +// selectsByLabel reports whether a policy selects resources without naming one. +func selectsByLabel(policy *unstructured.Unstructured) bool { + selectors, found, err := unstructured.NestedSlice(policy.Object, policySpecField, policyResourceSelectorsField) + if err != nil || !found || len(selectors) == 0 { + return false + } + for _, entry := range selectors { + selector, ok := entry.(map[string]any) + if !ok { + return false + } + if name, _, _ := unstructured.NestedString(selector, "name"); name == "" { + return true + } + } + return false +} + +// locationValues is locations as the []any an unstructured policy needs. +func locationValues(locations []string) []any { + values := make([]any, 0, len(locations)) + for _, location := range locations { + values = append(values, location) + } + return values +} + +func FabricIdentityPolicyName(namespace, networkName string) string { + return "cloud-fabric-identity-" + namespace + "-" + networkName +} + +// The manager runs locally, so what this ServiceAccount has to be able to do is +// hold a leader election lease and record events. Everything else is read and +// written on the hub under the federation credential, and is declared here +// because this role is also what an operator binds there. +// +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=create;delete;get;list;patch;update;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networks;networkcontexts,verbs=get;list;watch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=networkfabricidentities,verbs=create;delete;get;list;patch;update;watch +// +kubebuilder:rbac:groups=policy.karmada.io,resources=clusterpropagationpolicies,verbs=create;delete;get;list;patch;update;watch + +// SetupWithManager registers the reconciler. +// +// The network is the primary object: it decides whether an identity exists. +// Contexts still have to wake it, because placement follows them and nothing +// else would carry the identity to a location the network has just reached. +// +// Every request is keyed by a network, never by a context, because one +// network's identity is decided from all of its contexts at once. +// +// Both watches are sourced from the hub's cache rather than the manager's own, +// because that is where the copies live. The manager's cluster holds only the +// lease. +func (r *NetworkFabricIdentityReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.IdentityClass == "" { + return errors.New("an identifier class is required") + } + if r.IPAM == nil { + return errors.New("an identifier space is required") + } + if r.HubCluster == nil { + return errors.New("a hub to watch is required") + } + + hubCache := r.HubCluster.GetCache() + + // Once, on the leader, before anything is placed under the new scheme. + if err := mgr.Add(manager.RunnableFunc(r.sweepLegacyPlacements)); err != nil { + return fmt.Errorf("schedule the legacy placement sweep: %w", err) + } + + return ctrl.NewControllerManagedBy(mgr). + Named("networkfabricidentity"). + WatchesRawSource(source.Kind(hubCache, &networkingv1alpha.Network{}, + &handler.TypedEnqueueRequestForObject[*networkingv1alpha.Network]{})). + WatchesRawSource(source.Kind(hubCache, &networkingv1alpha.NetworkContext{}, + handler.TypedEnqueueRequestsFromMapFunc( + func(_ context.Context, presence *networkingv1alpha.NetworkContext) []reconcile.Request { + if presence == nil || presence.Spec.Network.Name == "" { + return nil + } + return []reconcile.Request{{NamespacedName: types.NamespacedName{ + Namespace: presence.Namespace, + Name: presence.Spec.Network.Name, + }}} + }))). + Complete(r) +} diff --git a/internal/cloud/controller/networkfabricidentity_controller_test.go b/internal/cloud/controller/networkfabricidentity_controller_test.go new file mode 100644 index 00000000..08006a77 --- /dev/null +++ b/internal/cloud/controller/networkfabricidentity_controller_test.go @@ -0,0 +1,901 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + "slices" + "sort" + "strings" + "testing" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + "go.miloapis.com/ipam/pkg/ipamerrors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/fabricidentity" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + testNetworkName = "prod" + testNamespace = "ns-project" + testNetworkUID = "11111111-1111-1111-1111-111111111111" + testClass = "datum-fabric-identity" +) + +// fakeIdentityIPAM stands in for the address service. Allocation is synchronous +// there, so the create response already carries the block. +type fakeIdentityIPAM struct { + client client.Client + // next is the index the pool hands out. A correctly provisioned identifier + // pool holds its own first block back, because a network handed index zero + // reads as one holding no identity at all. + next int + // created records every claim name the factory was asked to bind. + created []string + // retained maps an allocation name to the block it still holds after its + // claim was deleted under Retain. It is what makes a second claim of the + // same name a conflict rather than a fresh allocation. + retained map[string]string +} + +func allocationNameFor(claimName string) string { return "alloc-" + claimName } + +// refuseWhatTheServerWouldRefuse mirrors the address service's own admission of +// a claim. The fake would otherwise bind anything, which is how a claim that no +// real server has ever accepted passed every test here. +// +// Only the part this depends on is modelled: the server bounds a claim's prefix +// length by the family stated on the claim, before it looks at the class, so an +// identity block asked for without a family is read as an IPv4 length. +func refuseWhatTheServerWouldRefuse(ipClaim *ipamv1alpha1.IPClaim) error { + if ipClaim.Spec.ClassName == "" && ipClaim.Spec.IPFamily == "" { + return apierrors.NewInvalid( + ipamv1alpha1.SchemeGroupVersion.WithKind("IPClaim").GroupKind(), ipClaim.Name, + field.ErrorList{field.Required(field.NewPath("spec"), + "one of className or ipFamily is required")}) + } + if p := ipClaim.Spec.PrefixLength; p != nil { + maxLen := int32(32) + if ipClaim.Spec.IPFamily == ipamv1alpha1.IPv6 { + maxLen = 128 + } + if *p <= 0 || *p > maxLen { + return apierrors.NewInvalid( + ipamv1alpha1.SchemeGroupVersion.WithKind("IPClaim").GroupKind(), ipClaim.Name, + field.ErrorList{field.Invalid(field.NewPath("spec", "prefixLength"), *p, + fmt.Sprintf("must be between 1 and %d", maxLen))}) + } + } + return nil +} + +func newFakeIdentityIPAM(t *testing.T) *fakeIdentityIPAM { + t.Helper() + scheme := runtime.NewScheme() + if err := ipamv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("build the IPAM scheme: %v", err) + } + + f := &fakeIdentityIPAM{next: 1, retained: map[string]string{}} + f.client = fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + ipClaim, ok := obj.(*ipamv1alpha1.IPClaim) + if !ok { + return c.Create(ctx, obj, opts...) + } + f.created = append(f.created, ipClaim.Name) + + if err := refuseWhatTheServerWouldRefuse(ipClaim); err != nil { + return err + } + + // An allocation left behind by a deleted claim blocks the name it + // used, which is exactly what retention is for. + allocationName := allocationNameFor(ipClaim.Name) + if _, held := f.retained[allocationName]; held { + return ipamerrors.NewRetainedAllocation( + ipamv1alpha1.SchemeGroupVersion.WithResource("ipclaims").GroupResource(), + ipClaim.Name, allocationName, + "an allocation under this identity already exists, retained by an earlier claim of the same name") + } + + index := f.next + f.next++ + ipClaim.Status.Phase = ipamv1alpha1.ClaimBound + ipClaim.Status.AllocatedCIDR = blockForIndex(index) + return c.Create(ctx, obj, opts...) + }, + }).Build() + return f +} + +// blockForIndex renders the block a pool rooted at fc00::/32 hands out for an +// index, which is what the identity is read back out of. +func blockForIndex(index int) string { + return "fc00:0:" + hex16(index>>16) + ":" + hex16(index&0xffff) + "::/64" +} + +func hex16(v int) string { + const digits = "0123456789abcdef" + if v == 0 { + return "0" + } + out := "" + for v > 0 { + out = string(digits[v&0xf]) + out + v >>= 4 + } + return out +} + +// release models a claim deleted under Retain: the claim goes, the allocation +// stays, and the block it holds becomes readable through the allocation. +func (f *fakeIdentityIPAM) release(t *testing.T, ctx context.Context, claimName string) { + t.Helper() + var ipClaim ipamv1alpha1.IPClaim + if err := f.client.Get(ctx, client.ObjectKey{Namespace: "default", Name: claimName}, &ipClaim); err != nil { + t.Fatalf("read the claim being released: %v", err) + } + + allocation := &ipamv1alpha1.IPAllocation{} + allocation.Namespace = "default" + allocation.Name = allocationNameFor(claimName) + allocation.Status.AllocatedCIDR = ipClaim.Status.AllocatedCIDR + if err := f.client.Create(ctx, allocation); err != nil { + t.Fatalf("retain the allocation: %v", err) + } + if err := f.client.Delete(ctx, &ipClaim); err != nil { + t.Fatalf("delete the claim: %v", err) + } + f.retained[allocation.Name] = ipClaim.Status.AllocatedCIDR +} + +func (f *fakeIdentityIPAM) ClientForPlatform() (client.Client, error) { return f.client, nil } +func (f *fakeIdentityIPAM) ClientForProject(string) (client.Client, error) { + return nil, errors.New("a fabric identity is never drawn from a consumer's project") +} + +type identityFixture struct { + t *testing.T + ctx context.Context + networks client.Client + hub client.Client + ipam *fakeIdentityIPAM + reconciler *NetworkFabricIdentityReconciler + network *networkingv1alpha.Network +} + +func newIdentityFixture(t *testing.T, presences ...string) *identityFixture { + t.Helper() + + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatalf("build the networking scheme: %v", err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("build the cloud scheme: %v", err) + } + scheme.AddKnownTypeWithName(clusterPropagationPolicyGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(clusterPropagationPolicyGVK.GroupVersion().WithKind("ClusterPropagationPolicyList"), + &unstructured.UnstructuredList{}) + + network := &networkingv1alpha.Network{} + network.Namespace = testNamespace + network.Name = testNetworkName + network.UID = types.UID(testNetworkUID) + + objects := make([]client.Object, 0, 1+len(presences)) + objects = append(objects, network) + for _, location := range presences { + presence := &networkingv1alpha.NetworkContext{} + presence.Namespace = testNamespace + presence.Name = testNetworkName + "-" + location + presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: testNetworkName} + presence.Spec.Location = networkingv1alpha.LocationReference{Name: location} + objects = append(objects, presence) + } + + networks := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + hub := fake.NewClientBuilder().WithScheme(scheme).Build() + ipam := newFakeIdentityIPAM(t) + + return &identityFixture{ + t: t, + ctx: context.Background(), + networks: networks, + hub: hub, + ipam: ipam, + reconciler: &NetworkFabricIdentityReconciler{ + Networks: networks, + Hub: hub, + IPAM: ipam, + IdentityClass: testClass, + IdentityNamespace: "default", + }, + network: network, + } +} + +func (f *identityFixture) deletePresence(location string) { + f.t.Helper() + if err := f.networks.Delete(f.ctx, newPresence(location)); err != nil { + f.t.Fatalf("delete the presence: %v", err) + } +} + +func (f *identityFixture) deleteNetwork() { + f.t.Helper() + network := &networkingv1alpha.Network{} + network.Namespace = testNamespace + network.Name = testNetworkName + if err := f.networks.Delete(f.ctx, network); err != nil { + f.t.Fatalf("delete the network: %v", err) + } +} + +// addNetwork declares a second network alongside the fixture's own, with a +// presence in each location named. +func (f *identityFixture) addNetwork(name string, locations ...string) { + f.t.Helper() + network := &networkingv1alpha.Network{} + network.Namespace = testNamespace + network.Name = name + if err := f.networks.Create(f.ctx, network); err != nil { + f.t.Fatalf("create network %q: %v", name, err) + } + for _, location := range locations { + presence := &networkingv1alpha.NetworkContext{} + presence.Namespace = testNamespace + presence.Name = name + "-" + location + presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: name} + presence.Spec.Location = networkingv1alpha.LocationReference{Name: location} + if err := f.networks.Create(f.ctx, presence); err != nil { + f.t.Fatalf("declare the presence of %q in %q: %v", name, location, err) + } + } +} + +func (f *identityFixture) createNetwork() { + f.t.Helper() + network := &networkingv1alpha.Network{} + network.Namespace = testNamespace + network.Name = testNetworkName + if err := f.networks.Create(f.ctx, network); err != nil { + f.t.Fatalf("create the network: %v", err) + } +} + +func (f *identityFixture) reconcile() { + f.t.Helper() + _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName}, + }) + if err != nil { + f.t.Fatalf("reconcile: %v", err) + } +} + +func (f *identityFixture) published() (*cloudv1alpha1.NetworkFabricIdentity, bool) { + f.t.Helper() + var identity cloudv1alpha1.NetworkFabricIdentity + err := f.hub.Get(f.ctx, client.ObjectKey{Namespace: testNamespace, Name: testNetworkName}, &identity) + if err != nil { + return nil, false + } + return &identity, true +} + +// placement reads the locations off the identity itself, which is what the +// per-location policies select on. +func (f *identityFixture) placement() ([]string, bool) { + f.t.Helper() + identity, ok := f.published() + if !ok { + return nil, false + } + locations := make([]string, 0, len(identity.Labels)) + for key, value := range identity.Labels { + if strings.HasPrefix(key, fabricIdentityLocationLabelPrefix) && value == "true" { + locations = append(locations, strings.TrimPrefix(key, fabricIdentityLocationLabelPrefix)) + } + } + sort.Strings(locations) + return locations, len(locations) > 0 +} + +// policyFor reads the one policy that carries one identity to every location +// it is required at. There is one of these per network, not per location. +func (f *identityFixture) policyFor(networkName string) (*unstructured.Unstructured, bool) { + f.t.Helper() + policy := &unstructured.Unstructured{} + policy.SetGroupVersionKind(clusterPropagationPolicyGVK) + key := client.ObjectKey{Name: FabricIdentityPolicyName(testNamespace, networkName)} + if err := f.hub.Get(f.ctx, key, policy); err != nil { + return nil, false + } + return policy, true +} + +// placementLocations reads the locations a policy places its identity on. +func placementLocations(t *testing.T, policy *unstructured.Unstructured) []string { + t.Helper() + expressions, _, err := unstructured.NestedSlice(policy.Object, + "spec", "placement", "clusterAffinity", "labelSelector", "matchExpressions") + if err != nil || len(expressions) != 1 { + t.Fatalf("expected one placement expression, got %v (%v)", expressions, err) + } + expression, _ := expressions[0].(map[string]any) + if key, _, _ := unstructured.NestedString(expression, "key"); key != servingLocationTopologyLabel { + t.Fatalf("placement must select the label a cell claims its location with, got %q", key) + } + if op, _, _ := unstructured.NestedString(expression, "operator"); op != "In" { + t.Fatalf("placement must match any of the locations, got operator %q", op) + } + values, _, _ := unstructured.NestedStringSlice(expression, "values") + sort.Strings(values) + return values +} + +// The identity is published on a cloud object, not on the Network. Nothing a +// consumer reads carries it. +func TestIdentityIsPublishedOnItsOwnObject(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + identity, ok := f.published() + if !ok { + t.Fatal("the identity must be published") + } + if identity.Spec.Identity == 0 { + t.Fatal("a published identity is never zero") + } + if identity.Spec.NetworkRef.Name != testNetworkName { + t.Fatalf("the identity must name the network it belongs to, got %+v", identity.Spec.NetworkRef) + } + if len(f.ipam.created) != 1 || f.ipam.created[0] != fabricidentity.ClaimName(testNamespace, testNetworkName) { + t.Fatalf("the identity must be claimed from the platform tenancy under the network's UID, got %v", f.ipam.created) + } +} + +// One network, one identity, however many times it is reconciled. +func TestIdentityIsAllocatedOnlyOnce(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + first, _ := f.published() + for range 3 { + f.reconcile() + } + again, _ := f.published() + + if first.Spec.Identity != again.Spec.Identity { + t.Fatalf("the identity moved from %d to %d", first.Spec.Identity, again.Spec.Identity) + } + if len(f.ipam.created) != 1 { + t.Fatalf("a network already holding an identity must not ask for another, got %v", f.ipam.created) + } +} + +// Two networks are two identities. One shared between them would make them one +// network on the fabric: each would import the other's routes. +func TestEachNetworkGetsItsOwnIdentity(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + first, _ := f.published() + + f.addNetwork("staging", "us-central-1") + if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "staging"}, + }); err != nil { + t.Fatalf("reconcile the second network: %v", err) + } + + var second cloudv1alpha1.NetworkFabricIdentity + if err := f.hub.Get(f.ctx, client.ObjectKey{Namespace: testNamespace, Name: "staging"}, &second); err != nil { + t.Fatalf("the second network must be published too: %v", err) + } + if first.Spec.Identity == second.Spec.Identity { + t.Fatal("two networks must not share an identity") + } +} + +// Placement follows the presences: the identity reaches exactly the cells +// backing the network's NetworkContexts, and no others. +func TestIdentityIsPlacedOnTheCellsTheNetworkIsRequiredIn(t *testing.T) { + f := newIdentityFixture(t, "us-central-1", "us-east-1") + f.reconcile() + + locations, placed := f.placement() + if !placed { + t.Fatal("a network required somewhere must be placed there") + } + if len(locations) != 2 || locations[0] != "us-central-1" || locations[1] != "us-east-1" { + t.Fatalf("expected both locations, got %v", locations) + } +} + +// A network that exists is a network with an identity, whether or not anything +// has asked for it in a location yet. It is placed nowhere until something +// does, so no cell carries a value it has no use for, but the value is settled +// before the first context appears rather than while one is waiting on it. +func TestANetworkRequiredNowhereStillHasAnIdentity(t *testing.T) { + f := newIdentityFixture(t) + f.reconcile() + + published, ok := f.published() + if !ok { + t.Fatal("a network that exists must have an identity") + } + if published.Spec.Identity == 0 { + t.Fatal("an identity of zero reads as a network holding none") + } + if _, placed := f.placement(); placed { + t.Fatal("nothing requires it anywhere, so nothing should carry it") + } + + // Again, to prove it settles rather than drawing a second value. + f.reconcile() + again, ok := f.published() + if !ok { + t.Fatal("a second pass must not collect it") + } + if again.Spec.Identity != published.Spec.Identity { + t.Fatalf("the identity must not move, got %d then %d", published.Spec.Identity, again.Spec.Identity) + } + if len(f.ipam.created) != 1 { + t.Fatalf("exactly one claim for one network, got %v", f.ipam.created) + } +} + +// The last presence going withdraws the placement and nothing else. The +// network still exists, so it keeps the identity the fabric knows it by, and +// the next location it reaches is given that value straight away instead of +// waiting on an allocation. +func TestLastPresenceWithdrawsThePlacementButKeepsTheIdentity(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + before, ok := f.published() + if !ok { + t.Fatal("expected an identity to start from") + } + + f.deletePresence("us-central-1") + f.reconcile() + + kept, ok := f.published() + if !ok { + t.Fatal("the network still exists, so it must keep its identity") + } + if _, placed := f.placement(); placed { + t.Fatal("nothing requires it anywhere, so nothing should carry it") + } + if kept.Spec.Identity != before.Spec.Identity { + t.Fatalf("the identity must not move, got %d then %d", before.Spec.Identity, kept.Spec.Identity) + } + + if err := f.networks.Create(f.ctx, newPresence("us-central-1")); err != nil { + t.Fatalf("declare the presence again: %v", err) + } + f.reconcile() + + after, ok := f.published() + if !ok { + t.Fatal("the identity must still be there when the network is required again") + } + if after.Spec.Identity != before.Spec.Identity { + t.Fatalf("the same network must keep the same identity, got %d then %d", + before.Spec.Identity, after.Spec.Identity) + } + if len(f.ipam.created) != 1 { + t.Fatalf("a presence coming and going must not draw again, got %v", f.ipam.created) + } +} + +// The network going is what collects the object. It is the one signal that +// separates a network that is gone from one that is required nowhere right +// now, and it is the only thing this acts on. +// +// The allocation itself is retained: a Route Target still installed in a remote +// location would merge a new network into a dead one's routes. +func TestTheNetworkGoingCollectsTheObjectButNotTheAllocation(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + before, ok := f.published() + if !ok { + t.Fatal("expected an identity to start from") + } + + f.deleteNetwork() + f.reconcile() + + if _, ok := f.published(); ok { + t.Fatal("the network is gone, so the object should be collected") + } + + // A network of the same name comes back. It must come back with the + // identity the name always had, because the claim was retained and is named + // from the network's namespace and name. + f.createNetwork() + f.reconcile() + + after, ok := f.published() + if !ok { + t.Fatal("the identity must be republished when the network comes back") + } + if after.Spec.Identity != before.Spec.Identity { + t.Fatalf("a retained allocation must give back the same identity, got %d then %d", + before.Spec.Identity, after.Spec.Identity) + } +} + +// A network that cannot be read is not a network that is gone. Nothing may be +// collected on the strength of a failed read, because collecting takes the +// value a live VRF is named from with it. +func TestAnUnreadableNetworkCollectsNothing(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + before, ok := f.published() + if !ok { + t.Fatal("expected an identity to start from") + } + + f.reconciler.Networks = failingGetter{Client: f.networks} + if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName}, + }); err == nil { + t.Fatal("an unreadable network must be an error, never a collection") + } + + f.reconciler.Networks = f.networks + after, ok := f.published() + if !ok { + t.Fatal("a failed read must not collect the identity") + } + if after.Spec.Identity != before.Spec.Identity { + t.Fatalf("the identity must be left exactly as it was, got %d then %d", + before.Spec.Identity, after.Spec.Identity) + } + if _, placed := f.placement(); !placed { + t.Fatal("a failed read must not withdraw the placement either") + } +} + +// failingGetter stands in for a control plane that cannot answer a read of the +// network itself. Every get is an error, which is the case a collection must +// never mistake for "the network is gone". +type failingGetter struct { + client.Client +} + +func (failingGetter) Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error { + return errors.New("the control plane is unreachable") +} + +func newPresence(location string) *networkingv1alpha.NetworkContext { + presence := &networkingv1alpha.NetworkContext{} + presence.Namespace = testNamespace + presence.Name = testNetworkName + "-" + location + presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: testNetworkName} + presence.Spec.Location = networkingv1alpha.LocationReference{Name: location} + return presence +} + +// One policy per identity, naming that one object and listing every location +// it is required at. +// +// Karmada binds a resource to exactly one policy. Per-location policies each +// selecting the same identity by label therefore compete for it, and only the +// winner's placement takes effect -- an identity required in two locations +// reaches one of them, silently. Naming the object is what makes that +// impossible. +func TestOnePolicyPerIdentityCarriesItToEveryLocation(t *testing.T) { + f := newIdentityFixture(t, "us-central-1", "us-east-1") + f.reconcile() + + policy, ok := f.policyFor(testNetworkName) + if !ok { + t.Fatalf("expected a policy for %q", testNetworkName) + } + + selectors, _, err := unstructured.NestedSlice(policy.Object, "spec", "resourceSelectors") + if err != nil || len(selectors) != 1 { + t.Fatalf("expected one resource selector, got %v (%v)", selectors, err) + } + entry, _ := selectors[0].(map[string]any) + name, _, _ := unstructured.NestedString(entry, "name") + namespace, _, _ := unstructured.NestedString(entry, "namespace") + if name != testNetworkName || namespace != testNamespace { + t.Fatalf("the policy must name the one identity it carries, got %s/%s", namespace, name) + } + if _, found, _ := unstructured.NestedMap(entry, "labelSelector"); found { + t.Fatal("selecting by label is what lets two policies contend for one identity") + } + + if locations := placementLocations(t, policy); !slices.Equal(locations, []string{"us-central-1", "us-east-1"}) { + t.Fatalf("the policy must place on every location the network reaches, got %v", locations) + } + + // A second network needs its own policy: one identity, one policy. + f.addNetwork("staging", "us-central-1") + if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "staging"}, + }); err != nil { + t.Fatalf("reconcile the second network: %v", err) + } + + var policies unstructured.UnstructuredList + policies.SetGroupVersionKind(clusterPropagationPolicyGVK.GroupVersion().WithKind("ClusterPropagationPolicyList")) + if err := f.hub.List(f.ctx, &policies); err != nil { + t.Fatalf("list policies: %v", err) + } + if len(policies.Items) != 2 { + t.Fatalf("two networks must have two policies however many locations they reach, got %d", len(policies.Items)) + } + + // No identity may be selected by more than one policy. + named := map[string]int{} + for i := range policies.Items { + selectors, _, _ := unstructured.NestedSlice(policies.Items[i].Object, "spec", "resourceSelectors") + for _, selector := range selectors { + entry, _ := selector.(map[string]any) + namespace, _, _ := unstructured.NestedString(entry, "namespace") + name, _, _ := unstructured.NestedString(entry, "name") + named[namespace+"/"+name]++ + } + } + for identity, count := range named { + if count != 1 { + t.Fatalf("identity %s is selected by %d policies; Karmada honours only one", identity, count) + } + } +} + +// The per-location policies this replaces select by label, so they keep +// competing for identities until they are gone. +func TestLegacyPerLocationPoliciesAreSweptAway(t *testing.T) { + f := newIdentityFixture(t, "us-central-1", "us-east-1") + + legacy := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{ + "resourceSelectors": []any{map[string]any{ + "apiVersion": cloudv1alpha1.GroupVersion.String(), + "kind": "NetworkFabricIdentity", + "labelSelector": map[string]any{"matchLabels": map[string]any{LocationLabel("us-central-1"): "true"}}, + }}, + }, + }} + legacy.SetGroupVersionKind(clusterPropagationPolicyGVK) + legacy.SetName("cloud-fabric-identity-us-central-1") + legacy.SetLabels(map[string]string{FabricIdentityPolicyLabel: "true"}) + if err := f.hub.Create(f.ctx, legacy); err != nil { + t.Fatalf("create the legacy policy: %v", err) + } + + f.reconcile() + if err := f.reconciler.sweepLegacyPlacements(f.ctx); err != nil { + t.Fatalf("sweep: %v", err) + } + + swept := &unstructured.Unstructured{} + swept.SetGroupVersionKind(clusterPropagationPolicyGVK) + err := f.hub.Get(f.ctx, client.ObjectKey{Name: "cloud-fabric-identity-us-central-1"}, swept) + if !apierrors.IsNotFound(err) { + t.Fatalf("the legacy policy must be gone, got %v", err) + } + + // The policy written under the new scheme is left alone. + if _, ok := f.policyFor(testNetworkName); !ok { + t.Fatal("the sweep must not remove a policy that names its identity") + } +} + +// Withdrawal is what tears a data plane down, so it happens only when a +// presence is positively observed to be gone. +func TestPlacementShrinksOnlyOnAnObservedDeletion(t *testing.T) { + f := newIdentityFixture(t, "us-central-1", "us-east-1") + f.reconcile() + + f.deletePresence("us-east-1") + f.reconcile() + + locations, placed := f.placement() + if !placed { + t.Fatal("the placement must survive one presence going") + } + if len(locations) != 1 || locations[0] != "us-central-1" { + t.Fatalf("a presence that is actually gone withdraws only that cell, got %v", locations) + } +} + +// A read that failed says nothing about where the network is required. The +// placement already in force must survive it untouched. +func TestPlacementSurvivesAnUnreadablePresence(t *testing.T) { + f := newIdentityFixture(t, "us-central-1", "us-east-1") + f.reconcile() + + before, placed := f.placement() + if !placed { + t.Fatal("expected a placement to start from") + } + + f.reconciler.Networks = failingLister{Client: f.networks} + if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName}, + }); err == nil { + t.Fatal("an unreadable presence must be an error, never a withdrawal") + } + + f.reconciler.Networks = f.networks + after, placed := f.placement() + if !placed { + t.Fatal("the placement must not be withdrawn by a failed read") + } + if len(after) != len(before) || after[0] != before[0] || after[1] != before[1] { + t.Fatalf("the placement must be left exactly as it was: %v became %v", before, after) + } +} + +// failingLister stands in for a control plane that cannot answer. Every list is +// an error, which is the case a placement must never mistake for "the network +// is required nowhere". +// A sweep that cannot delete must not stop the manager: a controller that +// refuses to start reconciles nothing, which is strictly worse than a leftover +// policy it complains about. +func TestASweepThatCannotDeleteStillStarts(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + + legacy := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{ + "resourceSelectors": []any{map[string]any{ + "apiVersion": cloudv1alpha1.GroupVersion.String(), + "kind": "NetworkFabricIdentity", + "labelSelector": map[string]any{"matchLabels": map[string]any{LocationLabel("us-central-1"): "true"}}, + }}, + }, + }} + legacy.SetGroupVersionKind(clusterPropagationPolicyGVK) + legacy.SetName("cloud-fabric-identity-us-central-1") + legacy.SetLabels(map[string]string{FabricIdentityPolicyLabel: "true"}) + if err := f.hub.Create(f.ctx, legacy); err != nil { + t.Fatalf("create the legacy policy: %v", err) + } + + f.reconciler.Hub = failingDeleter{Client: f.hub} + if err := f.reconciler.sweepLegacyPlacements(f.ctx); err != nil { + t.Fatalf("a sweep that cannot delete must not be an error, got %v", err) + } + + // A hub it cannot even read is equally survivable. + f.reconciler.Hub = failingLister{Client: f.hub} + if err := f.reconciler.sweepLegacyPlacements(f.ctx); err != nil { + t.Fatalf("a sweep that cannot read must not be an error, got %v", err) + } +} + +type failingDeleter struct { + client.Client +} + +func (failingDeleter) Delete(context.Context, client.Object, ...client.DeleteOption) error { + return apierrors.NewForbidden(schema.GroupResource{ + Group: "policy.karmada.io", Resource: "clusterpropagationpolicies", + }, "cloud-fabric-identity-us-central-1", errors.New("not permitted")) +} + +type failingLister struct { + client.Client +} + +func (failingLister) List(context.Context, client.ObjectList, ...client.ListOption) error { + return errors.New("the control plane is unreachable") +} + +// A claim whose allocation was retained comes back through the conflict, not +// through a fresh allocation. This is a different path from the collection +// round trip: there the claim still existed and was read back, here the claim +// is gone and only the retained allocation remains. +// +// Retention is what stops a released identifier returning to the pool, where +// the allocator could hand it to any network at all. The cost is that a network +// recreated under the same name in the same namespace inherits its predecessor's +// identity, which is confined to one name in one namespace. +func TestARetainedAllocationIsAdoptedRatherThanReallocated(t *testing.T) { + f := newIdentityFixture(t, "us-central-1") + f.reconcile() + + before, ok := f.published() + if !ok { + t.Fatal("expected an identity to start from") + } + + // The network goes: its object is collected, and its claim is released + // under Retain, which leaves the allocation behind. + claimName := fabricidentity.ClaimName(testNamespace, testNetworkName) + f.ipam.release(t, f.ctx, claimName) + + f.deleteNetwork() + f.reconcile() + if _, ok := f.published(); ok { + t.Fatal("the network is gone, so the object should be collected") + } + + // A network of the same name comes back. The claim no longer exists, so the + // allocate path runs and must hit the retained allocation. + claimsBefore := len(f.ipam.created) + f.createNetwork() + f.reconcile() + + after, ok := f.published() + if !ok { + t.Fatal("the identity must be republished") + } + if after.Spec.Identity != before.Spec.Identity { + t.Fatalf("a retained allocation must be adopted, got %d where %d was held", + after.Spec.Identity, before.Spec.Identity) + } + if len(f.ipam.created) != claimsBefore+1 { + t.Fatalf("the adopt path runs through a refused create, got %d creates then %d", + claimsBefore, len(f.ipam.created)) + } + + // And the identity that came back is not merely the next one the pool would + // have handed out. + if next := blockForIndex(f.ipam.next); next == "" { + t.Fatal("unreachable") + } + fresh, err := fabricidentity.FromBlock(blockForIndex(f.ipam.next)) + if err != nil { + t.Fatalf("read the next free block: %v", err) + } + if after.Spec.Identity == fresh { + t.Fatal("the identity must come from the retained allocation, not from a fresh one") + } +} + +// The watches are sourced from the hub's cache, not the manager's own, because +// the manager runs against the cluster this is scheduled on and only the leader +// election lease lives there. Wired without a hub, the controller would come up +// watching a plane that holds none of the objects it exists for and would sit +// idle rather than fail, so setup refuses instead. +func TestSetupRefusesWithoutAHubToWatch(t *testing.T) { + reconciler := &NetworkFabricIdentityReconciler{ + IPAM: newFakeIdentityIPAM(t), + IdentityClass: testClass, + } + + if err := reconciler.SetupWithManager(nil); err == nil { + t.Fatal("a reconciler with no hub to watch must refuse to start") + } +} diff --git a/internal/cloud/controller/networkinterface_controller.go b/internal/cloud/controller/networkinterface_controller.go new file mode 100644 index 00000000..085ec819 --- /dev/null +++ b/internal/cloud/controller/networkinterface_controller.go @@ -0,0 +1,440 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/galactic" + "go.datum.net/galactic/internal/cloud/identifier" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + // LabelVPC records the base62 VPC identifier a NAD attaches to. + LabelVPC = "galactic.datumapis.com/vpc" + + // LabelVPCAttachment records the base62 attachment identifier a NAD holds. + // The NAD is the allocation record for that identifier. + LabelVPCAttachment = "galactic.datumapis.com/vpc-attachment" + legacyLabelVPC = "cloud.datumapis.com/vpc" + legacyLabelVPCAttachment = "cloud.datumapis.com/vpc-attachment" + legacyAttachmentKind = "VPCAttachment" + + // AnnotationHostInterface records the host device this attachment will get, + // on the NAD, at the moment the NAD is written. The name is derived from the + // VPC and the attachment identifier, so it is known here — well before the + // CNI ADD that creates the device. A runtime that must name the device when + // it asks for an interface, rather than learn it from the ADD result, has + // nowhere else to read it in time. galactic writes the same key during ADD + // from the same inputs, so the two always agree. + AnnotationHostInterface = "k8s.v1.cni.cncf.io/host-interface" + + // ConditionTypePrepared reports that the data plane's pre-Pod artifacts exist. + // Unlike Programmed, which only becomes true at CNI ADD, it is safe to gate + // Pod creation on. network-services-operator is adding the type in parallel. + ConditionTypePrepared = "Prepared" +) + +// maxIdentifierAttempts bounds the retry loop that draws an unused identifier. +const maxIdentifierAttempts = 100 + +// NetworkInterfaceReconciler realizes a fulfilled NetworkInterface claim on the +// galactic data plane. +// +// It creates the VPCAttachment and the NetworkAttachmentDefinition in one pass, +// so it already holds every render input and never has to look sideways. Both +// objects are per-interface, which keeps the attachment identifier and the tap +// device name stable across instance replacement, and it publishes the +// annotations a workload must carry so no infrastructure provider has to know +// what a NAD is. +type NetworkInterfaceReconciler struct { + client.Client + Scheme *runtime.Scheme + + // APIReader bypasses the cache when listing allocated identifiers, so a NAD + // written moments ago cannot be missed and its identifier reissued. + APIReader client.Reader + + // AttachmentMode is how guests in this cell consume an interface. It is + // required configuration standing in for a capability class that does not + // exist yet, so a cell states what it is rather than defaulting. It covers + // every interface that states no mode of its own. + AttachmentMode cloudv1alpha1.VPCAttachmentInterfaceMode +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcattachments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcattachments/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=k8s.cni.cncf.io,resources=network-attachment-definitions,verbs=get;list;watch;create;update;patch;delete + +func (r *NetworkInterfaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var networkInterface networkingv1alpha.NetworkInterface + if err := r.Get(ctx, req.NamespacedName, &networkInterface); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !networkInterface.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + if !claimFulfilled(&networkInterface) { + return ctrl.Result{}, nil + } + + var vpc cloudv1alpha1.VPC + vpcKey := types.NamespacedName{ + Namespace: networkInterface.Namespace, + Name: networkInterface.Status.NetworkContextRef.Name, + } + if err := r.Get(ctx, vpcKey, &vpc); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface, + metav1.ConditionFalse, "AwaitingVPC", fmt.Sprintf("VPC %s does not exist yet", vpcKey.Name)) + } + return ctrl.Result{}, fmt.Errorf("get VPC %s: %w", vpcKey, err) + } + if vpc.Status.VPC == "" { + return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface, + metav1.ConditionFalse, "AwaitingVPCIdentifier", + fmt.Sprintf("VPC %s has no identifier yet", vpc.Name)) + } + + attachment, err := r.reconcileAttachment(ctx, &networkInterface, &vpc) + if err != nil { + return ctrl.Result{}, err + } + nad, err := r.reconcileNAD(ctx, attachment, &vpc, &networkInterface) + if err != nil { + return ctrl.Result{}, err + } + if err := r.publishAttachmentStatus(ctx, attachment, &vpc, nad); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, r.publishToInterface(ctx, &networkInterface, attachment, &vpc) +} + +// claimFulfilled reports whether an interface is bound to a claim and holds +// every address it must carry. Nothing can be rendered before that. +func claimFulfilled(networkInterface *networkingv1alpha.NetworkInterface) bool { + if networkInterface.Status.Phase != networkingv1alpha.NetworkInterfacePhaseBound { + return false + } + if networkInterface.Status.NetworkContextRef == nil { + return false + } + return meta.IsStatusConditionTrue(networkInterface.Status.Conditions, + networkingv1alpha.NetworkInterfaceAllocated) +} + +// reconcileAttachment creates the VPCAttachment for an interface. The controller +// owns this object, not the infrastructure provider: it is the only component +// that speaks both the workload vocabulary and the data plane's. +func (r *NetworkInterfaceReconciler) reconcileAttachment( + ctx context.Context, networkInterface *networkingv1alpha.NetworkInterface, vpc *cloudv1alpha1.VPC, +) (*cloudv1alpha1.VPCAttachment, error) { + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Name: networkInterface.Name, Namespace: networkInterface.Namespace}, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, attachment, func() error { + attachment.Spec.VPC = cloudv1alpha1.VPCRef{Name: vpc.Name} + attachment.Spec.InterfaceRef = &cloudv1alpha1.NetworkInterfaceRef{Name: networkInterface.Name} + attachment.Spec.Interface.Name = networkInterface.Spec.InterfaceName + attachment.Spec.Interface.Mode = r.attachmentMode(networkInterface) + attachment.Spec.Interface.Addresses = interfaceAddresses(networkInterface) + return controllerutil.SetControllerReference(networkInterface, attachment, r.Scheme) + }); err != nil { + return nil, fmt.Errorf("reconcile VPC attachment %s: %w", attachment.Name, err) + } + return attachment, nil +} + +// reconcileNAD creates or updates the NAD the attachment owns. +func (r *NetworkInterfaceReconciler) reconcileNAD( + ctx context.Context, + attachment *cloudv1alpha1.VPCAttachment, + vpc *cloudv1alpha1.VPC, + networkInterface *networkingv1alpha.NetworkInterface, +) (*nadv1.NetworkAttachmentDefinition, error) { + nad := &nadv1.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: attachment.Name, Namespace: attachment.Namespace}, + } + + attachmentID := "" + if err := r.Get(ctx, client.ObjectKeyFromObject(nad), nad); err == nil { + attachmentID = nad.Labels[LabelVPCAttachment] + if attachmentID == "" { + attachmentID = nad.Labels[legacyLabelVPCAttachment] + } + } else if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("get NetworkAttachmentDefinition %s: %w", nad.Name, err) + } + if attachmentID == "" { + allocated, err := r.allocateAttachmentIdentifier(ctx, vpc.Status.VPC) + if err != nil { + return nil, err + } + attachmentID = allocated + } + + addresses := make([]galactic.Address, 0, len(networkInterface.Spec.Addresses)) + for _, address := range networkInterface.Spec.Addresses { + addresses = append(addresses, galactic.Address{ + Address: address.Address, + Gateway: address.Gateway, + }) + } + config, err := galactic.ConflistJSON(attachment.Name, masterPlugin(attachment.Spec.Interface.Mode), + vpc.Status.VPC, attachmentID, networkInterface.Spec.MTU, addresses, + declaresDevice(attachment.Spec.Interface.Mode)) + if err != nil { + return nil, err + } + + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, nad, func() error { + if nad.Labels == nil { + nad.Labels = map[string]string{} + } + nad.Labels[LabelVPC] = vpc.Status.VPC + nad.Labels[LabelVPCAttachment] = attachmentID + if nad.Annotations == nil { + nad.Annotations = map[string]string{} + } + nad.Annotations[AnnotationHostInterface] = galactic.HostInterfaceName(vpc.Status.VPC, attachmentID) + nad.Spec.Config = config + // The former cloud-group attachment owned this NAD. Its controller + // must be stopped before rollout; transfer ownership in the same write + // as the group/label update while retaining its stable attachment ID. + refs := nad.OwnerReferences[:0] + for _, ref := range nad.OwnerReferences { + if ref.APIVersion == "cloud.datumapis.com/v1alpha1" && ref.Kind == legacyAttachmentKind && ref.Name == attachment.Name { + continue + } + refs = append(refs, ref) + } + nad.OwnerReferences = refs + return controllerutil.SetControllerReference(attachment, nad, r.Scheme) + }); err != nil { + return nil, fmt.Errorf("reconcile NetworkAttachmentDefinition %s: %w", nad.Name, err) + } + return nad, nil +} + +// attachmentMode resolves how one guest consumes its interface. An interface +// that states a mode carries the workload's own requirement, so it wins. The +// cell-wide mode covers everything else, which is every interface written +// before a workload could state one. +func (r *NetworkInterfaceReconciler) attachmentMode( + networkInterface *networkingv1alpha.NetworkInterface, +) cloudv1alpha1.VPCAttachmentInterfaceMode { + switch networkInterface.Spec.AttachmentMode { + case networkingv1alpha.NetworkInterfaceAttachmentModeNetns: + return cloudv1alpha1.VPCAttachmentInterfaceModeNetns + case networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor: + return cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor + case networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared: + return cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared + default: + return r.AttachmentMode + } +} + +// masterPlugin translates how a guest consumes an interface into the galactic +// binary that realizes it. This is the only place the two vocabularies meet. +func masterPlugin(mode cloudv1alpha1.VPCAttachmentInterfaceMode) string { + switch mode { + case cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, + cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared: + return galactic.PluginTap + default: + return galactic.PluginVeth + } +} + +// declaresDevice reports whether the tap plugin describes the device to the +// hypervisor instead of leaving the hypervisor to discover it. +func declaresDevice(mode cloudv1alpha1.VPCAttachmentInterfaceMode) bool { + return mode == cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared +} + +// interfaceAddresses copies the addresses NSO allocated onto the attachment, so +// the attachment describes itself without a second read. +func interfaceAddresses(networkInterface *networkingv1alpha.NetworkInterface) []cloudv1alpha1.IPAddress { + addresses := make([]cloudv1alpha1.IPAddress, 0, len(networkInterface.Spec.Addresses)) + for _, address := range networkInterface.Spec.Addresses { + addresses = append(addresses, cloudv1alpha1.IPAddress(address.Address)) + } + return addresses +} + +// allocateAttachmentIdentifier draws a random identifier unused within the VPC. +// Random rather than lowest-free, so a freed identifier is not immediately +// reissued while its BGPAdvertisement is still being garbage collected. +func (r *NetworkInterfaceReconciler) allocateAttachmentIdentifier(ctx context.Context, vpc string) (string, error) { + used := make(map[string]struct{}) + for _, label := range []string{LabelVPC, legacyLabelVPC} { + var nads nadv1.NetworkAttachmentDefinitionList + if err := r.APIReader.List(ctx, &nads, client.MatchingLabels{label: vpc}); err != nil { + return "", fmt.Errorf("list NetworkAttachmentDefinitions for VPC %s: %w", vpc, err) + } + for _, nad := range nads.Items { + for _, key := range []string{LabelVPCAttachment, legacyLabelVPCAttachment} { + if id := nad.Labels[key]; id != "" { + used[id] = struct{}{} + } + } + } + } + + for range maxIdentifierAttempts { + candidate, err := identifier.RandomVPCAttachmentBase62() + if err != nil { + return "", err + } + if _, taken := used[candidate]; !taken { + return candidate, nil + } + } + return "", fmt.Errorf("no unused attachment identifier found in VPC %s after %d attempts", + vpc, maxIdentifierAttempts) +} + +// publishAttachmentStatus records the allocated identifiers on the attachment. +func (r *NetworkInterfaceReconciler) publishAttachmentStatus( + ctx context.Context, + attachment *cloudv1alpha1.VPCAttachment, + vpc *cloudv1alpha1.VPC, + nad *nadv1.NetworkAttachmentDefinition, +) error { + attachment.Status.VPC = vpc.Status.VPC + attachment.Status.VPCAttachment = nad.Labels[LabelVPCAttachment] + attachment.Status.NetworkAttachmentDefinition = nad.Name + attachment.Status.ObservedGeneration = attachment.Generation + meta.SetStatusCondition(&attachment.Status.Conditions, metav1.Condition{ + Type: cloudv1alpha1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "AttachmentDefinitionReady", + Message: fmt.Sprintf("NetworkAttachmentDefinition %s is ready for use", nad.Name), + ObservedGeneration: attachment.Generation, + }) + if err := r.Status().Update(ctx, attachment); err != nil { + return fmt.Errorf("update VPC attachment %s status: %w", client.ObjectKeyFromObject(attachment), err) + } + return nil +} + +// publishToInterface records what realizes the interface and which VPC it landed +// in, then reports Prepared so compute can release the Pod. +func (r *NetworkInterfaceReconciler) publishToInterface( + ctx context.Context, + networkInterface *networkingv1alpha.NetworkInterface, + attachment *cloudv1alpha1.VPCAttachment, + vpc *cloudv1alpha1.VPC, +) error { + ref := &networkingv1alpha.NetworkInterfaceAttachmentRef{ + APIGroup: cloudv1alpha1.GroupVersion.Group, + Kind: legacyAttachmentKind, + Name: attachment.Name, + } + if old := networkInterface.Status.AttachmentRef; old != nil && old.APIGroup != ref.APIGroup { + // NSO preserves the provider-owned Programmed condition. A condition + // earned by the former API group cannot certify this new attachment; + // the new BGPAdvertisement reconciler will report it again. + meta.SetStatusCondition(&networkInterface.Status.Conditions, metav1.Condition{ + Type: networkingv1alpha.NetworkInterfaceProgrammed, + Status: metav1.ConditionUnknown, + Reason: "APIGroupMigration", + Message: "waiting for the Galactic attachment to be programmed", + ObservedGeneration: networkInterface.Generation, + }) + } + networkInterface.Status.AttachmentRef = ref + networkInterface.Status.VPC = vpc.Status.VPC + return r.markPrepared(ctx, networkInterface, metav1.ConditionTrue, "AttachmentReady", + fmt.Sprintf("VPCAttachment %s and its attachment definition exist", attachment.Name)) +} + +// markPrepared reports whether the pre-Pod artifacts exist, on the interface and +// on the claim holding it, which is what compute gates Pod creation on. +func (r *NetworkInterfaceReconciler) markPrepared( + ctx context.Context, + networkInterface *networkingv1alpha.NetworkInterface, + status metav1.ConditionStatus, + reason, message string, +) error { + condition := metav1.Condition{ + Type: ConditionTypePrepared, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: networkInterface.Generation, + } + meta.SetStatusCondition(&networkInterface.Status.Conditions, condition) + if err := r.Status().Update(ctx, networkInterface); err != nil { + return fmt.Errorf("update network interface %s status: %w", + client.ObjectKeyFromObject(networkInterface), err) + } + + if networkInterface.Spec.ClaimRef == nil { + return nil + } + var claim networkingv1alpha.NetworkInterfaceClaim + key := types.NamespacedName{Namespace: networkInterface.Namespace, Name: networkInterface.Spec.ClaimRef.Name} + if err := r.Get(ctx, key, &claim); err != nil { + return client.IgnoreNotFound(err) + } + claimCondition := condition + claimCondition.ObservedGeneration = claim.Generation + meta.SetStatusCondition(&claim.Status.Conditions, claimCondition) + if err := r.Status().Update(ctx, &claim); err != nil { + return fmt.Errorf("update network interface claim %s status: %w", key, err) + } + return nil +} + +// SetupWithManager registers the reconciler with the manager. +func (r *NetworkInterfaceReconciler) SetupWithManager(mgr ctrl.Manager) error { + // A NAD is owned by the attachment rather than the interface, but all three + // share a name and namespace, so mapping one back is an identity. + nadToInterface := func(_ context.Context, obj client.Object) []ctrl.Request { + return []ctrl.Request{{NamespacedName: client.ObjectKeyFromObject(obj)}} + } + + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha.NetworkInterface{}). + Owns(&cloudv1alpha1.VPCAttachment{}). + Watches(&nadv1.NetworkAttachmentDefinition{}, handler.EnqueueRequestsFromMapFunc(nadToInterface)). + Named("networkinterface"). + Complete(r) +} diff --git a/internal/cloud/controller/networkinterface_controller_test.go b/internal/cloud/controller/networkinterface_controller_test.go new file mode 100644 index 00000000..9c58e540 --- /dev/null +++ b/internal/cloud/controller/networkinterface_controller_test.go @@ -0,0 +1,219 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "testing" + + nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/galactic" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func TestReconcileNADTakesOverLegacyAttachment(t *testing.T) { + scheme := runtime.NewScheme() + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := nadv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + controller := true + old := &nadv1.NetworkAttachmentDefinition{ObjectMeta: metav1.ObjectMeta{ + Name: "nic", Namespace: "tenant", + Labels: map[string]string{legacyLabelVPC: "1", legacyLabelVPCAttachment: "2"}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "cloud.datumapis.com/v1alpha1", Kind: "VPCAttachment", Name: "nic", UID: types.UID("old"), Controller: &controller}}, + }} + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old).Build() + r := &NetworkInterfaceReconciler{Client: cl, APIReader: cl, Scheme: scheme} + attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant", UID: types.UID("new")}} + attachment.Spec.Interface.Mode = cloudv1alpha1.VPCAttachmentInterfaceModeNetns + vpc := &cloudv1alpha1.VPC{} + vpc.Status.VPC = "1" + iface := &networkingv1alpha.NetworkInterface{} + iface.Spec.MTU = 1500 + got, err := r.reconcileNAD(context.Background(), attachment, vpc, iface) + if err != nil { + t.Fatal(err) + } + if got.Labels[LabelVPCAttachment] != "2" { + t.Fatalf("attachment ID changed: %q", got.Labels[LabelVPCAttachment]) + } + if len(got.OwnerReferences) != 1 || got.OwnerReferences[0].APIVersion != cloudv1alpha1.GroupVersion.String() || got.OwnerReferences[0].UID != attachment.UID { + t.Fatalf("owner references = %#v", got.OwnerReferences) + } +} + +func TestPublishToInterfaceReprogramsNSOAttachmentGroup(t *testing.T) { + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + iface := &networkingv1alpha.NetworkInterface{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} + iface.Status.AttachmentRef = &networkingv1alpha.NetworkInterfaceAttachmentRef{ + APIGroup: "cloud.datumapis.com", Kind: "VPCAttachment", Name: "nic", + } + iface.Status.Conditions = []metav1.Condition{{ + Type: networkingv1alpha.NetworkInterfaceProgrammed, + Status: metav1.ConditionTrue, Reason: "OldGroupProgrammed", LastTransitionTime: metav1.Now(), + }} + cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface).WithObjects(iface).Build() + r := &NetworkInterfaceReconciler{Client: cl} + attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} + vpc := &cloudv1alpha1.VPC{} + vpc.Status.VPC = "vpc-1" + if err := r.publishToInterface(context.Background(), iface, attachment, vpc); err != nil { + t.Fatal(err) + } + var got networkingv1alpha.NetworkInterface + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(iface), &got); err != nil { + t.Fatal(err) + } + if got.Status.AttachmentRef == nil || got.Status.AttachmentRef.APIGroup != "galactic.datumapis.com" { + t.Fatalf("attachment reference = %#v, want Galactic API group", got.Status.AttachmentRef) + } + condition := apimeta.FindStatusCondition(got.Status.Conditions, networkingv1alpha.NetworkInterfaceProgrammed) + if condition == nil || condition.Status != metav1.ConditionUnknown || condition.Reason != "APIGroupMigration" { + t.Fatalf("Programmed condition = %#v, want Unknown/APIGroupMigration", condition) + } +} + +func TestMasterPlugin(t *testing.T) { + tests := []struct { + mode cloudv1alpha1.VPCAttachmentInterfaceMode + want string + }{ + {cloudv1alpha1.VPCAttachmentInterfaceModeNetns, galactic.PluginVeth}, + {cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, galactic.PluginTap}, + {cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared, galactic.PluginTap}, + } + for _, test := range tests { + t.Run(string(test.mode), func(t *testing.T) { + if got := masterPlugin(test.mode); got != test.want { + t.Errorf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestClaimFulfilled(t *testing.T) { + newInterface := func(phase networkingv1alpha.NetworkInterfacePhase, allocated metav1.ConditionStatus, + contextRef *networkingv1alpha.LocalNetworkContextRef) *networkingv1alpha.NetworkInterface { + return &networkingv1alpha.NetworkInterface{ + Status: networkingv1alpha.NetworkInterfaceStatus{ + Phase: phase, + NetworkContextRef: contextRef, + Conditions: []metav1.Condition{{ + Type: networkingv1alpha.NetworkInterfaceAllocated, + Status: allocated, + Reason: "Test", + }}, + }, + } + } + contextRef := &networkingv1alpha.LocalNetworkContextRef{Name: "default-us-central-1"} + + tests := []struct { + name string + networkInterface *networkingv1alpha.NetworkInterface + want bool + }{ + {"bound and allocated", newInterface( + networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, contextRef), true}, + {"available", newInterface( + networkingv1alpha.NetworkInterfacePhaseAvailable, metav1.ConditionTrue, contextRef), false}, + {"not allocated", newInterface( + networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionFalse, contextRef), false}, + {"no network context", newInterface( + networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, nil), false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := claimFulfilled(test.networkInterface); got != test.want { + t.Errorf("got %v, want %v", got, test.want) + } + }) + } +} + +// The cell-wide mode is the only signal every attachment written until now +// carries, so it has to keep deciding for all of them. An interface that +// states its own mode is the exception. +func TestAttachmentModeFallsBackToTheCell(t *testing.T) { + r := &NetworkInterfaceReconciler{ + AttachmentMode: cloudv1alpha1.VPCAttachmentInterfaceModeNetns, + } + + tests := []struct { + name string + iface networkingv1alpha.NetworkInterfaceAttachmentMode + want cloudv1alpha1.VPCAttachmentInterfaceMode + }{ + {"unset", "", cloudv1alpha1.VPCAttachmentInterfaceModeNetns}, + {"netns", networkingv1alpha.NetworkInterfaceAttachmentModeNetns, + cloudv1alpha1.VPCAttachmentInterfaceModeNetns}, + {"hypervisor", networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor, + cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor}, + {"declared", networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared, + cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + networkInterface := &networkingv1alpha.NetworkInterface{ + Spec: networkingv1alpha.NetworkInterfaceSpec{AttachmentMode: test.iface}, + } + if got := r.attachmentMode(networkInterface); got != test.want { + t.Errorf("got %q, want %q", got, test.want) + } + }) + } +} + +// Only the declared mode asks the tap plugin to describe the device. +// TestHostInterfaceAnnotationKey pins the annotation key this controller writes +// on the NAD. The key is a contract with the data plane: galactic writes the +// same one during CNI ADD, and a runtime reads it to learn its host device. +// Renaming it here alone would leave that runtime with nothing to read, and +// nothing else in this repository would fail. +func TestHostInterfaceAnnotationKey(t *testing.T) { + const want = "k8s.v1.cni.cncf.io/host-interface" + if AnnotationHostInterface != want { + t.Errorf("AnnotationHostInterface = %q, want %q", AnnotationHostInterface, want) + } +} + +func TestDeclaresDevice(t *testing.T) { + if declaresDevice(cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor) { + t.Error("a discovered hypervisor attachment must not ask for a description") + } + if !declaresDevice(cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared) { + t.Error("a declared hypervisor attachment must ask for a description") + } +} diff --git a/internal/cloud/controller/subnet_controller.go b/internal/cloud/controller/subnet_controller.go new file mode 100644 index 00000000..46c735dd --- /dev/null +++ b/internal/cloud/controller/subnet_controller.go @@ -0,0 +1,180 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "fmt" + "slices" + "strconv" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// upstreamGenerationAnnotation names the generation of the project's subnet +// that the copy propagated here was taken from. The operator that owns the +// subnet stamps it on the copy, and reads a report back only for the +// generation it stamped. The key is the operator's; it is spelled out here +// because the operator version this module pins does not export it yet. +const upstreamGenerationAnnotation = "networking.datumapis.com/upstream-generation" + +// SubnetReconciler reports whether a location's address range is in place on +// this cell: the VPC built for the subnet's network context carries the range +// and is ready. It closes the Programmed condition the networking operator +// leaves for the data plane, on the copy of the Subnet propagated to this cell, +// and the federation carries the report back to the project. +// +// The range is in place when traffic can be routed in it. A VPC carries the +// range the moment it is built, because its address space is fixed at +// creation; what it waits for is the identity the fabric routes it by. +type SubnetReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch + +func (r *SubnetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var subnet networkingv1alpha.Subnet + if err := r.Get(ctx, req.NamespacedName, &subnet); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !subnet.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + var vpc *cloudv1alpha1.VPC + if subnet.Spec.NetworkContext.Name != "" { + vpc = &cloudv1alpha1.VPC{} + key := client.ObjectKey{Namespace: subnet.Namespace, Name: subnet.Spec.NetworkContext.Name} + if err := r.Get(ctx, key, vpc); err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("read the VPC for subnet %s: %w", req.NamespacedName, err) + } + vpc = nil + } + } + + condition := subnetProgrammedCondition(&subnet, vpc) + condition.ObservedGeneration = reportedGeneration(&subnet) + if !meta.SetStatusCondition(&subnet.Status.Conditions, condition) { + return ctrl.Result{}, nil + } + if err := r.Status().Update(ctx, &subnet); err != nil { + return ctrl.Result{}, fmt.Errorf("update subnet %s status: %w", req.NamespacedName, err) + } + return ctrl.Result{}, nil +} + +// subnetProgrammedCondition says whether the subnet's range is in place on this +// cell, given the VPC built for its network context, or nil for none yet. +func subnetProgrammedCondition(subnet *networkingv1alpha.Subnet, vpc *cloudv1alpha1.VPC) metav1.Condition { + condition := metav1.Condition{ + Type: networkingv1alpha.SubnetProgrammed, + Status: metav1.ConditionFalse, + } + + start, prefixLength, ok := subnetRange(subnet) + if !ok { + condition.Reason = networkingv1alpha.SubnetProgrammedReasonNotProgrammed + condition.Message = "the subnet states no range to put in place" + return condition + } + network := cloudv1alpha1.Network(fmt.Sprintf("%s/%d", start, prefixLength)) + + if vpc == nil { + condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress + condition.Message = fmt.Sprintf("waiting for the VPC for network context %q to be built", subnet.Spec.NetworkContext.Name) + return condition + } + + // The address space is fixed when the VPC is built. A range that was not + // there then is not routed by it now, and no amount of waiting changes that. + if !slices.Contains(vpc.Spec.Networks, network) { + condition.Reason = networkingv1alpha.SubnetProgrammedReasonNotProgrammed + condition.Message = fmt.Sprintf("VPC %q was built without %s; its address space is fixed at creation", vpc.Name, network) + return condition + } + + ready := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady) + if ready == nil || ready.Status != metav1.ConditionTrue || vpc.Status.VPC == "" { + condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress + condition.Message = fmt.Sprintf("VPC %q carries %s but is not ready yet", vpc.Name, network) + if ready != nil && ready.Message != "" { + condition.Message += ": " + ready.Message + } + return condition + } + + condition.Status = metav1.ConditionTrue + condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammed + condition.Message = fmt.Sprintf("%s is routed by VPC %s", network, vpc.Status.VPC) + return condition +} + +// reportedGeneration is the generation the report is about. Each API server +// counts generation on its own, so the copy's own generation says nothing to +// the project; the report is made against the upstream generation stamped on +// the copy, and falls back to the copy's generation for one that carries none. +func reportedGeneration(subnet *networkingv1alpha.Subnet) int64 { + if generation, err := strconv.ParseInt(subnet.Annotations[upstreamGenerationAnnotation], 10, 64); err == nil { + return generation + } + return subnet.Generation +} + +// SetupWithManager registers the reconciler with the manager. +func (r *SubnetReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha.Subnet{}). + Watches(&cloudv1alpha1.VPC{}, handler.EnqueueRequestsFromMapFunc(r.subnetsForVPC)). + Named("subnet"). + Complete(r) +} + +// subnetsForVPC names the subnets a VPC was built for, so its becoming ready +// is reported on each of them. +func (r *SubnetReconciler) subnetsForVPC(ctx context.Context, obj client.Object) []ctrl.Request { + vpc, ok := obj.(*cloudv1alpha1.VPC) + if !ok { + return nil + } + var subnets networkingv1alpha.SubnetList + if err := r.List(ctx, &subnets, client.InNamespace(vpc.Namespace)); err != nil { + return nil + } + requests := make([]ctrl.Request, 0, len(subnets.Items)) + for i := range subnets.Items { + if subnets.Items[i].Spec.NetworkContext.Name != vpc.Name { + continue + } + requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&subnets.Items[i])}) + } + return requests +} diff --git a/internal/cloud/controller/subnet_controller_test.go b/internal/cloud/controller/subnet_controller_test.go new file mode 100644 index 00000000..384179d0 --- /dev/null +++ b/internal/cloud/controller/subnet_controller_test.go @@ -0,0 +1,163 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// propagatedSubnet is the copy of a project's subnet a cell is handed: spec +// only, with the upstream generation the operator stamped on it. +func propagatedSubnet(upstreamGeneration string) *networkingv1alpha.Subnet { + subnet := &networkingv1alpha.Subnet{} + subnet.Namespace = vpcTestNamespace + subnet.Name = vpcTestNetwork + "-" + vpcTestLocation + "-ipv6" + subnet.Generation = 7 + if upstreamGeneration != "" { + subnet.Annotations = map[string]string{upstreamGenerationAnnotation: upstreamGeneration} + } + subnet.Spec.NetworkContext = networkingv1alpha.LocalNetworkContextRef{Name: vpcTestNetwork + "-" + vpcTestLocation} + subnet.Spec.StartAddress = "fd00::" + subnet.Spec.PrefixLength = 48 + return subnet +} + +func vpcFor(networks []cloudv1alpha1.Network, ready bool) *cloudv1alpha1.VPC { + vpc := &cloudv1alpha1.VPC{} + vpc.Namespace = vpcTestNamespace + vpc.Name = vpcTestNetwork + "-" + vpcTestLocation + vpc.Spec.Networks = networks + if ready { + vpc.Status.VPC = vpcTestIdentifier + vpc.Status.Conditions = []metav1.Condition{{ + Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue, Reason: "IdentifierAllocated", + }} + } else { + vpc.Status.Conditions = []metav1.Condition{{ + Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: "AwaitingFabricIdentity", + Message: "Waiting for the identity the fabric knows this network by", + }} + } + return vpc +} + +func reconcileSubnet(t *testing.T, objects ...client.Object) *networkingv1alpha.Subnet { + t.Helper() + + scheme := runtime.NewScheme() + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatalf("build the networking scheme: %v", err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("build the cloud scheme: %v", err) + } + cl := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&networkingv1alpha.Subnet{}). + WithObjects(objects...).Build() + + reconciler := &SubnetReconciler{Client: cl, Scheme: scheme} + key := types.NamespacedName{Namespace: vpcTestNamespace, Name: vpcTestNetwork + "-" + vpcTestLocation + "-ipv6"} + if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}); err != nil { + t.Fatalf("reconcile: %v", err) + } + + subnet := &networkingv1alpha.Subnet{} + if err := cl.Get(context.Background(), key, subnet); err != nil { + t.Fatalf("read the subnet: %v", err) + } + return subnet +} + +func programmedOf(t *testing.T, subnet *networkingv1alpha.Subnet) metav1.Condition { + t.Helper() + condition := meta.FindStatusCondition(subnet.Status.Conditions, networkingv1alpha.SubnetProgrammed) + if condition == nil { + t.Fatalf("the cell should have reported on the subnet, got %+v", subnet.Status.Conditions) + } + return *condition +} + +// The signal: a range is in place once the VPC built for its network context +// carries it and is routed by the fabric. The report is made against the +// generation the project knows, not the copy's own. +func TestSubnetIsProgrammedWhenAReadyVPCCarriesItsRange(t *testing.T) { + subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, true)) + + condition := programmedOf(t, subnet) + if condition.Status != metav1.ConditionTrue || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammed { + t.Fatalf("a ready VPC carrying the range should read as programmed, got %+v", condition) + } + if condition.ObservedGeneration != 3 { + t.Fatalf("the report is about the project's generation 3, got %d", condition.ObservedGeneration) + } +} + +// A VPC that carries the range but has no identity yet is on its way, and +// says so, rather than claiming the range is routed. +func TestSubnetWaitsForTheVPCToBecomeReady(t *testing.T) { + subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, false)) + + condition := programmedOf(t, subnet) + if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress { + t.Fatalf("a VPC awaiting its identity should read as in progress, got %+v", condition) + } +} + +// No VPC yet is the same wait: the network context reconciler builds it once +// the subnet it reads from is there. +func TestSubnetWaitsForTheVPCToBeBuilt(t *testing.T) { + subnet := reconcileSubnet(t, propagatedSubnet("3")) + + condition := programmedOf(t, subnet) + if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress { + t.Fatalf("a VPC not built yet should read as in progress, got %+v", condition) + } +} + +// A VPC's address space is fixed when it is built. A range it was built +// without is not routed by it, and that is a verdict, not a wait. +func TestSubnetIsNotProgrammedWhenTheVPCWasBuiltWithoutIt(t *testing.T) { + subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00:1::/48"}, true)) + + condition := programmedOf(t, subnet) + if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonNotProgrammed { + t.Fatalf("a range the VPC was built without should read as not programmed, got %+v", condition) + } +} + +// A copy with no upstream generation stamped on it reports against its own, +// so a project running an older operator still sees a report. +func TestSubnetReportFallsBackToTheCopysOwnGeneration(t *testing.T) { + subnet := reconcileSubnet(t, propagatedSubnet(""), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, true)) + + if got := programmedOf(t, subnet).ObservedGeneration; got != 7 { + t.Fatalf("an unstamped copy reports against its own generation 7, got %d", got) + } +} diff --git a/internal/cloud/fabricidentity/identity.go b/internal/cloud/fabricidentity/identity.go new file mode 100644 index 00000000..9a8b5e98 --- /dev/null +++ b/internal/cloud/fabricidentity/identity.go @@ -0,0 +1,226 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package fabricidentity allocates the identity the fabric knows a network by. +// +// The platform's address service allocates prefixes, not integers. Rather than +// build a second allocator with the same uniqueness and concurrency problems +// already solved there, an identity is allocated as a block from a pool that is +// never routed, and the integer is the block's index within that pool. A /32 +// root handing out /64s yields exactly 2^32 allocations whose distinguishing +// bits are exactly the 32 the fabric uses. +// +// That buys uniqueness, exhaustion accounting, quota and an audit trail, and +// costs address space that is never routed and never reachable. +package fabricidentity + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net/netip" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + "go.miloapis.com/ipam/pkg/ipamerrors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // BlockBits is the size of the block an identity is read out of. The pool + // roots a /32 and hands out /64s, so the bits between them are the block's + // index within the pool, and there are exactly 2^32 of them. + BlockBits = 64 + + // RootBits is where the pool's own prefix stops and the index begins. + // Reading the index from a fixed offset rather than from the pool's CIDR + // means the identity does not depend on an object this would otherwise have + // to read on every allocation. A pool rooted longer than a /32 leaves the + // leading bits of every index at zero, which is still unique within the one + // pool the platform allocates from. + RootBits = 32 +) + +// Request names one network's claim on the identifier space. +type Request struct { + // ClassName is the IPClass that hands out identities. + ClassName string + + // Namespace is the namespace in the platform's own tenancy the claim is + // written to. + Namespace string + + // NetworkNamespace and NetworkName identify the network the identity is + // for. The claim is named from the pair, so an identity is a permanent + // property of that name in that namespace. + // + // A network deleted and recreated under the same name in the same namespace + // therefore inherits the identity it had before. That is deliberate: the + // alternative is releasing identifiers back to the pool, where the + // allocator could hand one to any network at all. A Route Target still + // installed in a remote location's import policy would then merge an + // unrelated network into a dead one's routes. Inheritance is confined to + // one namespace and one name; reissue is not confined to anything. + NetworkNamespace string + NetworkName string +} + +// maxClaimNameLength is the ceiling on an object name in Kubernetes, which is +// all an IPClaim name is. +const maxClaimNameLength = 253 + +// ClaimName is the name the request's claim is held under. +// +// The delimiter is a dot rather than a dash because a namespace is a DNS label +// and cannot contain one, so the first dot after the prefix always ends the +// namespace. A dash would be ambiguous: namespace "a-b" with name "c" and +// namespace "a" with name "b-c" would collide. +// +// Uniqueness rests on the API server refusing a namespace containing a dot. A +// network name may contain one, and that is fine: the split is taken at the +// first dot, which is always the namespace boundary. +func ClaimName(networkNamespace, networkName string) string { + name := fmt.Sprintf("fabric-identity.%s.%s", networkNamespace, networkName) + if len(name) <= maxClaimNameLength { + return name + } + + sum := sha256.Sum256([]byte(networkNamespace + "/" + networkName)) + suffix := "." + hex.EncodeToString(sum[:])[:16] + return name[:maxClaimNameLength-len(suffix)] + suffix +} + +// Claim holds one block of the identifier space and reads the identity out of +// it. +// +// IPAM binds on create and refuses a duplicate name, so the read comes first. +// That is what makes the allocation idempotent without this recording anything +// of its own: the claim is the record. +func Claim(ctx context.Context, ipamClient client.Client, request Request) (int64, error) { + ipClaim := &ipamv1alpha1.IPClaim{} + ipClaim.Namespace = request.Namespace + ipClaim.Name = ClaimName(request.NetworkNamespace, request.NetworkName) + ipClaim.Spec = ipamv1alpha1.IPClaimSpec{ + ClassName: request.ClassName, + + // The class already fixes the family, but the server bounds a claim's + // prefix length from the family on the claim alone, before it resolves + // the class at all. Left unset, a /64 is read as an IPv4 length and + // refused, so every allocation fails. + IPFamily: ipamv1alpha1.IPv6, + + Target: ipamv1alpha1.TargetBlock, + PrefixLength: ptr.To(int32(BlockBits)), + + // An identity is never given back. A Route Target still installed in a + // remote location's import policy would silently merge a new network + // into a dead one's routes, so holding the block forever is the safe + // failure and reissuing it is not a failure anything can see. + ReclaimPolicy: ipamv1alpha1.ReclaimRetain, + } + + existing := &ipamv1alpha1.IPClaim{} + getErr := ipamClient.Get(ctx, client.ObjectKeyFromObject(ipClaim), existing) + if getErr != nil && !apierrors.IsNotFound(getErr) { + return 0, fmt.Errorf("read the identity claim %q: %w", ipClaim.Name, getErr) + } + + if getErr == nil { + ipClaim = existing + } else if createErr := ipamClient.Create(ctx, ipClaim); createErr != nil { + // An allocation retained by an earlier claim of this name is this + // network's own identity, kept precisely so it could not be handed to + // another network. Adopt it rather than treating it as a failure. + if allocationName, retained := ipamerrors.RetainedAllocation(createErr); retained { + return adopt(ctx, ipamClient, request.Namespace, allocationName) + } + + // The create can still lose a race with another writer, so ask again + // before calling this a failure to allocate. + raced := &ipamv1alpha1.IPClaim{} + if err := ipamClient.Get(ctx, client.ObjectKeyFromObject(ipClaim), raced); err != nil { + return 0, fmt.Errorf("claim a fabric identity: %w", createErr) + } + ipClaim = raced + } + + if ipClaim.Status.AllocatedCIDR == "" { + return 0, fmt.Errorf("the identity space allocated nothing for this network (phase %q)", ipClaim.Status.Phase) + } + + return FromBlock(ipClaim.Status.AllocatedCIDR) +} + +// adopt reads the identity out of an allocation this network already holds. +// The allocation outlives the claim that made it, which is what retention is +// for, so the block it names is the same one this network has always had. +func adopt(ctx context.Context, ipamClient client.Client, namespace, allocationName string) (int64, error) { + allocation := &ipamv1alpha1.IPAllocation{} + if err := ipamClient.Get(ctx, + client.ObjectKey{Namespace: namespace, Name: allocationName}, allocation); err != nil { + return 0, fmt.Errorf("read the retained allocation %q: %w", allocationName, err) + } + if allocation.Status.AllocatedCIDR == "" { + return 0, fmt.Errorf("the retained allocation %q holds no block", allocationName) + } + return FromBlock(allocation.Status.AllocatedCIDR) +} + +// FromBlock reads the identity out of the block the identifier space handed +// out. The block's index within the pool is the identity, and the index is the +// 32 bits between the pool's root and the block, which is exactly the width +// that survives into the Route Target. +func FromBlock(cidr string) (int64, error) { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + return 0, &UnusableError{message: fmt.Sprintf( + "the identity space answered with %q, which is not a prefix", cidr)} + } + + address := prefix.Addr() + if !address.Is6() || address.Is4In6() { + return 0, &UnusableError{message: fmt.Sprintf( + "the identity space answered with %q; identifiers are read out of an IPv6 space", cidr)} + } + + // Shorter than a /64 and two networks could be handed blocks that share an + // index; longer and one block's index is not the whole of it. + if prefix.Bits() != BlockBits { + return 0, &UnusableError{message: fmt.Sprintf( + "the identity space answered with %q; identifiers are read out of a /%d", cidr, BlockBits)} + } + + octets := address.As16() + identity := int64(binary.BigEndian.Uint32(octets[RootBits/8 : BlockBits/8])) + if identity == 0 { + return 0, &UnusableError{message: fmt.Sprintf( + "the identity space answered with %q, whose index is zero; zero is what a network holding no identity reads as, so the pool must not hand out its first block", cidr)} + } + return identity, nil +} + +// UnusableError says the identity space answered, and its answer cannot be +// turned into an identity. Retrying reaches the same block, so this is a wait +// on an operator rather than on the service. +type UnusableError struct { + message string +} + +func (e *UnusableError) Error() string { return e.message } diff --git a/internal/cloud/fabricidentity/identity_test.go b/internal/cloud/fabricidentity/identity_test.go new file mode 100644 index 00000000..395c5068 --- /dev/null +++ b/internal/cloud/fabricidentity/identity_test.go @@ -0,0 +1,228 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package fabricidentity + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// The identity is the block's index within the pool, which is the 32 bits +// between the pool's root and the block, which is exactly the width that +// survives into the Route Target. +func TestFromBlockReadsTheIndexOutOfTheBlock(t *testing.T) { + for _, tc := range []struct { + name string + cidr string + want int64 + wants string + }{ + {name: "the first usable block", cidr: "fc00:0:0:1::/64", want: 1}, + {name: "an index spanning both halves", cidr: "fc00:0:1234:5678::/64", want: 0x12345678}, + {name: "the last block in the pool", cidr: "fc00:0:ffff:ffff::/64", want: 0xffffffff}, + {name: "a pool rooted longer than a /32", cidr: "fc00:0:0:beef::/64", want: 0xbeef}, + + {name: "the pool's own zero block", cidr: "fc00::/64", wants: "index is zero"}, + {name: "a block wider than a /64", cidr: "fc00:0:0:1::/48", wants: "read out of a /64"}, + {name: "a block narrower than a /64", cidr: "fc00:0:0:1::/96", wants: "read out of a /64"}, + {name: "an IPv4 block", cidr: "10.0.0.0/24", wants: "read out of an IPv6 space"}, + {name: "not a prefix at all", cidr: "fc00::", wants: "not a prefix"}, + } { + t.Run(tc.name, func(t *testing.T) { + identity, err := FromBlock(tc.cidr) + if tc.wants != "" { + if err == nil { + t.Fatalf("expected a refusal, got identity %d", identity) + } + if !contains(err.Error(), tc.wants) { + t.Fatalf("expected the refusal to mention %q, got %q", tc.wants, err.Error()) + } + var unusable *UnusableError + if !asUnusable(err, &unusable) { + t.Fatalf("a bad block must be an UnusableError so the caller can tell it from an outage") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if identity != tc.want { + t.Fatalf("expected identity %d, got %d", tc.want, identity) + } + // A value needing more than 32 bits would be uniqueness the platform + // believes it has and the Route Target does not. + if identity > 0xffffffff { + t.Fatalf("identity %d does not fit the 32 bits the fabric carries", identity) + } + }) + } +} + +// The claim is named from the network's namespace and name, which is what makes +// the identity a permanent property of that pair and makes allocation idempotent +// without recording anything of its own. +func TestClaimNameIsStableAndCollisionFree(t *testing.T) { + namespace, name := "ns", "prod" + if ClaimName(namespace, name) != ClaimName("ns", "prod") { + t.Fatal("the same network must reach the same claim") + } + if ClaimName("ns", "prod") == ClaimName("ns", "staging") { + t.Fatal("two networks in a namespace must reach different claims") + } + if ClaimName("a", "b") == ClaimName("b", "a") { + t.Fatal("namespace and name must not be interchangeable") + } + + // A dash delimiter would collide here: both would render "...a-b-c". A + // namespace is a DNS label and cannot contain a dot, so the first dot after + // the prefix always ends it. + if ClaimName("a-b", "c") == ClaimName("a", "b-c") { + t.Fatal("the delimiter must not be ambiguous") + } + + // A network name may contain dots; a namespace may not. Uniqueness rests on + // that: "ns.a" is not a namespace the API server will accept, so the pair + // that would collide with ("ns", "a.b") cannot exist. Asserted here so the + // dependency is recorded rather than assumed. + if strings.Contains("ns", ".") { + t.Fatal("a namespace is a DNS label and cannot contain a dot") + } + if ClaimName("ns", "a.b") == ClaimName("ns", "a-b") { + t.Fatal("two names in one namespace must reach different claims") + } + + long := ClaimName(strings.Repeat("n", 200), strings.Repeat("p", 200)) + if len(long) > maxClaimNameLength { + t.Fatalf("an over-long pair must still yield a usable name, got %d characters", len(long)) + } + if long == ClaimName(strings.Repeat("n", 200), strings.Repeat("q", 200)) { + t.Fatal("two truncated names must still differ") + } +} + +func contains(haystack, needle string) bool { + return len(needle) == 0 || (len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} + +func asUnusable(err error, target **UnusableError) bool { + return errors.As(err, target) +} + +// The address service bounds a claim's prefix length by the family stated on +// the claim, before it resolves the class the claim names. A /64 asked for +// without a family is therefore read as an IPv4 length and refused, and no +// network is ever given an identity. +func TestClaimStatesTheFamilyTheBlockIsReadFrom(t *testing.T) { + scheme := runtime.NewScheme() + if err := ipamv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("build the IPAM scheme: %v", err) + } + + ipamClient := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + ipClaim, ok := obj.(*ipamv1alpha1.IPClaim) + if !ok { + return c.Create(ctx, obj, opts...) + } + if err := admit(ipClaim); err != nil { + return err + } + ipClaim.Status.Phase = ipamv1alpha1.ClaimBound + ipClaim.Status.AllocatedCIDR = "fd30:0:0:1::/64" + return c.Create(ctx, obj, opts...) + }, + }).Build() + + request := Request{ + ClassName: "datum-fabric-identity", + Namespace: "datum-cloud", + NetworkNamespace: "ns-project", + NetworkName: "taptest", + } + + identity, err := Claim(context.Background(), ipamClient, request) + if err != nil { + t.Fatalf("allocate an identity: %v", err) + } + if identity != 1 { + t.Fatalf("expected identity 1, got %d", identity) + } + + var written ipamv1alpha1.IPClaim + key := client.ObjectKey{Namespace: request.Namespace, Name: ClaimName(request.NetworkNamespace, request.NetworkName)} + if err := ipamClient.Get(context.Background(), key, &written); err != nil { + t.Fatalf("read the claim back: %v", err) + } + + if written.Spec.IPFamily != ipamv1alpha1.IPv6 { + t.Fatalf("the claim must state IPv6, got %q; without it the server bounds a /%d against IPv4", + written.Spec.IPFamily, BlockBits) + } + if written.Spec.ClassName != request.ClassName { + t.Fatalf("expected the claim to name class %q, got %q", request.ClassName, written.Spec.ClassName) + } + if written.Spec.PrefixLength == nil || *written.Spec.PrefixLength != BlockBits { + t.Fatalf("expected the claim to ask for a /%d, got %v", BlockBits, written.Spec.PrefixLength) + } +} + +// admit mirrors the address service's own validation of a claim, which the fake +// client does not do. Only the rule this depends on is modelled: the bound on +// prefix length comes from the family stated on the claim, not from the class. +func admit(ipClaim *ipamv1alpha1.IPClaim) error { + if ipClaim.Spec.ClassName == "" && ipClaim.Spec.IPFamily == "" { + return apierrors.NewInvalid( + ipamv1alpha1.SchemeGroupVersion.WithKind("IPClaim").GroupKind(), ipClaim.Name, + field.ErrorList{field.Required(field.NewPath("spec"), "one of className or ipFamily is required")}) + } + p := ipClaim.Spec.PrefixLength + if p == nil { + return nil + } + maxLen := int32(32) + if ipClaim.Spec.IPFamily == ipamv1alpha1.IPv6 { + maxLen = 128 + } + if *p <= 0 || *p > maxLen { + return apierrors.NewInvalid( + ipamv1alpha1.SchemeGroupVersion.WithKind("IPClaim").GroupKind(), ipClaim.Name, + field.ErrorList{field.Invalid(field.NewPath("spec", "prefixLength"), *p, + fmt.Sprintf("must be between 1 and %d", maxLen))}) + } + return nil +} diff --git a/internal/cloud/galactic/galactic.go b/internal/cloud/galactic/galactic.go new file mode 100644 index 00000000..be1fbd11 --- /dev/null +++ b/internal/cloud/galactic/galactic.go @@ -0,0 +1,214 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package galactic is the vocabulary this operator shares with the galactic +// data plane: the CNI conflist a NetworkAttachmentDefinition carries, the +// kernel interface names derived from a (vpc, attachment) pair, and the +// BGPAdvertisement annotations galactic publishes back. +package galactic + +import ( + "encoding/json" + "fmt" + "strings" + + "go.datum.net/galactic/internal/cloud/identifier" +) + +const ( + // CNIVersion is the only conflist version galactic-bgp reconstructs a + // prevResult from; "1.1.0" also works, anything older fails every ADD. + CNIVersion = "1.0.0" + + // PluginVeth is the master plugin for container attachments. + PluginVeth = "galactic-veth" + + // PluginTap is the master plugin for virtual machine guests. + PluginTap = "galactic-tap" + + // PluginBGP publishes the attachment into BGP and SRv6. + PluginBGP = "galactic-bgp" + + // PluginIPAM is the delegated IPAM binary. + PluginIPAM = "galactic-ipam" + + // SystemNamespace holds the BGP CRDs the chain reads and writes. + SystemNamespace = "galactic-system" +) + +const ( + // AnnotationNetNS is the BGPAdvertisement annotation key prefix carrying a + // container's netns path. + AnnotationNetNS = "galactic.datum.net/netns" + + // AnnotationAllocatedSubnetIPv6 is the annotation key prefix carrying a + // container's allocated IPv6 pod subnet. + AnnotationAllocatedSubnetIPv6 = "galactic.datum.net/allocated-subnet-ipv6" + + // AnnotationAllocatedSubnetIPv4 is the annotation key prefix carrying a + // container's allocated IPv4 pod address. + AnnotationAllocatedSubnetIPv4 = "galactic.datum.net/allocated-subnet-ipv4" + + // AnnotationNoAddressing marks an advertisement whose guest manages its own + // addressing, so an empty prefix list is intentional. + AnnotationNoAddressing = "galactic.datum.net/no-addressing" + + // ConditionAdvertised is set on a BGPAdvertisement from live GoBGP state. + ConditionAdvertised = "Advertised" +) + +// NetConfList is a CNI conflist, the payload of a NAD's spec.config. +type NetConfList struct { + CNIVersion string `json:"cniVersion"` + Name string `json:"name"` + Plugins []any `json:"plugins"` +} + +// MasterPlugin is the galactic-veth or galactic-tap stanza. +type MasterPlugin struct { + Type string `json:"type"` + VPC string `json:"vpc"` + VPCAttachment string `json:"vpcattachment"` + Namespace string `json:"namespace"` + MTU int32 `json:"mtu,omitempty"` + // DAN asks the tap plugin to describe the device to the hypervisor in a + // file the hypervisor reads, rather than leaving it to be discovered from + // the node. Only the tap plugin reads it, and it is omitted when false so + // every conflist rendered until now is byte-identical. + DAN bool `json:"dan,omitempty"` + IPAM *IPAM `json:"ipam,omitempty"` +} + +// BGPPlugin is the galactic-bgp stanza. It is never optional: the master plugin +// fetches its own NAD and fails ADD before creating kernel state without it. +type BGPPlugin struct { + Type string `json:"type"` + VPC string `json:"vpc"` + VPCAttachment string `json:"vpcattachment"` + Namespace string `json:"namespace"` +} + +// IPAM is the delegated IPAM block. Presence alone decides whether IPAM runs. +type IPAM struct { + Type string `json:"type"` + Addresses []Address `json:"addresses,omitempty"` +} + +// Address is one pre-decided address, in CIDR notation, with the next hop the +// interface routes through for its family. Without the gateway the guest has an +// address but no route off its own link, and the host installs neither the tap +// gateway address nor the route to the attachment's prefix. +type Address struct { + Address string `json:"address"` + Gateway string `json:"gateway,omitempty"` +} + +// Conflist renders the conflist for one attachment. Addresses are the addresses +// NSO already allocated; an empty list means the guest addresses itself and no +// IPAM block is emitted. Set dan for a guest whose hypervisor is handed the +// device rather than discovering it. +func Conflist(name, plugin, vpc, vpcAttachment string, mtu int32, addresses []Address, dan bool) NetConfList { + master := MasterPlugin{ + Type: plugin, + VPC: vpc, + VPCAttachment: vpcAttachment, + Namespace: SystemNamespace, + MTU: mtu, + DAN: dan, + } + if len(addresses) > 0 { + master.IPAM = &IPAM{Type: PluginIPAM, Addresses: addresses} + } + return NetConfList{ + CNIVersion: CNIVersion, + Name: name, + Plugins: []any{ + master, + BGPPlugin{Type: PluginBGP, VPC: vpc, VPCAttachment: vpcAttachment, Namespace: SystemNamespace}, + }, + } +} + +// ConflistJSON renders the conflist as the string a NAD's spec.config holds. +func ConflistJSON(name, plugin, vpc, vpcAttachment string, mtu int32, addresses []Address, dan bool) (string, error) { + raw, err := json.Marshal(Conflist(name, plugin, vpc, vpcAttachment, mtu, addresses, dan)) + if err != nil { + return "", fmt.Errorf("marshal CNI conflist: %w", err) + } + return string(raw), nil +} + +// HostInterfaceName returns the host-side veth or tap device name. +func HostInterfaceName(vpc, vpcAttachment string) string { + return fmt.Sprintf("G%09s%03sH", vpc, vpcAttachment) +} + +// GuestInterfaceName returns the guest-side veth device name. +func GuestInterfaceName(vpc, vpcAttachment string) string { + return fmt.Sprintf("G%09s%03sG", vpc, vpcAttachment) +} + +// VRFInterfaceName returns the VRF device name, which is per-VPC rather than +// per-attachment. +func VRFInterfaceName(vpc string) string { + return fmt.Sprintf("G%09sV", vpc) +} + +// AdvertisementName returns the hex-encoded identity prefix shared by every +// node-qualified BGPAdvertisement galactic derives from an attachment. +func AdvertisementName(vpc, vpcAttachment string) string { + vpcHex, err := identifier.Base62ToHex(vpc) + if err != nil { + return "" + } + attachmentHex, err := identifier.Base62ToHex(vpcAttachment) + if err != nil { + return "" + } + return fmt.Sprintf("%s-%s", vpcHex, attachmentHex) +} + +// SplitAdvertisementName recovers the base62 (vpc, attachment) pair from a +// current node-qualified advertisement name. +func SplitAdvertisementName(name string) (vpc, vpcAttachment string, ok bool) { + parts := strings.SplitN(name, "-", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return "", "", false + } + vpc, err := identifier.HexToBase62(parts[0]) + if err != nil || vpc == "" { + return "", "", false + } + vpcAttachment, err = identifier.HexToBase62(parts[1]) + if err != nil || vpcAttachment == "" { + return "", "", false + } + return vpc, vpcAttachment, true +} + +// AllocatedSubnets returns every allocated subnet recorded on an advertisement, +// across both families and every container ID. +func AllocatedSubnets(annotations map[string]string) []string { + var subnets []string + for key, value := range annotations { + if strings.HasPrefix(key, AnnotationAllocatedSubnetIPv6+".") || + strings.HasPrefix(key, AnnotationAllocatedSubnetIPv4+".") { + subnets = append(subnets, value) + } + } + return subnets +} diff --git a/internal/cloud/galactic/galactic_test.go b/internal/cloud/galactic/galactic_test.go new file mode 100644 index 00000000..d718ff21 --- /dev/null +++ b/internal/cloud/galactic/galactic_test.go @@ -0,0 +1,171 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package galactic + +import ( + "encoding/json" + "testing" +) + +func TestConflistChainIsComplete(t *testing.T) { + conflist := Conflist("web-eth0", PluginTap, "0000000jU", "01a", 1400, + []Address{ + {Address: "fd00:10:ff01:0:1::1/96", Gateway: "fd00:10:ff01::1"}, + {Address: "172.20.1.7/32", Gateway: "172.20.1.1"}, + }, false) + + if conflist.CNIVersion != "1.0.0" { + t.Errorf("cniVersion: got %q, want %q", conflist.CNIVersion, "1.0.0") + } + if len(conflist.Plugins) != 2 { + t.Fatalf("plugin count: got %d, want 2", len(conflist.Plugins)) + } + + master, ok := conflist.Plugins[0].(MasterPlugin) + if !ok { + t.Fatalf("first plugin: got %T, want MasterPlugin", conflist.Plugins[0]) + } + if master.Type != PluginTap { + t.Errorf("master plugin: got %q, want %q", master.Type, PluginTap) + } + if master.IPAM == nil || len(master.IPAM.Addresses) != 2 { + t.Fatalf("ipam addresses: got %v, want two entries", master.IPAM) + } + // Without the gateway the guest has an address it cannot route off. + for _, address := range master.IPAM.Addresses { + if address.Gateway == "" { + t.Errorf("address %q carries no gateway", address.Address) + } + } + + // The master plugin fails ADD before creating kernel state without this. + bgp, ok := conflist.Plugins[1].(BGPPlugin) + if !ok { + t.Fatalf("second plugin: got %T, want BGPPlugin", conflist.Plugins[1]) + } + if bgp.Type != PluginBGP { + t.Errorf("bgp plugin: got %q, want %q", bgp.Type, PluginBGP) + } +} + +func TestConflistOmitsIPAMForSelfAddressingGuest(t *testing.T) { + raw, err := ConflistJSON("web-eth0", PluginTap, "0000000jU", "01a", 0, nil, false) + if err != nil { + t.Fatalf("ConflistJSON: %v", err) + } + + var decoded struct { + Plugins []map[string]any `json:"plugins"` + } + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("unmarshal conflist: %v", err) + } + if _, present := decoded.Plugins[0]["ipam"]; present { + t.Errorf("ipam block present for a guest managing its own addressing: %s", raw) + } + if _, present := decoded.Plugins[0]["mtu"]; present { + t.Errorf("mtu emitted when unset: %s", raw) + } +} + +func TestInterfaceNames(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"host", HostInterfaceName("jU", "1a"), "G0000000jU01aH"}, + {"guest", GuestInterfaceName("jU", "1a"), "G0000000jU01aG"}, + {"vrf", VRFInterfaceName("jU"), "G0000000jUV"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.got != test.want { + t.Errorf("got %q, want %q", test.got, test.want) + } + if len(test.got) > 15 { + t.Errorf("interface name %q exceeds the 15 character kernel limit", test.got) + } + }) + } +} + +func TestSplitAdvertisementName(t *testing.T) { + tests := []struct { + input string + vpc string + vpcAttachment string + ok bool + }{ + {"e008-d54-compute-c21d3c-us-east-1-staging-lab", "eV2", "T2", true}, + {"e007-d53-edge-567e29-us-east-1", "eV1", "T1", true}, + {"e008-d54", "", "", false}, + {"-d54-worker", "", "", false}, + {"e008--worker", "", "", false}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + vpc, vpcAttachment, ok := SplitAdvertisementName(test.input) + if vpc != test.vpc || vpcAttachment != test.vpcAttachment || ok != test.ok { + t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", + vpc, vpcAttachment, ok, test.vpc, test.vpcAttachment, test.ok) + } + }) + } +} + +func TestAdvertisementNameEncodesBase62IdentityAsHex(t *testing.T) { + if got, want := AdvertisementName("eV2", "T2"), "e008-d54"; got != want { + t.Errorf("AdvertisementName() = %q, want %q", got, want) + } + if got := AdvertisementName("not-valid!", "T2"); got != "" { + t.Errorf("AdvertisementName() with invalid VPC = %q, want empty", got) + } +} + +// The tap plugin reads this one field to decide whether it describes the device +// to the hypervisor. Every attachment rendered until now leaves it out, so its +// absence has to stay the default. +func TestConflistCarriesTheDeclaredDeviceRequest(t *testing.T) { + declared, err := ConflistJSON("vm-eth0", PluginTap, "0000000jU", "01a", 1400, nil, true) + if err != nil { + t.Fatalf("ConflistJSON: %v", err) + } + discovered, err := ConflistJSON("vm-eth0", PluginTap, "0000000jU", "01a", 1400, nil, false) + if err != nil { + t.Fatalf("ConflistJSON: %v", err) + } + + if got := masterStanza(t, declared)["dan"]; got != true { + t.Errorf("declared attachment: got %v, want true", got) + } + if _, present := masterStanza(t, discovered)["dan"]; present { + t.Errorf("discovered attachment carries the field: %s", discovered) + } +} + +func masterStanza(t *testing.T, raw string) map[string]any { + t.Helper() + var decoded struct { + Plugins []map[string]any `json:"plugins"` + } + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("unmarshal conflist: %v", err) + } + return decoded.Plugins[0] +} diff --git a/internal/cloud/identifier/identifier.go b/internal/cloud/identifier/identifier.go new file mode 100644 index 00000000..657d22d5 --- /dev/null +++ b/internal/cloud/identifier/identifier.go @@ -0,0 +1,95 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package identifier allocates the VPC and VPC attachment identifiers the +// galactic data plane keys on. Identifiers are generated as hex and published +// as base62, which is what keeps a kernel interface name inside 15 characters. +package identifier + +import ( + "crypto/rand" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/kenshaw/baseconv" +) + +const ( + // MaxVPC is the largest VPC identifier: 48 bits, nine base62 characters. + MaxVPC uint64 = 0xFFFFFFFFFFFF + + // MaxVPCAttachment is the largest attachment identifier: 16 bits, three + // base62 characters. + MaxVPCAttachment uint64 = 0xFFFF +) + +// Hex renders value as a zero-padded hex string wide enough to hold max. +// Zero and max are reserved. +func Hex(value, max uint64) (string, error) { + if value == 0 || value == max { + return "", fmt.Errorf("%d is a reserved identifier value", value) + } + if value > max { + return "", fmt.Errorf("%d exceeds the maximum identifier value %d", value, max) + } + return fmt.Sprintf("%0*x", len(strconv.FormatUint(max, 16)), value), nil +} + +// Random returns a random hex identifier in (0, max). +func Random(max uint64) (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(int64(max-1))) + if err != nil { + return "", fmt.Errorf("draw random identifier: %w", err) + } + return Hex(n.Uint64()+1, max) +} + +// RandomVPCAttachment returns a random 16-bit attachment identifier in hex. +func RandomVPCAttachment() (string, error) { + return Random(MaxVPCAttachment) +} + +// HexToBase62 converts a hex identifier to the base62 form the CNI chain reads. +func HexToBase62(value string) (string, error) { + return baseconv.Convert(strings.ToLower(value), baseconv.DigitsHex, baseconv.Digits62) +} + +// Base62ToHex converts a base62 identifier back to lowercase hex. +func Base62ToHex(value string) (string, error) { + return baseconv.Convert(value, baseconv.Digits62, baseconv.DigitsHex) +} + +// VPCBase62 renders a VPC identifier in base62, with the reserved-value guards +// and the width that keeps a kernel interface name inside fifteen characters. +func VPCBase62(value uint64) (string, error) { + hex, err := Hex(value, MaxVPC) + if err != nil { + return "", err + } + return HexToBase62(hex) +} + +// RandomVPCAttachmentBase62 returns a random attachment identifier in base62. +func RandomVPCAttachmentBase62() (string, error) { + hex, err := RandomVPCAttachment() + if err != nil { + return "", err + } + return HexToBase62(hex) +} diff --git a/internal/cloud/identifier/identifier_test.go b/internal/cloud/identifier/identifier_test.go new file mode 100644 index 00000000..5e7cda33 --- /dev/null +++ b/internal/cloud/identifier/identifier_test.go @@ -0,0 +1,87 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package identifier + +import "testing" + +func TestHexRejectsReservedValues(t *testing.T) { + for _, value := range []uint64{0, MaxVPC, MaxVPC + 1} { + if _, err := Hex(value, MaxVPC); err == nil { + t.Errorf("Hex(%d): got no error, want one", value) + } + } +} + +func TestRandomIdentifiersFitTheirInterfaceNameSegment(t *testing.T) { + for range 200 { + attachment, err := RandomVPCAttachmentBase62() + if err != nil { + t.Fatalf("RandomVPCAttachmentBase62: %v", err) + } + if len(attachment) > 3 { + t.Errorf("attachment identifier %q exceeds three base62 characters", attachment) + } + } +} + +func TestBase62RoundTrip(t *testing.T) { + hex, err := Random(MaxVPC) + if err != nil { + t.Fatalf("Random(MaxVPC): %v", err) + } + base62, err := HexToBase62(hex) + if err != nil { + t.Fatalf("HexToBase62(%q): %v", hex, err) + } + back, err := Base62ToHex(base62) + if err != nil { + t.Fatalf("Base62ToHex(%q): %v", base62, err) + } + if want := trimLeadingZeros(hex); back != want { + t.Errorf("round trip: got %q, want %q", back, want) + } +} + +func trimLeadingZeros(value string) string { + for i := range value { + if value[i] != '0' { + return value[i:] + } + } + return "0" +} + +func TestVPCBase62RejectsReservedValuesAndFitsItsSegment(t *testing.T) { + for _, value := range []uint64{0, MaxVPC, MaxVPC + 1} { + if _, err := VPCBase62(value); err == nil { + t.Errorf("VPCBase62(%d): got no error, want one", value) + } + } + + // The widest fabric identity is 32 bits, well inside the 48 the VPC + // identifier holds, so nothing an identity carries can overflow the segment. + for _, value := range []uint64{1, 16, 1 << 31, 1<<32 - 1} { + encoded, err := VPCBase62(value) + if err != nil { + t.Fatalf("VPCBase62(%d): %v", value, err) + } + if len(encoded) > 9 { + t.Errorf("VPCBase62(%d) = %q exceeds nine base62 characters", value, encoded) + } + } +} diff --git a/internal/cloud/ipam/client.go b/internal/cloud/ipam/client.go new file mode 100644 index 00000000..c7ed84bc --- /dev/null +++ b/internal/cloud/ipam/client.go @@ -0,0 +1,159 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package ipam reaches the platform's address management service. +// +// Every request names the tenancy it is made for, so nothing can allocate +// without saying on whose behalf. Two tenancies exist: a consumer's own +// project, and the platform itself. +package ipam + +import ( + "errors" + "fmt" + "net/url" + "sync" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// resourceManagerGroup is the API group whose project path addresses one +// project's control plane. It is a constant rather than an import so that +// reaching IPAM does not pull the whole resource manager API surface in for the +// sake of one string. +const resourceManagerGroup = "resourcemanager.miloapis.com" + +// ClientFactory returns a client bound to one tenancy. +type ClientFactory interface { + // ClientForProject reaches IPAM on a consumer's behalf, inside their own + // project. What it allocates is theirs, counts against their quota, and is + // unique only among their own allocations. + ClientForProject(project string) (client.Client, error) + + // ClientForPlatform reaches IPAM on the platform's own behalf, for values + // that must be unique across every consumer and must not be gated on one + // enabling the address service or draw on their quota. + // + // A network's fabric identity is the case this exists for: uniqueness per + // project is not uniqueness, and a consumer never asked for the value and + // cannot see it. + // + // IPAM has no platform tenancy of its own, so today this is one project + // control plane the platform owns. The seam is here so that when it gains + // one, nothing above this line changes. + ClientForPlatform() (client.Client, error) +} + +// NewClientFactory builds tenancy-scoped clients from one connection. The +// clients are uncached, because a cache would watch every project served. +// +// platformProject names the control plane platform-owned allocations are made +// in. Empty means the deployment allocates nothing platform-scoped. +func NewClientFactory(base *rest.Config, scheme *runtime.Scheme, platformProject string) (ClientFactory, error) { + if base == nil { + return nil, errors.New("a rest config is required") + } + return &projectPathClientFactory{ + base: base, + scheme: scheme, + platformProject: platformProject, + clients: map[string]client.Client{}, + }, nil +} + +type projectPathClientFactory struct { + base *rest.Config + scheme *runtime.Scheme + platformProject string + + mu sync.Mutex + clients map[string]client.Client +} + +func (f *projectPathClientFactory) ClientForPlatform() (client.Client, error) { + if f.platformProject == "" { + return nil, ErrNoPlatformTenancy + } + return f.ClientForProject(f.platformProject) +} + +func (f *projectPathClientFactory) ClientForProject(project string) (client.Client, error) { + if project == "" { + return nil, ErrNoProject + } + + f.mu.Lock() + defer f.mu.Unlock() + + if existing, ok := f.clients[project]; ok { + return existing, nil + } + + cfg, err := f.configForProject(project) + if err != nil { + return nil, err + } + + cl, err := client.New(cfg, client.Options{Scheme: f.scheme}) + if err != nil { + return nil, fmt.Errorf("build IPAM client for project %q: %w", project, err) + } + + f.clients[project] = cl + return cl, nil +} + +// configForProject addresses the base connection at one project's control +// plane. The path names the project, so the platform authorizes this +// operator's own identity against that project rather than trusting a +// caller-supplied parent. Any path the base host already carries is replaced, +// not extended. +func (f *projectPathClientFactory) configForProject(project string) (*rest.Config, error) { + cfg := rest.CopyConfig(f.base) + + host, err := url.Parse(cfg.Host) + if err != nil { + return nil, fmt.Errorf("parse IPAM host %q: %w", cfg.Host, err) + } + host.Path = fmt.Sprintf("/apis/%s/v1alpha1/projects/%s/control-plane", resourceManagerGroup, project) + cfg.Host = host.String() + + return cfg, nil +} + +// Scheme is the scheme a tenancy-scoped IPAM client is built with. +func Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + return nil, err + } + if err := ipamv1alpha1.AddToScheme(scheme); err != nil { + return nil, err + } + return scheme, nil +} + +// ErrNoProject says a request named no project, which is never a default. +var ErrNoProject = errors.New("no project") + +// ErrNoPlatformTenancy says the deployment named no place for the platform to +// allocate what it owns, so it allocates none of it. +var ErrNoPlatformTenancy = errors.New("no platform tenancy is configured") diff --git a/internal/cloud/leaderelection/leaderelection.go b/internal/cloud/leaderelection/leaderelection.go new file mode 100644 index 00000000..f4a11635 --- /dev/null +++ b/internal/cloud/leaderelection/leaderelection.go @@ -0,0 +1,42 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package leaderelection + +import ( + "net" + "time" + + "k8s.io/client-go/rest" +) + +const ( + QPS = 5 + Burst = 10 +) + +func RestConfig(base *rest.Config) *rest.Config { + cfg := rest.CopyConfig(base) + cfg.RateLimiter = nil + cfg.QPS = QPS + cfg.Burst = Burst + cfg.Dial = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext + return cfg +} diff --git a/internal/cloud/leaderelection/leaderelection_test.go b/internal/cloud/leaderelection/leaderelection_test.go new file mode 100644 index 00000000..b1ca341a --- /dev/null +++ b/internal/cloud/leaderelection/leaderelection_test.go @@ -0,0 +1,145 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package leaderelection + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "k8s.io/apimachinery/pkg/runtime/schema" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/flowcontrol" +) + +type remoteAddrRecorder struct { + mu sync.Mutex + addrs []string +} + +func (r *remoteAddrRecorder) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.addrs = append(r.addrs, req.RemoteAddr) + r.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) +} + +func (r *remoteAddrRecorder) last() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.addrs[len(r.addrs)-1] +} + +func restClientFor(t *testing.T, cfg *rest.Config) *rest.RESTClient { + t.Helper() + cfg = rest.CopyConfig(cfg) + cfg.GroupVersion = &schema.GroupVersion{Group: "coordination.k8s.io", Version: "v1"} + cfg.APIPath = "/apis" + cfg.NegotiatedSerializer = clientgoscheme.Codecs.WithoutConversion() + c, err := rest.RESTClientFor(cfg) + if err != nil { + t.Fatalf("building REST client: %v", err) + } + return c +} + +func getLease(ctx context.Context, c *rest.RESTClient) error { + return c.Get().Namespace("default").Resource("leases").Name("lease").Do(ctx).Error() +} + +func TestLeaderElectionRestConfigDoesNotMutateBase(t *testing.T) { + limiter := flowcontrol.NewTokenBucketRateLimiter(1, 1) + base := &rest.Config{Host: "https://example.invalid", QPS: 50, Burst: 100, RateLimiter: limiter} + + cfg := RestConfig(base) + + if cfg.RateLimiter != nil { + t.Errorf("leader election config reuses the base rate limiter") + } + if cfg.QPS != QPS || cfg.Burst != Burst { + t.Errorf("got QPS %v Burst %d, want %v and %d", cfg.QPS, cfg.Burst, QPS, Burst) + } + if cfg.Dial == nil { + t.Errorf("leader election config has no dialer of its own") + } + if base.RateLimiter != limiter || base.QPS != 50 || base.Burst != 100 || base.Dial != nil { + t.Errorf("base config was mutated: %+v", base) + } +} + +func TestLeaderElectionIgnoresSaturatedMainLimiter(t *testing.T) { + srv := httptest.NewServer(&remoteAddrRecorder{}) + defer srv.Close() + + base := &rest.Config{ + Host: srv.URL, + RateLimiter: flowcontrol.NewTokenBucketRateLimiter(0.001, 1), + } + mainClient := restClientFor(t, base) + + if err := getLease(context.Background(), mainClient); err != nil { + t.Fatalf("first request on main client: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := getLease(ctx, mainClient); err == nil { + t.Fatalf("expected the main client to be throttled") + } + + leaderClient := restClientFor(t, RestConfig(base)) + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := getLease(ctx, leaderClient); err != nil { + t.Fatalf("leader election request was throttled by the main limiter: %v", err) + } +} + +func TestLeaderElectionUsesItsOwnConnection(t *testing.T) { + rec := &remoteAddrRecorder{} + srv := httptest.NewServer(rec) + defer srv.Close() + + base := &rest.Config{Host: srv.URL, QPS: -1} + mainClient := restClientFor(t, base) + ctx := context.Background() + + if err := getLease(ctx, mainClient); err != nil { + t.Fatalf("main client request: %v", err) + } + mainAddr := rec.last() + + if err := getLease(ctx, restClientFor(t, base)); err != nil { + t.Fatalf("second main client request: %v", err) + } + if rec.last() != mainAddr { + t.Fatalf("clients built from the same config did not share a connection, so this test cannot tell connections apart") + } + + if err := getLease(ctx, restClientFor(t, RestConfig(base))); err != nil { + t.Fatalf("leader election request: %v", err) + } + if rec.last() == mainAddr { + t.Errorf("leader election reused the main client's connection %s", mainAddr) + } +} diff --git a/internal/cloud/webhook/pod_webhook.go b/internal/cloud/webhook/pod_webhook.go new file mode 100644 index 00000000..caa65fd8 --- /dev/null +++ b/internal/cloud/webhook/pod_webhook.go @@ -0,0 +1,224 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package webhook delivers a prepared attachment to the Pod that consumes it. +// Injecting the annotation here is what keeps Multus knowledge inside the one +// component that writes NetworkAttachmentDefinitions. +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/galactic/internal/cloud/controller" +) + +const ( + // InjectInterfacesLabel is the opt-in an infrastructure provider stamps on a + // Pod. It is a label rather than an annotation because objectSelector matches + // only labels, and that selector is what bounds a failurePolicy of Fail to + // the Pods that need an interface. + InjectInterfacesLabel = "networking.datumapis.com/inject-interfaces" + + // InjectedInterfacesAnnotation records which interfaces were injected, so the + // difference between the Pod applied and the Pod admitted is traceable. + InjectedInterfacesAnnotation = "networking.datumapis.com/injected-interfaces" + + // MultusNetworksAnnotation is what Multus resolves at sandbox creation. + MultusNetworksAnnotation = "k8s.v1.cni.cncf.io/networks" + + // MultusDefaultNetworkAnnotation displaces the cluster's default network for + // this Pod, so the instance's first interface carries its traffic rather than + // whatever CNI the cluster installs by default. + MultusDefaultNetworkAnnotation = "v1.multus-cni.io/default-network" + + // WebhookPath is the path the mutating webhook configuration points at. + WebhookPath = "/mutate-v1-pod" + + // instanceKind is the owner a Pod must resolve to for interfaces to inject. + instanceKind = "Instance" +) + +// PodInterfaceInjector injects the attachments prepared for a Pod's instance. +type PodInterfaceInjector struct { + client.Client + Decoder admission.Decoder +} + +// +kubebuilder:rbac:groups=compute.datumapis.com,resources=instances,verbs=get;list;watch + +// Handle resolves a Pod to its instance's interfaces and injects the annotation +// that delivers them. +func (i *PodInterfaceInjector) Handle(ctx context.Context, req admission.Request) admission.Response { + pod := &corev1.Pod{} + if err := i.Decoder.Decode(req, pod); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + if pod.Labels[InjectInterfacesLabel] != "true" { + return admission.Allowed("pod did not opt in to interface injection") + } + + namespace := req.Namespace + instanceName, found := instanceOwner(pod) + if !found { + return admission.Denied(fmt.Sprintf( + "pod carries %s but is not owned by a %s, so its interfaces cannot be resolved", + InjectInterfacesLabel, instanceKind)) + } + + var instance computev1alpha.Instance + if err := i.Get(ctx, types.NamespacedName{Namespace: namespace, Name: instanceName}, &instance); err != nil { + if apierrors.IsNotFound(err) { + return admission.Denied(fmt.Sprintf("instance %s/%s does not exist", namespace, instanceName)) + } + return admission.Errored(http.StatusInternalServerError, err) + } + + networks, injected, response := i.resolveNetworks(ctx, &instance, namespace) + if response != nil { + return *response + } + if len(networks) == 0 { + return admission.Allowed("instance declares no network interfaces") + } + + if pod.Annotations == nil { + pod.Annotations = map[string]string{} + } + injectNetworks(pod.Annotations, networks) + pod.Annotations[InjectedInterfacesAnnotation] = strings.Join(injected, ",") + + patched, err := json.Marshal(pod) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + ctrl.LoggerFrom(ctx).Info("injected network interfaces into pod", + "instance", instanceName, "interfaces", injected, + "networks", pod.Annotations[MultusNetworksAnnotation], + "defaultNetwork", pod.Annotations[MultusDefaultNetworkAnnotation]) + return admission.PatchResponseFromRaw(req.Object.Raw, patched) +} + +// resolveNetworks walks the instance's interfaces in declared order and finds the +// attachment definition prepared for each. +func (i *PodInterfaceInjector) resolveNetworks( + ctx context.Context, instance *computev1alpha.Instance, namespace string, +) (networks, injected []string, denied *admission.Response) { + for _, declared := range instance.Spec.NetworkInterfaces { + interfaceName := interfaceRefFor(instance, declared.Name) + if interfaceName == "" { + response := admission.Denied(fmt.Sprintf( + "instance %s has no bound NetworkInterface for %s yet", instance.Name, declared.Name)) + return nil, nil, &response + } + + // The controller created this NAD alongside the interface, so it shares + // its name; the convention never crosses a component boundary. + var nad nadv1.NetworkAttachmentDefinition + key := types.NamespacedName{Namespace: namespace, Name: interfaceName} + if err := i.Get(ctx, key, &nad); err != nil { + response := admission.Denied(fmt.Sprintf( + "no attachment definition prepared for NetworkInterface %s: %v", key, err)) + return nil, nil, &response + } + if _, ours := nad.Labels[controller.LabelVPCAttachment]; !ours { + response := admission.Denied(fmt.Sprintf( + "attachment definition %s carries no attachment identifier", key)) + return nil, nil, &response + } + + networks = append(networks, fmt.Sprintf("%s/%s", nad.Namespace, nad.Name)) + injected = append(injected, interfaceName) + } + return networks, injected, nil +} + +// interfaceRefFor returns the NetworkInterface bound to an instance's entry. +func interfaceRefFor(instance *computev1alpha.Instance, name string) string { + for _, status := range instance.Status.NetworkInterfaces { + if status.Name != name || status.NetworkInterfaceRef == nil { + continue + } + return status.NetworkInterfaceRef.Name + } + return "" +} + +// instanceOwner returns the name of the Instance controlling a Pod. +func instanceOwner(pod *corev1.Pod) (string, bool) { + for _, owner := range pod.OwnerReferences { + if owner.Kind != instanceKind || owner.Controller == nil || !*owner.Controller { + continue + } + if group, _, _ := strings.Cut(owner.APIVersion, "/"); group != computev1alpha.GroupVersion.Group { + continue + } + return owner.Name, true + } + return "", false +} + +// injectNetworks makes the first network the Pod's default and lists the rest as +// additional networks. Multus attaches the default network as eth0 on its own, so +// listing it again would attach the same definition a second time as net1. +func injectNetworks(annotations map[string]string, networks []string) { + defaultNetwork := networks[0] + annotations[MultusDefaultNetworkAnnotation] = defaultNetwork + + merged := mergeNetworks(annotations[MultusNetworksAnnotation], networks[1:], defaultNetwork) + if merged == "" { + delete(annotations, MultusNetworksAnnotation) + return + } + annotations[MultusNetworksAnnotation] = merged +} + +// mergeNetworks appends the resolved networks to whatever the Pod already asked +// for, without duplicating an entry or repeating the default network. +func mergeNetworks(existing string, networks []string, defaultNetwork string) string { + merged := []string{} + for entry := range strings.SplitSeq(existing, ",") { + if entry = strings.TrimSpace(entry); entry != "" && entry != defaultNetwork { + merged = append(merged, entry) + } + } + for _, network := range networks { + if !slices.Contains(merged, network) { + merged = append(merged, network) + } + } + return strings.Join(merged, ",") +} + +// SetupWithManager registers the webhook with the manager's webhook server. +func (i *PodInterfaceInjector) SetupWithManager(mgr ctrl.Manager) { + i.Decoder = admission.NewDecoder(mgr.GetScheme()) + mgr.GetWebhookServer().Register(WebhookPath, &admission.Webhook{Handler: i}) +} diff --git a/internal/cloud/webhook/pod_webhook_test.go b/internal/cloud/webhook/pod_webhook_test.go new file mode 100644 index 00000000..b79327b1 --- /dev/null +++ b/internal/cloud/webhook/pod_webhook_test.go @@ -0,0 +1,134 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package webhook + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + computev1alpha "go.datum.net/compute/api/v1alpha" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func TestInstanceOwner(t *testing.T) { + tests := []struct { + name string + owner metav1.OwnerReference + want string + }{ + {"controlling instance", metav1.OwnerReference{ + APIVersion: "compute.datumapis.com/v1alpha", Kind: "Instance", + Name: "web-0", Controller: ptr.To(true)}, "web-0"}, + {"not controlling", metav1.OwnerReference{ + APIVersion: "compute.datumapis.com/v1alpha", Kind: "Instance", Name: "web-0"}, ""}, + {"another group's instance", metav1.OwnerReference{ + APIVersion: "example.com/v1", Kind: "Instance", + Name: "web-0", Controller: ptr.To(true)}, ""}, + {"a replicaset", metav1.OwnerReference{ + APIVersion: "apps/v1", Kind: "ReplicaSet", + Name: "web", Controller: ptr.To(true)}, ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{OwnerReferences: []metav1.OwnerReference{test.owner}}} + got, found := instanceOwner(pod) + if got != test.want || found != (test.want != "") { + t.Errorf("got (%q, %v), want (%q, %v)", got, found, test.want, test.want != "") + } + }) + } +} + +func TestInterfaceRefFor(t *testing.T) { + instance := &computev1alpha.Instance{ + Status: computev1alpha.InstanceStatus{ + NetworkInterfaces: []computev1alpha.InstanceNetworkInterfaceStatus{ + {Name: "eth0", NetworkInterfaceRef: &networkingv1alpha.LocalNetworkInterfaceRef{Name: "web-0-eth0"}}, + {Name: "eth1"}, + }, + }, + } + if got := interfaceRefFor(instance, "eth0"); got != "web-0-eth0" { + t.Errorf("bound interface: got %q, want %q", got, "web-0-eth0") + } + if got := interfaceRefFor(instance, "eth1"); got != "" { + t.Errorf("unbound interface: got %q, want empty", got) + } + if got := interfaceRefFor(instance, "eth9"); got != "" { + t.Errorf("undeclared interface: got %q, want empty", got) + } +} + +func TestMergeNetworks(t *testing.T) { + tests := []struct { + name string + existing string + networks []string + want string + }{ + {"empty", "", []string{"ns/a"}, "ns/a"}, + {"nothing to add", "", nil, ""}, + {"appends in order", "", []string{"ns/a", "ns/b"}, "ns/a,ns/b"}, + {"preserves what the pod asked for", "ns/other", []string{"ns/a"}, "ns/other,ns/a"}, + {"does not duplicate", "ns/a", []string{"ns/a"}, "ns/a"}, + {"tolerates whitespace", " ns/other , ", []string{"ns/a"}, "ns/other,ns/a"}, + {"drops the default network", "ns/default,ns/other", []string{"ns/a"}, "ns/other,ns/a"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := mergeNetworks(test.existing, test.networks, "ns/default"); got != test.want { + t.Errorf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestInjectNetworks(t *testing.T) { + tests := []struct { + name string + existing map[string]string + networks []string + wantDefault string + wantList string + wantListSet bool + }{ + {"single interface is only the default", map[string]string{}, + []string{"ns/a"}, "ns/a", "", false}, + {"additional interfaces exclude the default", map[string]string{}, + []string{"ns/a", "ns/b", "ns/c"}, "ns/a", "ns/b,ns/c", true}, + {"preserves what the pod asked for", map[string]string{MultusNetworksAnnotation: "ns/other"}, + []string{"ns/a"}, "ns/a", "ns/other", true}, + {"drops a default the pod already listed", map[string]string{MultusNetworksAnnotation: "ns/a"}, + []string{"ns/a", "ns/b"}, "ns/a", "ns/b", true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + injectNetworks(test.existing, test.networks) + if got := test.existing[MultusDefaultNetworkAnnotation]; got != test.wantDefault { + t.Errorf("default network: got %q, want %q", got, test.wantDefault) + } + got, set := test.existing[MultusNetworksAnnotation] + if got != test.wantList || set != test.wantListSet { + t.Errorf("networks: got (%q, %v), want (%q, %v)", got, set, test.wantList, test.wantListSet) + } + }) + } +} diff --git a/internal/cnibgp/bgp.go b/internal/cnibgp/bgp.go index de90ea33..4e4c760c 100644 --- a/internal/cnibgp/bgp.go +++ b/internal/cnibgp/bgp.go @@ -39,13 +39,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/cniipam" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/gc" "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // maxRetries is the number of retry attempts for transient Kubernetes API diff --git a/internal/cnibgp/bgp_test.go b/internal/cnibgp/bgp_test.go index e0d33d1b..1235ef73 100644 --- a/internal/cnibgp/bgp_test.go +++ b/internal/cnibgp/bgp_test.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/cni/veth" "go.datum.net/galactic/internal/cniipam" "go.datum.net/galactic/internal/config" @@ -35,7 +36,6 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/plumbing/vrf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( @@ -1034,7 +1034,7 @@ func TestIsTransientError(t *testing.T) { {"validation error is not transient", apierrors.NewBadRequest("bad request"), false}, { "not found error is not transient", - apierrors.NewNotFound(schema.GroupResource{Group: "network.datumapis.com", Resource: "bgpadvertisements"}, "test"), + apierrors.NewNotFound(schema.GroupResource{Group: "galactic.datumapis.com", Resource: "bgpadvertisements"}, "test"), false, }, {"503 service unavailable is transient", apierrors.NewServiceUnavailable("service unavailable"), true}, diff --git a/internal/cnibgp/ops_check.go b/internal/cnibgp/ops_check.go index f4372379..9b3a6a0a 100644 --- a/internal/cnibgp/ops_check.go +++ b/internal/cnibgp/ops_check.go @@ -22,6 +22,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/nadpatch" @@ -34,7 +35,6 @@ import ( "go.datum.net/galactic/internal/plumbing/intf" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/plumbing/vrf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // cmdCheck verifies that the state cmdAdd published is still in place: the diff --git a/internal/cnibgp/ops_check_test.go b/internal/cnibgp/ops_check_test.go index 8517073f..9d54b486 100644 --- a/internal/cnibgp/ops_check_test.go +++ b/internal/cnibgp/ops_check_test.go @@ -12,10 +12,10 @@ import ( discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) func withNodeName(t *testing.T, nodeName string) { diff --git a/internal/cnibgp/resource.go b/internal/cnibgp/resource.go index dbc0789e..71164311 100644 --- a/internal/cnibgp/resource.go +++ b/internal/cnibgp/resource.go @@ -16,8 +16,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) var cniScheme = runtime.NewScheme() diff --git a/internal/cnibgp/resource_test.go b/internal/cnibgp/resource_test.go index b42f7ec4..94d79cf6 100644 --- a/internal/cnibgp/resource_test.go +++ b/internal/cnibgp/resource_test.go @@ -12,8 +12,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const testNodeName = "dfw-worker" diff --git a/internal/cnimaster/resource.go b/internal/cnimaster/resource.go index 45344f57..6b567227 100644 --- a/internal/cnimaster/resource.go +++ b/internal/cnimaster/resource.go @@ -15,7 +15,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // NADPatchTimeout bounds each API call a master plugin makes with the client diff --git a/internal/controller/bgpadvertisement_controller.go b/internal/controller/bgpadvertisement_controller.go index c4684f5d..9c8ac62a 100644 --- a/internal/controller/bgpadvertisement_controller.go +++ b/internal/controller/bgpadvertisement_controller.go @@ -11,7 +11,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // BGPAdvertisementReconciler watches BGPAdvertisement resources and enqueues diff --git a/internal/controller/bgppeer_controller.go b/internal/controller/bgppeer_controller.go index ba870084..80def2d0 100644 --- a/internal/controller/bgppeer_controller.go +++ b/internal/controller/bgppeer_controller.go @@ -12,7 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // BGPPeerReconciler watches BGPPeer resources and enqueues the owning BGPRouter(s). diff --git a/internal/controller/bgppolicy_controller.go b/internal/controller/bgppolicy_controller.go index d4c64677..76f5468d 100644 --- a/internal/controller/bgppolicy_controller.go +++ b/internal/controller/bgppolicy_controller.go @@ -12,7 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // BGPPolicyReconciler watches BGPPolicy resources and enqueues the diff --git a/internal/controller/bgprouter_controller.go b/internal/controller/bgprouter_controller.go index 6d95bc09..f0d37ed7 100644 --- a/internal/controller/bgprouter_controller.go +++ b/internal/controller/bgprouter_controller.go @@ -22,11 +22,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/model" "go.datum.net/galactic/internal/plumbing/nptv6" "go.datum.net/galactic/internal/reconcile" galacticruntime "go.datum.net/galactic/internal/runtime" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // annotationConfigHash is the annotation key used to persist the last-applied diff --git a/internal/controller/bgprouter_vrf_status_test.go b/internal/controller/bgprouter_vrf_status_test.go index 03c905f1..97356f57 100644 --- a/internal/controller/bgprouter_vrf_status_test.go +++ b/internal/controller/bgprouter_vrf_status_test.go @@ -16,10 +16,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/hash" "go.datum.net/galactic/internal/model" "go.datum.net/galactic/internal/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/controller/bgpvrfinstance_controller.go b/internal/controller/bgpvrfinstance_controller.go index fb84066e..2cf2f1a9 100644 --- a/internal/controller/bgpvrfinstance_controller.go +++ b/internal/controller/bgpvrfinstance_controller.go @@ -14,7 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // BGPVRFInstanceReconciler watches BGPVRFInstance resources and enqueues diff --git a/internal/controller/egressshard_controller.go b/internal/controller/egressshard_controller.go index 6c5dcc49..62bcccfd 100644 --- a/internal/controller/egressshard_controller.go +++ b/internal/controller/egressshard_controller.go @@ -25,9 +25,9 @@ import ( ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/source" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // EgressShardIdentity is the identity an egress translation datapath is diff --git a/internal/controller/egressshard_controller_test.go b/internal/controller/egressshard_controller_test.go index 9c7e801f..6da49779 100644 --- a/internal/controller/egressshard_controller_test.go +++ b/internal/controller/egressshard_controller_test.go @@ -19,7 +19,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( diff --git a/internal/controller/indexer.go b/internal/controller/indexer.go index 2af39665..0136a87f 100644 --- a/internal/controller/indexer.go +++ b/internal/controller/indexer.go @@ -12,8 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // Field index names used across controllers. diff --git a/internal/controller/networkgateway_controller.go b/internal/controller/networkgateway_controller.go index a5706fad..2427848c 100644 --- a/internal/controller/networkgateway_controller.go +++ b/internal/controller/networkgateway_controller.go @@ -31,9 +31,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/gateway" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // GatewayEngine is the interface NetworkGatewayReconciler drives, satisfied by @@ -44,13 +44,13 @@ type GatewayEngine interface { Reconcile(ctx context.Context, desired gateway.EngineState) (gateway.EngineStatus, error) // DatapathGeneration returns the datapath's current generation counter. It - // must be captured before the NetworkRule CRDs are listed; see + // must be captured before the NetworkService CRDs are listed; see // ReconcileOrphans. DatapathGeneration() uint64 // ReconcileOrphans removes vip_table state left behind by a mid-reconcile // crash. cutoff must come from DatapathGeneration, read before desired's - // NetworkRule CRDs were listed, so an entry written during the listing + // NetworkService CRDs were listed, so an entry written during the listing // survives. ReconcileOrphans(ctx context.Context, desired gateway.EngineState, cutoff uint64) error @@ -65,7 +65,7 @@ type GatewayEngine interface { // Each pass does three things: // // 1. Assembles a gateway.EngineState from every accepted, non-deleting -// NetworkRule in the namespace, resolving each backend's SRv6 uSID, and +// NetworkService in the namespace, resolving each backend's SRv6 uSID, and // converges the engine toward it. Under the anycast model every gateway // node in a PoP serves every accepted rule identically, so there is no // primary or secondary node to gate on. A deleting rule stays in that @@ -278,12 +278,12 @@ func (r *NetworkGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Reque } // Crash-safety ordering contract (see GatewayEngine.ReconcileOrphans): - // cutoff must be captured before desired's NetworkRule CRDs are listed. + // cutoff must be captured before desired's NetworkService CRDs are listed. cutoff := r.Engine.DatapathGeneration() - ruleList := &bgpv1alpha1.NetworkRuleList{} + ruleList := &bgpv1alpha1.NetworkServiceList{} if err := r.List(ctx, ruleList, client.InNamespace(gw.Namespace)); err != nil { - return ctrl.Result{}, fmt.Errorf("list NetworkRules for NetworkGateway %s: %w", req.NamespacedName, err) + return ctrl.Result{}, fmt.Errorf("list NetworkServices for NetworkGateway %s: %w", req.NamespacedName, err) } routerName, err := r.routerNameForNode(ctx, gw.Namespace) @@ -338,7 +338,7 @@ func (r *NetworkGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Reque sweepErr := r.sweepOrphanedAdvertisements(ctx, gw.Namespace, ruleList.Items) if sweepErr != nil { - logger.Error(sweepErr, "withdraw advertisements for deleted NetworkRules") + logger.Error(sweepErr, "withdraw advertisements for deleted NetworkServices") } // Crash recovery. A failed sweep leaves orphaned vip_table state behind @@ -359,12 +359,12 @@ func (r *NetworkGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Reque } // gatherRules builds this pass's engine state and rule outcomes from rules, -// every NetworkRule in namespace, and settles the drain tracker against it. +// every NetworkService in namespace, and settles the drain tracker against it. // attachments holds every VPCAttachment in the cluster; each rule selects from // those in its own VPC (groupAttachmentsByVPC). It returns how long until a // held rule's drain delay ends, or zero. func (r *NetworkGatewayReconciler) gatherRules( - ctx context.Context, namespace string, rules []bgpv1alpha1.NetworkRule, + ctx context.Context, namespace string, rules []bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, sidIndex *backendSIDIndex, ) (gateway.EngineState, []ruleOutcome, time.Duration, error) { logger := log.FromContext(ctx) @@ -388,7 +388,7 @@ func (r *NetworkGatewayReconciler) gatherRules( for i := range rules { rule := &rules[i] if !rule.DeletionTimestamp.IsZero() { - // Being torn down. NetworkRuleReconciler's finalizer owns the + // Being torn down. NetworkServiceReconciler's finalizer owns the // rule's advertisements: reconcileDelete deletes every one // carrying networkRuleLabel, whichever node made it. Until they // are all gone the route may still draw traffic here, so the rule @@ -450,7 +450,7 @@ func (r *NetworkGatewayReconciler) clock() time.Time { // kept instead. If this node never loaded it, there is nothing to keep. // attachments are the candidates for rule's backends, as for buildDesiredRule. func (r *NetworkGatewayReconciler) drainingOutcome( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, + ctx context.Context, rule *bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, sidIndex *backendSIDIndex, owners backendOwners, ) (ruleOutcome, bool, error) { if !controllerutil.ContainsFinalizer(rule, networkRuleFinalizer) { @@ -461,7 +461,7 @@ func (r *NetworkGatewayReconciler) drainingOutcome( client.InNamespace(rule.Namespace), client.MatchingLabels{networkRuleLabel: rule.Name}, ); err != nil { - return ruleOutcome{}, false, fmt.Errorf("list BGPAdvertisements for deleting NetworkRule %s/%s: %w", + return ruleOutcome{}, false, fmt.Errorf("list BGPAdvertisements for deleting NetworkService %s/%s: %w", rule.Namespace, rule.Name, err) } if len(advList.Items) == 0 { @@ -478,11 +478,11 @@ func (r *NetworkGatewayReconciler) drainingOutcome( return ruleOutcome{rule: rule, desired: dr, draining: true}, true, nil } -// ruleOutcome is one accepted NetworkRule's result from a reconcile pass: the +// ruleOutcome is one accepted NetworkService's result from a reconcile pass: the // engine state it built, or why it could not be built. unresolved lists the // backends left out of desired because their uSID did not resolve. type ruleOutcome struct { - rule *bgpv1alpha1.NetworkRule + rule *bgpv1alpha1.NetworkService desired gateway.DesiredRule unresolved []string buildErr error @@ -526,18 +526,18 @@ func (r *NetworkGatewayReconciler) publishRuleOutcomes( switch { case cond.Status != metav1.ConditionTrue: if err := withdrawRuleAdvertisements(ctx, r.Client, o.rule, r.NodeName); err != nil { - logger.Error(err, "withdraw BGPAdvertisements for unloaded NetworkRule", "networkRule", o.rule.Name) + logger.Error(err, "withdraw BGPAdvertisements for unloaded NetworkService", "networkRule", o.rule.Name) advErrs = append(advErrs, fmt.Errorf("networkRule %s: %w", o.rule.Name, err)) } case routerName != "": if err := r.applyBGPAdvertisements(ctx, o.rule, o.desired, routerName); err != nil { - logger.Error(err, "apply BGPAdvertisements for NetworkRule", "networkRule", o.rule.Name) + logger.Error(err, "apply BGPAdvertisements for NetworkService", "networkRule", o.rule.Name) advErrs = append(advErrs, fmt.Errorf("networkRule %s: %w", o.rule.Name, err)) } } if err := r.setRuleConditions(ctx, o.rule, cond, ipv4ServedCondition(r.NodeName, o.rule)); err != nil { - logger.Error(err, "update NetworkRule conditions", "networkRule", o.rule.Name) + logger.Error(err, "update NetworkService conditions", "networkRule", o.rule.Name) statusErrs = append(statusErrs, fmt.Errorf("networkRule %s status: %w", o.rule.Name, err)) } } @@ -613,7 +613,7 @@ func readyConditionFor(status gateway.EngineStatus, advErr error) metav1.Conditi more = len(failures) - maxReadyFailures failures = failures[:maxReadyFailures] } - msg := "NetworkRules failed to apply: " + strings.Join(failures, "; ") + msg := "NetworkServices failed to apply: " + strings.Join(failures, "; ") if more > 0 { msg += fmt.Sprintf(" and %d more", more) } @@ -635,15 +635,15 @@ func readyConditionFor(status gateway.EngineStatus, advErr error) metav1.Conditi } } -// programmedConditionType returns the NetworkRule condition type that records -// whether node's engine loaded the rule. NetworkRule status is shared by every +// programmedConditionType returns the NetworkService condition type that records +// whether node's engine loaded the rule. NetworkService status is shared by every // gateway node in the namespace while loading is per node, so each node owns // its own condition type and never overwrites another node's result. func programmedConditionType(node string) string { return node + "/" + bgpv1alpha1.ConditionTypeProgrammed } -// ipv4ServedConditionType returns the NetworkRule condition type that records +// ipv4ServedConditionType returns the NetworkService condition type that records // whether node serves the rule's IPv4 VIPs. Like programmedConditionType, each // gateway node owns its own. func ipv4ServedConditionType(node string) string { @@ -655,7 +655,7 @@ func ipv4ServedConditionType(node string) string { // always False, whether or not the rule built: IPv4 VIPs are left out of the // engine and never advertised, so the condition is what tells the rule's // owner that those addresses do not work. -func ipv4ServedCondition(node string, rule *bgpv1alpha1.NetworkRule) *metav1.Condition { +func ipv4ServedCondition(node string, rule *bgpv1alpha1.NetworkService) *metav1.Condition { if !hasIPv4VIP(rule) { return nil } @@ -671,18 +671,18 @@ func ipv4ServedCondition(node string, rule *bgpv1alpha1.NetworkRule) *metav1.Con // setRuleConditions writes programmed and, when non-nil, ipv4 to rule's // status in one update, and removes node's IPv4Served condition when ipv4 is // nil. It skips the write when nothing changed. Every write fans out to every -// gateway node through the NetworkRule watch, so an unconditional write would +// gateway node through the NetworkService watch, so an unconditional write would // never settle. // -// Every gateway node and NetworkRuleReconciler write the same status, so a +// Every gateway node and NetworkServiceReconciler write the same status, so a // write retries on conflict against a fresh read. A rule deleted mid-pass // has nothing left to record and counts as success. func (r *NetworkGatewayReconciler) setRuleConditions( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, programmed metav1.Condition, ipv4 *metav1.Condition, + ctx context.Context, rule *bgpv1alpha1.NetworkService, programmed metav1.Condition, ipv4 *metav1.Condition, ) error { key := client.ObjectKeyFromObject(rule) err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} if err := r.Get(ctx, key, current); err != nil { return err } @@ -708,19 +708,19 @@ func (r *NetworkGatewayReconciler) setRuleConditions( } // clearNodeRuleConditions removes node's Programmed and IPv4Served -// conditions from every NetworkRule in namespace, for a node that no longer +// conditions from every NetworkService in namespace, for a node that no longer // serves any of them. A write retries on conflict against a fresh read, and a // rule deleted mid-pass counts as cleared. func clearNodeRuleConditions(ctx context.Context, c client.Client, namespace, node string) error { - list := &bgpv1alpha1.NetworkRuleList{} + list := &bgpv1alpha1.NetworkServiceList{} if err := c.List(ctx, list, client.InNamespace(namespace)); err != nil { - return fmt.Errorf("list NetworkRules to clear node %s conditions: %w", node, err) + return fmt.Errorf("list NetworkServices to clear node %s conditions: %w", node, err) } var errs []error for i := range list.Items { key := client.ObjectKeyFromObject(&list.Items[i]) err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} if err := c.Get(ctx, key, current); err != nil { return err } @@ -732,7 +732,7 @@ func clearNodeRuleConditions(ctx context.Context, c client.Client, namespace, no return c.Status().Update(ctx, current) }) if err != nil && !apierrors.IsNotFound(err) { - errs = append(errs, fmt.Errorf("clear node %s condition on NetworkRule %s: %w", node, key.Name, err)) + errs = append(errs, fmt.Errorf("clear node %s condition on NetworkService %s: %w", node, key.Name, err)) } } return errors.Join(errs...) @@ -742,7 +742,7 @@ func clearNodeRuleConditions(ctx context.Context, c client.Client, namespace, no // rule, one per address family, so the fabric stops sending the rule's VIP // traffic to a node that has not loaded it. func withdrawRuleAdvertisements( - ctx context.Context, c client.Client, rule *bgpv1alpha1.NetworkRule, node string, + ctx context.Context, c client.Client, rule *bgpv1alpha1.NetworkService, node string, ) error { return pruneRuleAdvertisements(ctx, c, rule, node, nil) } @@ -759,14 +759,14 @@ func withdrawRuleAdvertisements( // its renamed replacement, and withdrawing the rule from the node would miss // it. func pruneRuleAdvertisements( - ctx context.Context, c client.Client, rule *bgpv1alpha1.NetworkRule, node string, keep map[string]bool, + ctx context.Context, c client.Client, rule *bgpv1alpha1.NetworkService, node string, keep map[string]bool, ) error { advList := &bgpv1alpha1.BGPAdvertisementList{} if err := c.List(ctx, advList, client.InNamespace(rule.Namespace), client.MatchingLabels{networkRuleLabel: rule.Name}, ); err != nil { - return fmt.Errorf("list BGPAdvertisements for NetworkRule %s: %w", rule.Name, err) + return fmt.Errorf("list BGPAdvertisements for NetworkService %s: %w", rule.Name, err) } var errs []error @@ -798,7 +798,7 @@ func isIPv4VIP(vip netip.Addr) bool { // hasIPv4VIP reports whether rule lists an IPv4 VIP in either form isIPv4VIP // accepts. Addresses that do not parse are left to buildDesiredRule, which // rejects them. -func hasIPv4VIP(rule *bgpv1alpha1.NetworkRule) bool { +func hasIPv4VIP(rule *bgpv1alpha1.NetworkService) bool { for _, v := range rule.Spec.VIPAddresses { if addr, err := netip.ParseAddr(v); err == nil && isIPv4VIP(addr) { return true @@ -832,7 +832,7 @@ func hasIPv4VIP(rule *bgpv1alpha1.NetworkRule) bool { // advertising one would draw traffic nothing answers. The rule's IPv6 VIPs // serve without them, and a rule with no other VIP fails with errNoServedVIP. func buildDesiredRule( - rule *bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, + rule *bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, sidIndex *backendSIDIndex, owners backendOwners, ) (dr gateway.DesiredRule, unresolved []string, err error) { vips := make([]netip.Addr, 0, len(rule.Spec.VIPAddresses)) @@ -902,7 +902,7 @@ func buildDesiredRule( VPCRef: rule.Spec.VPCRef, VIPAddresses: vips, Protocol: string(rule.Spec.Protocol), - //nolint:gosec // rule.Spec.Port is CRD-validated to [1,65535] (Minimum/Maximum markers on NetworkRuleSpec.Port) + //nolint:gosec // rule.Spec.Port is CRD-validated to [1,65535] (Minimum/Maximum markers on NetworkServiceSpec.Port) Port: uint16(rule.Spec.Port), Backends: backends, }, unresolved, nil @@ -969,7 +969,7 @@ func routerNameForNode(ctx context.Context, c client.Client, namespace, nodeName // nor needs extra RBAC. A rule that is already being deleted gets nothing // created or updated at all. func (r *NetworkGatewayReconciler) applyBGPAdvertisements( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, desired gateway.DesiredRule, routerName string, + ctx context.Context, rule *bgpv1alpha1.NetworkService, desired gateway.DesiredRule, routerName string, ) error { if !rule.DeletionTimestamp.IsZero() { return nil @@ -1089,7 +1089,7 @@ func prefixesByFamily(vips []netip.Addr) (v4, v6 []string) { func (r *NetworkGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&bgpv1alpha1.NetworkGateway{}). - Watches(&bgpv1alpha1.NetworkRule{}, handler.EnqueueRequestsFromMapFunc( + Watches(&bgpv1alpha1.NetworkService{}, handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []ctrlreconcile.Request { return ruleToGatewayRequests(ctx, r.Client, obj) }), @@ -1185,27 +1185,27 @@ func locatesBackend(obj client.Object) bool { } // isRuleAdvertisement reports whether obj is a gateway VIP advertisement made -// for a NetworkRule. +// for a NetworkService. func isRuleAdvertisement(obj client.Object) bool { _, ok := obj.GetLabels()[networkRuleLabel] return ok } -// ruleToGatewayRequests maps a NetworkRule change to every NetworkGateway in -// its namespace. A NetworkRule carries no gatewayRef, and every gateway node +// ruleToGatewayRequests maps a NetworkService change to every NetworkGateway in +// its namespace. A NetworkService carries no gatewayRef, and every gateway node // advertises every VIP identically (anycast), so every gateway node in the // rule's PoP must re-evaluate its own engine state whenever any rule changes. // The broadcast is intentional rather than a missing index. func ruleToGatewayRequests(ctx context.Context, c client.Client, obj client.Object) []ctrlreconcile.Request { - rule, ok := obj.(*bgpv1alpha1.NetworkRule) + rule, ok := obj.(*bgpv1alpha1.NetworkService) if !ok { return nil } - return broadcastToGatewayRequests(ctx, c, rule.Namespace, "NetworkRule", rule.Name) + return broadcastToGatewayRequests(ctx, c, rule.Namespace, "NetworkService", rule.Name) } // broadcastToGatewayRequests lists every NetworkGateway in namespace and -// returns a reconcile request for each. It is the primitive the NetworkRule, +// returns a reconcile request for each. It is the primitive the NetworkService, // BGPRouter, BGPAdvertisement, and BGPVRFInstance watches all build on. // sourceKind and sourceName appear only in the list-failure log line. func broadcastToGatewayRequests( @@ -1249,9 +1249,9 @@ func gatewayNodeNames(ctx context.Context, c client.Client, namespace string) ([ } // isGatewayNode reports whether nodeName is one of namespace's registered -// gateway nodes. NetworkRuleReconciler uses it to scope its per-object +// gateway nodes. NetworkServiceReconciler uses it to scope its per-object // lifecycle work to gateway-role nodes, so every other node's router process -// leaves NetworkRule objects alone. +// leaves NetworkService objects alone. func isGatewayNode(ctx context.Context, c client.Client, namespace, nodeName string) (bool, error) { names, err := gatewayNodeNames(ctx, c, namespace) if err != nil { @@ -1334,11 +1334,11 @@ func isNodeAdvertisement(adv *bgpv1alpha1.BGPAdvertisement, nodeName string) boo } // sweepOrphanedAdvertisements deletes this node's BGPAdvertisements for rules -// that no longer exist. rules is every NetworkRule in namespace as this pass +// that no longer exist. rules is every NetworkService in namespace as this pass // listed them. // // This node can create an advertisement from a cache that has not yet seen its -// rule's deletion timestamp, after NetworkRuleReconciler's teardown has already +// rule's deletion timestamp, after NetworkServiceReconciler's teardown has already // removed the finalizer. Teardown never sees that advertisement. Without this // sweep it would stay advertised, with no node serving the rule, until // Kubernetes garbage collection follows its owner reference. Here it is @@ -1349,7 +1349,7 @@ func isNodeAdvertisement(adv *bgpv1alpha1.BGPAdvertisement, nodeName string) boo // still exists, so a cache that has not yet seen a new rule never withdraws a // live route. func (r *NetworkGatewayReconciler) sweepOrphanedAdvertisements( - ctx context.Context, namespace string, rules []bgpv1alpha1.NetworkRule, + ctx context.Context, namespace string, rules []bgpv1alpha1.NetworkService, ) error { known := make(map[string]bool, len(rules)) for i := range rules { @@ -1375,16 +1375,16 @@ func (r *NetworkGatewayReconciler) sweepOrphanedAdvertisements( continue } err := r.APIReader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ruleName}, - &bgpv1alpha1.NetworkRule{}) + &bgpv1alpha1.NetworkService{}) switch { case err == nil: continue case !apierrors.IsNotFound(err): - errs = append(errs, fmt.Errorf("check NetworkRule %s for BGPAdvertisement %s: %w", ruleName, adv.Name, err)) + errs = append(errs, fmt.Errorf("check NetworkService %s for BGPAdvertisement %s: %w", ruleName, adv.Name, err)) continue } if err := r.Delete(ctx, adv); err != nil && !apierrors.IsNotFound(err) { - errs = append(errs, fmt.Errorf("withdraw BGPAdvertisement %s for deleted NetworkRule %s: %w", + errs = append(errs, fmt.Errorf("withdraw BGPAdvertisement %s for deleted NetworkService %s: %w", adv.Name, ruleName, err)) } } @@ -1394,7 +1394,7 @@ func (r *NetworkGatewayReconciler) sweepOrphanedAdvertisements( // withdrawNodeAdvertisements deletes every BGPAdvertisement that gateway node // nodeName created in namespace: each per-rule, per-address-family route it // advertised. It lists by gatewayNodeLabel and deletes those that -// isNodeAdvertisement accepts, so it needs no NetworkRule object, live or +// isNodeAdvertisement accepts, so it needs no NetworkService object, live or // deleted, and never matches by name suffix. An advertisement without // gatewayNodeLabel is not withdrawn and has to be deleted by hand. // diff --git a/internal/controller/networkgateway_controller_test.go b/internal/controller/networkgateway_controller_test.go index ec38309f..e172ab08 100644 --- a/internal/controller/networkgateway_controller_test.go +++ b/internal/controller/networkgateway_controller_test.go @@ -26,10 +26,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/gateway" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // errAdvertisementWrite and errOrphanSweep are the injected failures the @@ -116,7 +116,7 @@ func newIndexedClientBuilder(scheme *runtime.Scheme) *fake.ClientBuilder { }) } -func acceptRule(rule *bgpv1alpha1.NetworkRule) { +func acceptRule(rule *bgpv1alpha1.NetworkService) { meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{ Type: bgpv1alpha1.ConditionTypeAccepted, Status: metav1.ConditionTrue, @@ -166,12 +166,12 @@ func gatewayReadyCondition(t *testing.T, c client.Client) *metav1.Condition { } // ruleProgrammedCondition returns testNodeGWA's Programmed condition on the -// named NetworkRule, or nil when the rule carries none. +// named NetworkService, or nil when the rule carries none. func ruleProgrammedCondition(t *testing.T, c client.Client, name string) *metav1.Condition { t.Helper() - rule := &bgpv1alpha1.NetworkRule{} + rule := &bgpv1alpha1.NetworkService{} if err := c.Get(context.Background(), testRuleKey(name), rule); err != nil { - t.Fatalf("get NetworkRule %s: %v", name, err) + t.Fatalf("get NetworkService %s: %v", name, err) } return meta.FindStatusCondition(rule.Status.Conditions, programmedConditionType(testNodeGWA)) } @@ -183,10 +183,10 @@ func assertRuleProgrammed(t *testing.T, c client.Client, status metav1.Condition name := testRuleName cond := ruleProgrammedCondition(t, c, name) if cond == nil { - t.Fatalf("NetworkRule %s has no %s condition", name, programmedConditionType(testNodeGWA)) + t.Fatalf("NetworkService %s has no %s condition", name, programmedConditionType(testNodeGWA)) } if cond.Status != status || cond.Reason != reason { - t.Errorf("NetworkRule %s %s = %s/%s, want %s/%s", + t.Errorf("NetworkService %s %s = %s/%s, want %s/%s", name, cond.Type, cond.Status, cond.Reason, status, reason) } } @@ -196,7 +196,7 @@ func TestNetworkGatewayReconciler_SkipsNonMatchingNode(t *testing.T) { gw := newTestGateway(testNodeGWA) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gw). Build() @@ -236,7 +236,7 @@ func TestNetworkGatewayReconciler_IgnoresDeletionOfOtherNodesGateway(t *testing. gwA := newTestGateway(testNodeGWA) // this node's own gateway; still exists fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA). Build() @@ -257,7 +257,7 @@ func TestNetworkGatewayReconciler_IgnoresDeletionOfOtherNodesGateway(t *testing. } // TestNetworkGatewayReconciler_BuildsDesiredStateForAcceptedRules covers -// DSR's anycast model directly: every accepted, non-deleting NetworkRule in +// DSR's anycast model directly: every accepted, non-deleting NetworkService in // the namespace goes into desired state, with no primary/secondary // distinction to gate on: every gateway node in a PoP serves every accepted // rule identically. @@ -292,7 +292,7 @@ func TestNetworkGatewayReconciler_BuildsDesiredStateForAcceptedRules(t *testing. deleting.DeletionTimestamp = &now fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, gwB, backendRouter, backendAdv, backendVRF, backendAdv2, backendVRF2, newBackendAttachment("vpc-1"), newBackendAttachment("vpc-2"), ruleA, ruleB, notAccepted, deleting). @@ -341,7 +341,7 @@ func TestNetworkGatewayReconciler_ExcludesRuleWithUnresolvableBackend(t *testing acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule). Build() @@ -373,7 +373,7 @@ func TestNetworkGatewayReconciler_ServesThroughResolvedBackends(t *testing.T) { attachment := newBackendAttachment("vpc-1", testBackendAddr, "fd00:99::1") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, attachment, rule). Build() @@ -537,14 +537,14 @@ func TestGatherRules_SharedBackendServesOldestRule(t *testing.T) { vrfInstances: map[string]*bgpv1alpha1.BGPVRFInstance{backendVRF.Name: backendVRF}, } attachments := []*cloudv1alpha1.VPCAttachment{newBackendAttachment("vpc-1")} - rule := func(name, vip string, created int64, backendPort int32) bgpv1alpha1.NetworkRule { + rule := func(name, vip string, created int64, backendPort int32) bgpv1alpha1.NetworkService { r := newTestRule(name, "vpc-1", vip) r.CreationTimestamp = metav1.NewTime(time.Unix(created, 0)) r.Spec.BackendPort = backendPort acceptRule(r) return *r } - rules := []bgpv1alpha1.NetworkRule{ + rules := []bgpv1alpha1.NetworkService{ rule("newer", "2001:db8:100::33", 200, testBackendPort), rule("older", "2001:db8:100::32", 100, testBackendPort), rule("other-port", "2001:db8:100::34", 300, testBackendPort+1), @@ -570,9 +570,9 @@ func TestGatherRules_SharedBackendServesOldestRule(t *testing.T) { t.Error("rule newer is loaded, want it left out: its only backend is served by rule older") } newer := byName["newer"] - wantOwner := "served by NetworkRule " + testNamespace + "/older" + wantOwner := "served by NetworkService " + testNamespace + "/older" if newer.buildErr == nil || !strings.Contains(newer.buildErr.Error(), wantOwner) { - t.Errorf("rule newer build error = %v, want it to name NetworkRule %s/older", newer.buildErr, testNamespace) + t.Errorf("rule newer build error = %v, want it to name NetworkService %s/older", newer.buildErr, testNamespace) } } @@ -629,7 +629,7 @@ func TestNetworkGatewayReconciler_SkipsBGPAdvertisementWiringWithoutRouter(t *te acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -667,7 +667,7 @@ func TestNetworkGatewayReconciler_CreatesBGPAdvertisement(t *testing.T) { rule.UID = testRuleUID fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, gwB, router, backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -697,7 +697,7 @@ func TestNetworkGatewayReconciler_CreatesBGPAdvertisement(t *testing.T) { t.Errorf("Prefixes = %v, want [%s]", adv.Spec.Prefixes, testVIPPrefix) } if got := adv.Labels[networkRuleLabel]; got != testRuleName { - t.Errorf("Labels[%s] = %q, want %q (networkrule_controller.go's teardown depends on this)", + t.Errorf("Labels[%s] = %q, want %q (networkservice_controller.go's teardown depends on this)", networkRuleLabel, got, testRuleName) } assertRuleOwnerReference(t, adv) @@ -712,7 +712,7 @@ func TestNetworkGatewayReconciler_CreatesBGPAdvertisement(t *testing.T) { // applyBGPAdvertisements's update path self-healing an advertisement that // was created before networkRuleLabel (issue #367) or gatewayNodeLabel // (issue #714) existed — without this, an advertisement from an older -// release would stay permanently invisible to NetworkRuleReconciler's +// release would stay permanently invisible to NetworkServiceReconciler's // teardown List and to withdrawNodeAdvertisements's. The owner reference to // the rule (#715) is backfilled the same way, so garbage collection covers it // too. @@ -736,7 +736,7 @@ func TestNetworkGatewayReconciler_BackfillsLabelOnExistingAdvertisement(t *testi } fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, router, backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule, preexisting). Build() @@ -776,7 +776,7 @@ func TestNetworkGatewayReconciler_AdvertisementFailureSurfaces(t *testing.T) { acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, router, backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). WithInterceptorFuncs(interceptor.Funcs{ Create: func( @@ -810,7 +810,7 @@ func TestNetworkGatewayReconciler_AdvertisementFailureSurfaces(t *testing.T) { t.Errorf("Ready reason = %q, want %q", cond.Reason, reasonAdvertisementFailed) } if !strings.Contains(cond.Message, testRuleName) { - t.Errorf("Ready message = %q, want it to name the failing NetworkRule %q", cond.Message, testRuleName) + t.Errorf("Ready message = %q, want it to name the failing NetworkService %q", cond.Message, testRuleName) } } @@ -827,7 +827,7 @@ func TestNetworkGatewayReconciler_ReportsEngineHealthyOnCleanPass(t *testing.T) acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, router, backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -868,7 +868,7 @@ func TestNetworkGatewayReconciler_DoesNotAdvertiseUnloadedRule(t *testing.T) { acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -912,7 +912,7 @@ func TestNetworkGatewayReconciler_WithdrawsRuleThatStopsLoading(t *testing.T) { acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -963,7 +963,7 @@ func TestNetworkGatewayReconciler_WithdrawsRuleThatStopsBuilding(t *testing.T) { // No backend fixtures: the backend no longer resolves. fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), rule, stale). Build() @@ -989,7 +989,7 @@ func TestNetworkGatewayReconciler_ReturnsOrphanSweepFailure(t *testing.T) { gwA := newTestGateway(testNodeGWA) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA). Build() @@ -1049,7 +1049,7 @@ func TestNetworkGatewayReconciler_WithdrawsAdvertisementsForDepartedGatewayNode( survivorAdv := newNodeAdvertisement(testRuleName, testNodeGWA, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, ruleV4, ruleV6, otherRuleV4, survivorAdv). Build() @@ -1097,7 +1097,7 @@ func TestNetworkGatewayReconciler_DepartedNodeLeavesSuffixSharingNodesAlone(t *t cniAdv := newAdvertisement("vpc0000001-att0000001-" + surviving + "-v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(surviving), departedV4, departedV6, survivorV4, survivorV6, unlabelledSurvivor, cniAdv). Build() @@ -1135,7 +1135,7 @@ func TestNetworkGatewayReconciler_WithdrawsAdvertisementsForLongNamedNode(t *tes survivorV4 := newNodeAdvertisement(testRuleName, surviving, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), departedV4, survivorV4). Build() @@ -1213,7 +1213,7 @@ func TestNetworkGatewayReconciler_WithdrawsAdvertisementsOnOwnDeletion(t *testin ruleV4 := newNodeAdvertisement(testRuleName, testNodeGWA, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, ruleV4). Build() @@ -1294,7 +1294,7 @@ func TestRuleToGatewayRequests_UsesRuleNamespace(t *testing.T) { // than a panic-inducing cast) is what makes that safe to rely on. func TestRuleToGatewayRequests_WrongTypeReturnsNil(t *testing.T) { if reqs := ruleToGatewayRequests(context.Background(), nil, &bgpv1alpha1.NetworkGateway{}); reqs != nil { - t.Errorf("reqs = %v, want nil for a non-NetworkRule object", reqs) + t.Errorf("reqs = %v, want nil for a non-NetworkService object", reqs) } } @@ -1319,7 +1319,7 @@ func TestNetworkGatewayReconciler_DisabledWithdrawsAndSkipsTheEngine(t *testing. other := newNodeAdvertisement(testRuleName, testNodeGWB, "v6") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(gwA, own, other). Build() @@ -1402,7 +1402,7 @@ func TestNetworkGatewayReconciler_ClearsDepartedNodeProgrammedCondition(t *testi } fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), rule). Build() @@ -1412,15 +1412,15 @@ func TestNetworkGatewayReconciler_ClearsDepartedNodeProgrammedCondition(t *testi t.Fatalf("Reconcile: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), testRuleKey(testRuleName), got); err != nil { - t.Fatalf("get NetworkRule %s: %v", testRuleName, err) + t.Fatalf("get NetworkService %s: %v", testRuleName, err) } if meta.FindStatusCondition(got.Status.Conditions, programmedConditionType(testNodeGWB)) != nil { - t.Errorf("departed node %s's condition is still on NetworkRule %s", testNodeGWB, testRuleName) + t.Errorf("departed node %s's condition is still on NetworkService %s", testNodeGWB, testRuleName) } if meta.FindStatusCondition(got.Status.Conditions, programmedConditionType(testNodeGWA)) == nil { - t.Errorf("surviving node %s's condition was removed from NetworkRule %s", testNodeGWA, testRuleName) + t.Errorf("surviving node %s's condition was removed from NetworkService %s", testNodeGWA, testRuleName) } } @@ -1462,15 +1462,15 @@ func TestNetworkGatewayReconciler_RuleDeletedMidPassIsNotAnError(t *testing.T) { acceptRule(rule) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). WithInterceptorFuncs(interceptor.Funcs{ Get: func( ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption, ) error { - if _, ok := obj.(*bgpv1alpha1.NetworkRule); ok { - return apierrors.NewNotFound(bgpv1alpha1.GroupVersion.WithResource("networkrules").GroupResource(), key.Name) + if _, ok := obj.(*bgpv1alpha1.NetworkService); ok { + return apierrors.NewNotFound(bgpv1alpha1.GroupVersion.WithResource("networkservices").GroupResource(), key.Name) } return c.Get(ctx, key, obj, opts...) }, @@ -1495,14 +1495,14 @@ func TestNetworkGatewayReconciler_UnchangedProgrammedConditionWritesNothing(t *t var ruleWrites atomic.Int32 fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). WithInterceptorFuncs(interceptor.Funcs{ SubResourceUpdate: func( ctx context.Context, c client.Client, sub string, obj client.Object, opts ...client.SubResourceUpdateOption, ) error { - if _, ok := obj.(*bgpv1alpha1.NetworkRule); ok { + if _, ok := obj.(*bgpv1alpha1.NetworkService); ok { ruleWrites.Add(1) } return c.SubResource(sub).Update(ctx, obj, opts...) @@ -1517,13 +1517,13 @@ func TestNetworkGatewayReconciler_UnchangedProgrammedConditionWritesNothing(t *t t.Fatalf("first Reconcile: %v", err) } if got := ruleWrites.Load(); got != 1 { - t.Fatalf("first pass wrote NetworkRule status %d times, want 1", got) + t.Fatalf("first pass wrote NetworkService status %d times, want 1", got) } if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("second Reconcile: %v", err) } if got := ruleWrites.Load(); got != 1 { - t.Errorf("unchanged second pass wrote NetworkRule status %d times in total, want 1", got) + t.Errorf("unchanged second pass wrote NetworkService status %d times in total, want 1", got) } } @@ -1677,7 +1677,7 @@ func TestNetworkGatewayReconciler_WithdrawsOwnAdvertisementForDeletedRule(t *tes other := newNodeAdvertisement(testRuleName, testNodeGWB, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), own, other). Build() @@ -1705,7 +1705,7 @@ func TestNetworkGatewayReconciler_KeepsAdvertisementForRuleMissingFromCache(t *t own := newNodeAdvertisement(testRuleName, testNodeGWA, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), rule, own). Build() diff --git a/internal/controller/networkgateway_drain.go b/internal/controller/networkgateway_drain.go index 570d7dc1..d871c220 100644 --- a/internal/controller/networkgateway_drain.go +++ b/internal/controller/networkgateway_drain.go @@ -12,7 +12,7 @@ import ( ) // ruleDrainDelay is how long a gateway node keeps serving a deleted -// NetworkRule after the last of its BGPAdvertisements is gone. Deleting the +// NetworkService after the last of its BGPAdvertisements is gone. Deleting the // object only starts the withdrawal: each router still has to send it and // every peer has to act on it. Until then traffic keeps arriving for the VIP, // and a node that already dropped the rule from its datapath would blackhole @@ -21,7 +21,7 @@ const ruleDrainDelay = 5 * time.Second // ruleDrainTracker remembers what this node put in the engine's desired state // on its last pass, so a rule that is deleted can stay loaded for -// ruleDrainDelay after its advertisements are gone, even once the NetworkRule +// ruleDrainDelay after its advertisements are gone, even once the NetworkService // object itself has disappeared from the list. // // It holds memory only. After a process restart it knows nothing, and a @@ -56,7 +56,7 @@ func (t *ruleDrainTracker) lastLoaded(key string) (gateway.DesiredRule, bool) { } // settle completes desired for one pass in namespace and records it for the -// next. live holds the key of every listed NetworkRule that is not being +// next. live holds the key of every listed NetworkService that is not being // deleted. // // A rule in last that is in neither desired nor live was deleted and its diff --git a/internal/controller/networkgateway_drain_test.go b/internal/controller/networkgateway_drain_test.go index 8b7fb753..072a0565 100644 --- a/internal/controller/networkgateway_drain_test.go +++ b/internal/controller/networkgateway_drain_test.go @@ -18,8 +18,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/gateway" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // testRuleUID is the UID the drain and owner-reference tests give their rule, @@ -29,7 +29,7 @@ const testRuleUID = types.UID("rule-1-uid") // testRuleKeyString is testRuleName's key in the engine's desired state. const testRuleKeyString = testNamespace + "/" + testRuleName -// advWriteCounter counts BGPAdvertisement creates and updates and NetworkRule +// advWriteCounter counts BGPAdvertisement creates and updates and NetworkService // status writes, the two things a draining rule must never cause. type advWriteCounter struct { advWrites atomic.Int32 @@ -53,7 +53,7 @@ func (w *advWriteCounter) funcs() interceptor.Funcs { SubResourceUpdate: func( ctx context.Context, c client.Client, sub string, obj client.Object, opts ...client.SubResourceUpdateOption, ) error { - if _, ok := obj.(*bgpv1alpha1.NetworkRule); ok { + if _, ok := obj.(*bgpv1alpha1.NetworkService); ok { w.ruleWrites.Add(1) } return c.SubResource(sub).Update(ctx, obj, opts...) @@ -67,8 +67,8 @@ func (w *advWriteCounter) reset() { } // newFinalizedRule returns testRuleName, accepted, carrying the teardown -// finalizer and testRuleUID, as NetworkRuleReconciler leaves a live rule. -func newFinalizedRule() *bgpv1alpha1.NetworkRule { +// finalizer and testRuleUID, as NetworkServiceReconciler leaves a live rule. +func newFinalizedRule() *bgpv1alpha1.NetworkService { rule := newTestRule(testRuleName, "vpc-1", testVIP) acceptRule(rule) rule.UID = testRuleUID @@ -95,7 +95,7 @@ func TestNetworkGatewayReconciler_DrainsDeletedRuleUntilWithdrawn(t *testing.T) var writes advWriteCounter fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). WithInterceptorFuncs(writes.funcs()). @@ -130,9 +130,9 @@ func TestNetworkGatewayReconciler_DrainsDeletedRuleUntilWithdrawn(t *testing.T) } // The finalizer holds the object, so this only sets its deletion - // timestamp, the state NetworkRuleReconciler.reconcileDelete starts from. + // timestamp, the state NetworkServiceReconciler.reconcileDelete starts from. if err := fakeClient.Delete(ctx, rule); err != nil { - t.Fatalf("delete NetworkRule: %v", err) + t.Fatalf("delete NetworkService: %v", err) } writes.reset() @@ -144,7 +144,7 @@ func TestNetworkGatewayReconciler_DrainsDeletedRuleUntilWithdrawn(t *testing.T) t.Errorf("draining: %d BGPAdvertisement writes, want 0 (a deleting rule is never re-advertised)", got) } if got := writes.ruleWrites.Load(); got != 0 { - t.Errorf("draining: %d NetworkRule status writes, want 0 (Programmed is left alone)", got) + t.Errorf("draining: %d NetworkService status writes, want 0 (Programmed is left alone)", got) } // reconcileDelete withdraws the route. @@ -159,16 +159,16 @@ func TestNetworkGatewayReconciler_DrainsDeletedRuleUntilWithdrawn(t *testing.T) } // reconcileDelete removes the finalizer and the object goes away. - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(ctx, testRuleKey(testRuleName), current); err != nil { - t.Fatalf("get NetworkRule: %v", err) + t.Fatalf("get NetworkService: %v", err) } current.Finalizers = nil if err := fakeClient.Update(ctx, current); err != nil { t.Fatalf("remove finalizer: %v", err) } if err := fakeClient.Get(ctx, testRuleKey(testRuleName), current); !apierrors.IsNotFound(err) { - t.Fatalf("get NetworkRule after finalizer removal: err = %v, want NotFound", err) + t.Fatalf("get NetworkService after finalizer removal: err = %v, want NotFound", err) } clock.t = clock.t.Add(2 * time.Second) @@ -201,7 +201,7 @@ func TestNetworkGatewayReconciler_DrainingRuleKeepsLastLoadedState(t *testing.T) rule := newFinalizedRule() fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -219,7 +219,7 @@ func TestNetworkGatewayReconciler_DrainingRuleKeepsLastLoadedState(t *testing.T) } if err := fakeClient.Delete(ctx, rule); err != nil { - t.Fatalf("delete NetworkRule: %v", err) + t.Fatalf("delete NetworkService: %v", err) } if err := fakeClient.Delete(ctx, backendAdv); err != nil { t.Fatalf("delete backend BGPAdvertisement: %v", err) @@ -272,7 +272,7 @@ func TestApplyBGPAdvertisements_SkipsDeletingRule(t *testing.T) { func assertRuleOwnerReference(t *testing.T, adv *bgpv1alpha1.BGPAdvertisement) { t.Helper() for _, ref := range adv.OwnerReferences { - if ref.Kind != "NetworkRule" || ref.Name != testRuleName { + if ref.Kind != "NetworkService" || ref.Name != testRuleName { continue } if ref.UID != testRuleUID { @@ -289,7 +289,7 @@ func assertRuleOwnerReference(t *testing.T, adv *bgpv1alpha1.BGPAdvertisement) { } return } - t.Errorf("BGPAdvertisement %s has no owner reference to NetworkRule %s; got %+v", + t.Errorf("BGPAdvertisement %s has no owner reference to NetworkService %s; got %+v", adv.Name, testRuleName, adv.OwnerReferences) } diff --git a/internal/controller/networkgateway_ipv4_test.go b/internal/controller/networkgateway_ipv4_test.go index 9380be67..01e7148c 100644 --- a/internal/controller/networkgateway_ipv4_test.go +++ b/internal/controller/networkgateway_ipv4_test.go @@ -16,8 +16,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // TestNetworkGatewayReconciler_WithholdsIPv4VIPs covers the #705 stopgap: an @@ -56,7 +56,7 @@ func TestNetworkGatewayReconciler_WithholdsIPv4VIPs(t *testing.T) { otherNodeV4 := newNodeAdvertisement(testRuleName, testNodeGWB, "v4") fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule, staleV4, otherNodeV4). Build() @@ -91,9 +91,9 @@ func TestNetworkGatewayReconciler_WithholdsIPv4VIPs(t *testing.T) { t.Errorf("-v6 advertisement: err = %v, want NotFound for a rule with no IPv6 VIP", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(ctx, testRuleKey(testRuleName), got); err != nil { - t.Fatalf("get NetworkRule: %v", err) + t.Fatalf("get NetworkService: %v", err) } programmed := meta.FindStatusCondition(got.Status.Conditions, programmedConditionType(testNodeGWA)) if programmed == nil || programmed.Status != tt.wantProgrammed || programmed.Reason != tt.wantReason { @@ -125,7 +125,7 @@ func TestNetworkGatewayReconciler_RemovesIPv4ServedWithoutIPv4VIP(t *testing.T) } fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), newTestRouter(), backendRouter, backendAdv, backendVRF, newBackendAttachment("vpc-1"), rule). Build() @@ -135,9 +135,9 @@ func TestNetworkGatewayReconciler_RemovesIPv4ServedWithoutIPv4VIP(t *testing.T) t.Fatalf("Reconcile: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), testRuleKey(testRuleName), got); err != nil { - t.Fatalf("get NetworkRule: %v", err) + t.Fatalf("get NetworkService: %v", err) } if c := meta.FindStatusCondition(got.Status.Conditions, ipv4ServedConditionType(testNodeGWA)); c != nil { t.Errorf("node %s kept IPv4Served for a rule with no IPv4 VIP: %+v", testNodeGWA, c) @@ -159,7 +159,7 @@ func TestNetworkGatewayReconciler_ClearsDepartedNodeIPv4ServedCondition(t *testi }) fakeClient := newIndexedClientBuilder(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkGateway{}, &bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), rule). Build() @@ -168,9 +168,9 @@ func TestNetworkGatewayReconciler_ClearsDepartedNodeIPv4ServedCondition(t *testi t.Fatalf("Reconcile: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), testRuleKey(testRuleName), got); err != nil { - t.Fatalf("get NetworkRule: %v", err) + t.Fatalf("get NetworkService: %v", err) } if c := meta.FindStatusCondition(got.Status.Conditions, ipv4ServedConditionType(testNodeGWB)); c != nil { t.Errorf("departed node %s's IPv4Served condition is still on the rule: %+v", testNodeGWB, c) diff --git a/internal/controller/networkgateway_predicate_test.go b/internal/controller/networkgateway_predicate_test.go index 9e130399..dc03f210 100644 --- a/internal/controller/networkgateway_predicate_test.go +++ b/internal/controller/networkgateway_predicate_test.go @@ -9,7 +9,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/event" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) func testAdvertisement(generation int64, srv6 bool) *bgpv1alpha1.BGPAdvertisement { diff --git a/internal/controller/networkrule_binding_controller.go b/internal/controller/networkservice_binding_controller.go similarity index 85% rename from internal/controller/networkrule_binding_controller.go rename to internal/controller/networkservice_binding_controller.go index fa72960b..b19e9e1f 100644 --- a/internal/controller/networkrule_binding_controller.go +++ b/internal/controller/networkservice_binding_controller.go @@ -28,25 +28,25 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) -// bindingNodeLabel is set on every ServiceVIPBinding NetworkRuleBindingReconciler +// bindingNodeLabel is set on every ServiceVIPBinding NetworkServiceBindingReconciler // writes, to gatewayNodeLabelValue of the node it was written for, so each // node lists only its own bindings. const bindingNodeLabel = "galactic.datum.net/binding-node" // bindingManagedByLabel and bindingManagedByValue mark a ServiceVIPBinding as -// generated. NetworkRuleBindingReconciler deletes only bindings carrying them, +// generated. NetworkServiceBindingReconciler deletes only bindings carrying them, // so it never removes one it did not write. const ( bindingManagedByLabel = "app.kubernetes.io/managed-by" bindingManagedByValue = "galactic-router" ) -// conditionTypeBackendsBound is the per-node NetworkRule condition, as +// conditionTypeBackendsBound is the per-node NetworkService condition, as // "/BackendsBound", recording whether every binding node needs for the // rule exists and reports Bound. Reasons are the reasonBindings* constants. const conditionTypeBackendsBound = "BackendsBound" @@ -74,8 +74,8 @@ const ( reasonBindingsClaimed = "BackendsClaimed" ) -// NetworkRuleBindingReconciler writes, on its own node, the ServiceVIPBindings -// that a NetworkRule's backends need: one per IPv6 VIP per backend whose +// NetworkServiceBindingReconciler writes, on its own node, the ServiceVIPBindings +// that a NetworkService's backends need: one per IPv6 VIP per backend whose // VPCAttachment reports this node. It runs in galactic-router on every node. // // It is the only writer of generated bindings, and each node writes only its @@ -103,21 +103,21 @@ const ( // The rule's "/BackendsBound" condition summarizes this node's bindings, // so a rule that is Programmed on the gateways but cannot reach a backend on // some node says so on the rule itself. -type NetworkRuleBindingReconciler struct { +type NetworkServiceBindingReconciler struct { client.Client Scheme *runtime.Scheme NodeName string } -// Reconcile converges this node's generated bindings for one NetworkRule. -func (r *NetworkRuleBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - rule := &bgpv1alpha1.NetworkRule{} +// Reconcile converges this node's generated bindings for one NetworkService. +func (r *NetworkServiceBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + rule := &bgpv1alpha1.NetworkService{} if err := r.Get(ctx, req.NamespacedName, rule); err != nil { if apierrors.IsNotFound(err) { // Owner references garbage-collect the rule's bindings. return ctrl.Result{}, nil } - return ctrl.Result{}, fmt.Errorf("get NetworkRule %s: %w", req.NamespacedName, err) + return ctrl.Result{}, fmt.Errorf("get NetworkService %s: %w", req.NamespacedName, err) } if !rule.DeletionTimestamp.IsZero() { // The gateways keep serving a deleting rule until its routes are @@ -140,9 +140,9 @@ func (r *NetworkRuleBindingReconciler) Reconcile(ctx context.Context, req ctrl.R if err != nil { return ctrl.Result{}, err } - rules := &bgpv1alpha1.NetworkRuleList{} + rules := &bgpv1alpha1.NetworkServiceList{} if err := r.List(ctx, rules, client.InNamespace(rule.Namespace)); err != nil { - return ctrl.Result{}, fmt.Errorf("list NetworkRules in %s: %w", rule.Namespace, err) + return ctrl.Result{}, fmt.Errorf("list NetworkServices in %s: %w", rule.Namespace, err) } owners := ruleBackendOwners(rules.Items, attachments) desired, claimed, buildErr := r.desiredBindings(rule, attachments, owners) @@ -180,8 +180,8 @@ func (r *NetworkRuleBindingReconciler) Reconcile(ctx context.Context, req ctrl.R // another rule. It also returns the backends on this node it left out for that // reason. The datapath translates IPv6 only, so a rule with no IPv6 VIP needs // none, and ruleTranslatedVIP refuses a rule with more than one. -func (r *NetworkRuleBindingReconciler) desiredBindings( - rule *bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, owners backendOwners, +func (r *NetworkServiceBindingReconciler) desiredBindings( + rule *bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, owners backendOwners, ) ([]*bgpv1alpha1.ServiceVIPBinding, []claimedBackend, error) { vip, ok, err := ruleTranslatedVIP(rule) if err != nil || !ok { @@ -208,8 +208,8 @@ func (r *NetworkRuleBindingReconciler) desiredBindings( } // newBinding builds the binding for one VIP and one backend on this node. -func (r *NetworkRuleBindingReconciler) newBinding( - rule *bgpv1alpha1.NetworkRule, vip netip.Addr, b ruleBackend, +func (r *NetworkServiceBindingReconciler) newBinding( + rule *bgpv1alpha1.NetworkService, vip netip.Addr, b ruleBackend, ) *bgpv1alpha1.ServiceVIPBinding { return &bgpv1alpha1.ServiceVIPBinding{ ObjectMeta: metav1.ObjectMeta{ @@ -237,8 +237,8 @@ func (r *NetworkRuleBindingReconciler) newBinding( // applyBindings creates or updates every binding in desired and deletes this // node's other generated bindings for rule. -func (r *NetworkRuleBindingReconciler) applyBindings( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, desired []*bgpv1alpha1.ServiceVIPBinding, +func (r *NetworkServiceBindingReconciler) applyBindings( + ctx context.Context, rule *bgpv1alpha1.NetworkService, desired []*bgpv1alpha1.ServiceVIPBinding, ) error { keep := make(map[string]bool, len(desired)) var errs []error @@ -281,8 +281,8 @@ func (r *NetworkRuleBindingReconciler) applyBindings( } // nodeBindings lists the generated bindings this node wrote for rule. -func (r *NetworkRuleBindingReconciler) nodeBindings( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, +func (r *NetworkServiceBindingReconciler) nodeBindings( + ctx context.Context, rule *bgpv1alpha1.NetworkService, ) ([]bgpv1alpha1.ServiceVIPBinding, error) { list := &bgpv1alpha1.ServiceVIPBindingList{} if err := r.List(ctx, list, @@ -293,7 +293,7 @@ func (r *NetworkRuleBindingReconciler) nodeBindings( bindingManagedByLabel: bindingManagedByValue, }, ); err != nil { - return nil, fmt.Errorf("list ServiceVIPBindings for NetworkRule %s: %w", rule.Name, err) + return nil, fmt.Errorf("list ServiceVIPBindings for NetworkService %s: %w", rule.Name, err) } return list.Items, nil } @@ -309,8 +309,8 @@ func (r *NetworkRuleBindingReconciler) nodeBindings( // other node stays silent. The gateways already report the error on the // rule, and a condition from every router node would cost a status write per // node per edit, each conflicting with the others. -func (r *NetworkRuleBindingReconciler) publishCondition( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, desired []*bgpv1alpha1.ServiceVIPBinding, +func (r *NetworkServiceBindingReconciler) publishCondition( + ctx context.Context, rule *bgpv1alpha1.NetworkService, desired []*bgpv1alpha1.ServiceVIPBinding, claimed []claimedBackend, buildErr error, served bool, ) error { condType := backendsBoundConditionType(r.NodeName) @@ -370,8 +370,8 @@ func (r *NetworkRuleBindingReconciler) publishCondition( // unboundBindings returns "vip -> backend: reason" for every desired binding // that is not Bound yet, reading each one's current status. -func (r *NetworkRuleBindingReconciler) unboundBindings( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, desired []*bgpv1alpha1.ServiceVIPBinding, +func (r *NetworkServiceBindingReconciler) unboundBindings( + ctx context.Context, rule *bgpv1alpha1.NetworkService, desired []*bgpv1alpha1.ServiceVIPBinding, ) ([]string, error) { existing, err := r.nodeBindings(ctx, rule) if err != nil { @@ -405,14 +405,14 @@ func (r *NetworkRuleBindingReconciler) unboundBindings( // updateRuleCondition applies mutate to rule's current conditions and writes // them only if mutate reports a change. Every status write fans out to every -// reconciler watching NetworkRules, so an unconditional write would never +// reconciler watching NetworkServices, so an unconditional write would never // settle. -func (r *NetworkRuleBindingReconciler) updateRuleCondition( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, mutate func(*[]metav1.Condition) bool, +func (r *NetworkServiceBindingReconciler) updateRuleCondition( + ctx context.Context, rule *bgpv1alpha1.NetworkService, mutate func(*[]metav1.Condition) bool, ) error { key := client.ObjectKeyFromObject(rule) err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} if err := r.Get(ctx, key, current); err != nil { return err } @@ -425,7 +425,7 @@ func (r *NetworkRuleBindingReconciler) updateRuleCondition( return nil } if err != nil { - return fmt.Errorf("update %s condition on NetworkRule %s: %w", conditionTypeBackendsBound, key, err) + return fmt.Errorf("update %s condition on NetworkService %s: %w", conditionTypeBackendsBound, key, err) } return nil } @@ -437,17 +437,17 @@ func backendsBoundConditionType(node string) string { return node + "/" + conditionTypeBackendsBound } -// SetupWithManager registers the NetworkRuleBindingReconciler with the manager. -func (r *NetworkRuleBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { +// SetupWithManager registers the NetworkServiceBindingReconciler with the manager. +func (r *NetworkServiceBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). // A rule's bindings depend on its spec and on whether it is // Accepted. Other status writes, including this reconciler's own // conditions and the gateways' Programmed conditions, change neither. - For(&bgpv1alpha1.NetworkRule{}, builder.WithPredicates(ruleBindingInputsChanged())). + For(&bgpv1alpha1.NetworkService{}, builder.WithPredicates(ruleBindingInputsChanged())). // Rules in one namespace compete for backends, so a rule appearing, // going, or changing its spec can give another rule a backend or take // one away. Every rule in its namespace is re-queued. - Watches(&bgpv1alpha1.NetworkRule{}, handler.EnqueueRequestsFromMapFunc( + Watches(&bgpv1alpha1.NetworkService{}, handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []ctrlreconcile.Request { return namespaceRuleRequests(ctx, r.Client, obj.GetNamespace()) }), @@ -475,18 +475,18 @@ func (r *NetworkRuleBindingReconciler) SetupWithManager(mgr ctrl.Manager) error return r.bindingRuleRequests(obj) }), ). - Named("networkrule-binding"). + Named("networkservice-binding"). Complete(r) } -// ruleBindingInputsChanged passes NetworkRule events that can change the +// ruleBindingInputsChanged passes NetworkService events that can change the // bindings it needs: a create or delete, a spec change, or a change to its // Accepted condition. func ruleBindingInputsChanged() predicate.Predicate { return predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { - oldRule, okOld := e.ObjectOld.(*bgpv1alpha1.NetworkRule) - newRule, okNew := e.ObjectNew.(*bgpv1alpha1.NetworkRule) + oldRule, okOld := e.ObjectOld.(*bgpv1alpha1.NetworkService) + newRule, okNew := e.ObjectNew.(*bgpv1alpha1.NetworkService) if !okOld || !okNew { return true } @@ -499,7 +499,7 @@ func ruleBindingInputsChanged() predicate.Predicate { } } -// vpcRuleRequests returns a reconcile request for every NetworkRule whose VPC +// vpcRuleRequests returns a reconcile request for every NetworkService whose VPC // is obj's observed VPC, the only rules whose backends obj can be. An // attachment with no observed VPC is no rule's candidate and maps to nothing. func vpcRuleRequests(ctx context.Context, c client.Client, obj client.Object) []ctrlreconcile.Request { @@ -507,9 +507,9 @@ func vpcRuleRequests(ctx context.Context, c client.Client, obj client.Object) [] if !ok || attachment.Status.VPC == "" { return nil } - list := &bgpv1alpha1.NetworkRuleList{} + list := &bgpv1alpha1.NetworkServiceList{} if err := c.List(ctx, list); err != nil { - log.FromContext(ctx).Error(err, "list NetworkRules for VPCAttachment change") + log.FromContext(ctx).Error(err, "list NetworkServices for VPCAttachment change") return nil } var reqs []ctrlreconcile.Request @@ -522,12 +522,12 @@ func vpcRuleRequests(ctx context.Context, c client.Client, obj client.Object) [] return reqs } -// namespaceRuleRequests returns a reconcile request for every NetworkRule in +// namespaceRuleRequests returns a reconcile request for every NetworkService in // namespace. func namespaceRuleRequests(ctx context.Context, c client.Client, namespace string) []ctrlreconcile.Request { - list := &bgpv1alpha1.NetworkRuleList{} + list := &bgpv1alpha1.NetworkServiceList{} if err := c.List(ctx, list, client.InNamespace(namespace)); err != nil { - log.FromContext(ctx).Error(err, "list NetworkRules for NetworkRule change", "namespace", namespace) + log.FromContext(ctx).Error(err, "list NetworkServices for NetworkService change", "namespace", namespace) return nil } reqs := make([]ctrlreconcile.Request, 0, len(list.Items)) @@ -539,7 +539,7 @@ func namespaceRuleRequests(ctx context.Context, c client.Client, namespace strin // bindingRuleRequests maps a generated binding written for this node to its // rule. Other nodes' bindings and hand-written ones map to nothing. -func (r *NetworkRuleBindingReconciler) bindingRuleRequests(obj client.Object) []ctrlreconcile.Request { +func (r *NetworkServiceBindingReconciler) bindingRuleRequests(obj client.Object) []ctrlreconcile.Request { labels := obj.GetLabels() if labels[bindingManagedByLabel] != bindingManagedByValue || labels[bindingNodeLabel] != gatewayNodeLabelValue(r.NodeName) { diff --git a/internal/controller/networkrule_binding_controller_test.go b/internal/controller/networkservice_binding_controller_test.go similarity index 85% rename from internal/controller/networkrule_binding_controller_test.go rename to internal/controller/networkservice_binding_controller_test.go index ffece23f..1c35b007 100644 --- a/internal/controller/networkrule_binding_controller_test.go +++ b/internal/controller/networkservice_binding_controller_test.go @@ -19,9 +19,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // testBindingVIP is the IPv6 VIP the binding writer's rules use: the backend @@ -30,29 +30,31 @@ const testBindingVIP = "2001:db8:100::32" // newBindingTestRule returns an accepted rule with one IPv6 and one IPv4 VIP, // selecting newBackendAttachment's attachments in testVPCRef. -func newBindingTestRule() *bgpv1alpha1.NetworkRule { +func newBindingTestRule() *bgpv1alpha1.NetworkService { rule := newTestRule(testRuleName, testVPCRef, testBindingVIP, testVIP4) rule.UID = "rule-uid" acceptRule(rule) return rule } -// newBindingWriter returns a NetworkRuleBindingReconciler for +// newBindingWriter returns a NetworkServiceBindingReconciler for // testComputeNodeName over a fake client holding objs, with the // VPCAttachmentByVPC index galactic-router's cache registers. -func newBindingWriter(t *testing.T, objs ...client.Object) (*NetworkRuleBindingReconciler, client.Client) { +func newBindingWriter(t *testing.T, objs ...client.Object) (*NetworkServiceBindingReconciler, client.Client) { t.Helper() scheme := newRuleTestScheme(t) c := fake.NewClientBuilder(). WithScheme(scheme). WithIndex(&cloudv1alpha1.VPCAttachment{}, VPCAttachmentByVPC, vpcAttachmentVPC). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}, &bgpv1alpha1.ServiceVIPBinding{}, &cloudv1alpha1.VPCAttachment{}). + WithStatusSubresource( + &bgpv1alpha1.NetworkService{}, &bgpv1alpha1.ServiceVIPBinding{}, &cloudv1alpha1.VPCAttachment{}, + ). WithObjects(objs...). Build() - return &NetworkRuleBindingReconciler{Client: c, Scheme: scheme, NodeName: testComputeNodeName}, c + return &NetworkServiceBindingReconciler{Client: c, Scheme: scheme, NodeName: testComputeNodeName}, c } -func reconcileBindings(t *testing.T, r *NetworkRuleBindingReconciler) { +func reconcileBindings(t *testing.T, r *NetworkServiceBindingReconciler) { t.Helper() req := ctrl.Request{NamespacedName: client.ObjectKey{Namespace: testNamespace, Name: testRuleName}} if _, err := r.Reconcile(context.Background(), req); err != nil { @@ -95,20 +97,20 @@ func markBindingBound(t *testing.T, c client.Client, binding bgpv1alpha1.Service } } -func getBindingRule(t *testing.T, c client.Client) *bgpv1alpha1.NetworkRule { +func getBindingRule(t *testing.T, c client.Client) *bgpv1alpha1.NetworkService { t.Helper() - rule := &bgpv1alpha1.NetworkRule{} + rule := &bgpv1alpha1.NetworkService{} key := client.ObjectKey{Namespace: testNamespace, Name: testRuleName} if err := c.Get(context.Background(), key, rule); err != nil { - t.Fatalf("get NetworkRule: %v", err) + t.Fatalf("get NetworkService: %v", err) } return rule } -// TestNetworkRuleBindingReconciler_WritesBindingForLocalBackend is #799's -// regression test: applying only a NetworkRule produces a working binding on +// TestNetworkServiceBindingReconciler_WritesBindingForLocalBackend is #799's +// regression test: applying only a NetworkService produces a working binding on // the backend's node, with nothing written by hand. -func TestNetworkRuleBindingReconciler_WritesBindingForLocalBackend(t *testing.T) { +func TestNetworkServiceBindingReconciler_WritesBindingForLocalBackend(t *testing.T) { rule := newBindingTestRule() r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef)) @@ -124,7 +126,7 @@ func TestNetworkRuleBindingReconciler_WritesBindingForLocalBackend(t *testing.T) VPCRef: testVPCRef, VIPAddress: testBindingVIP, Port: 443, - Protocol: bgpv1alpha1.NetworkRuleProtocolTCP, + Protocol: bgpv1alpha1.NetworkServiceProtocolTCP, BackendAddress: testBackendAddr, BackendPort: testBackendPort, EgressKind: bgpv1alpha1.ServiceVIPBindingEgressKindVeth, @@ -141,15 +143,15 @@ func TestNetworkRuleBindingReconciler_WritesBindingForLocalBackend(t *testing.T) t.Errorf("binding labels = %v, want managed-by, rule and node labels", b.Labels) } owners := b.OwnerReferences - if len(owners) != 1 || owners[0].Kind != "NetworkRule" || owners[0].Name != testRuleName { - t.Errorf("binding owner references = %+v, want the NetworkRule", b.OwnerReferences) + if len(owners) != 1 || owners[0].Kind != "NetworkService" || owners[0].Name != testRuleName { + t.Errorf("binding owner references = %+v, want the NetworkService", b.OwnerReferences) } } -// TestNetworkRuleBindingReconciler_SameNodeBackendsGetOwnBindings covers two +// TestNetworkServiceBindingReconciler_SameNodeBackendsGetOwnBindings covers two // backends of one VIP on one node: each gets its own binding, which the // per-backend slot lets the node program side by side. -func TestNetworkRuleBindingReconciler_SameNodeBackendsGetOwnBindings(t *testing.T) { +func TestNetworkServiceBindingReconciler_SameNodeBackendsGetOwnBindings(t *testing.T) { rule := newBindingTestRule() r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef, testBackendAddr, "fd00:10::2")) @@ -161,10 +163,10 @@ func TestNetworkRuleBindingReconciler_SameNodeBackendsGetOwnBindings(t *testing. } } -// TestNetworkRuleBindingReconciler_OnlyThisNodesBackends covers a backend on +// TestNetworkServiceBindingReconciler_OnlyThisNodesBackends covers a backend on // another node and one in another VPC carrying the selected label: neither // gets a binding here. -func TestNetworkRuleBindingReconciler_OnlyThisNodesBackends(t *testing.T) { +func TestNetworkServiceBindingReconciler_OnlyThisNodesBackends(t *testing.T) { rule := newBindingTestRule() elsewhere := newBackendAttachment(testVPCRef, "fd00:10::7") elsewhere.Name = "elsewhere" @@ -179,11 +181,11 @@ func TestNetworkRuleBindingReconciler_OnlyThisNodesBackends(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_FollowsBackend covers a backend moving to +// TestNetworkServiceBindingReconciler_FollowsBackend covers a backend moving to // another node, changing address, leaving the selector, and moving to another // VPC: each time the stale binding goes and, where the backend is still local, // a new one comes. -func TestNetworkRuleBindingReconciler_FollowsBackend(t *testing.T) { +func TestNetworkServiceBindingReconciler_FollowsBackend(t *testing.T) { tests := []struct { name string change func(*cloudv1alpha1.VPCAttachment) @@ -245,10 +247,10 @@ func TestNetworkRuleBindingReconciler_FollowsBackend(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_WaitsForObservedVPC covers an attachment +// TestNetworkServiceBindingReconciler_WaitsForObservedVPC covers an attachment // whose VPC is not observed yet: it is in no VPC's index entry, so it gets no // binding until its status names the rule's VPC. -func TestNetworkRuleBindingReconciler_WaitsForObservedVPC(t *testing.T) { +func TestNetworkServiceBindingReconciler_WaitsForObservedVPC(t *testing.T) { attachment := newBackendAttachment(testVPCRef) attachment.Status.VPC = "" r, c := newBindingWriter(t, newBindingTestRule(), attachment) @@ -273,11 +275,11 @@ func TestNetworkRuleBindingReconciler_WaitsForObservedVPC(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_KeepsBindingsWhileUnaccepted covers a rule +// TestNetworkServiceBindingReconciler_KeepsBindingsWhileUnaccepted covers a rule // that loses Accepted, as it does whenever the namespace briefly has no // NetworkGateway: its bindings stay, so the backend nodes keep their // vip_xlat_table rows for when the gateways come back. -func TestNetworkRuleBindingReconciler_KeepsBindingsWhileUnaccepted(t *testing.T) { +func TestNetworkServiceBindingReconciler_KeepsBindingsWhileUnaccepted(t *testing.T) { rule := newBindingTestRule() r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef)) reconcileBindings(t, r) @@ -296,9 +298,9 @@ func TestNetworkRuleBindingReconciler_KeepsBindingsWhileUnaccepted(t *testing.T) } } -// TestNetworkRuleBindingReconciler_NeverAcceptedRuleHasNoBindings covers a +// TestNetworkServiceBindingReconciler_NeverAcceptedRuleHasNoBindings covers a // rule the gateways have not accepted yet: nothing is written for it. -func TestNetworkRuleBindingReconciler_NeverAcceptedRuleHasNoBindings(t *testing.T) { +func TestNetworkServiceBindingReconciler_NeverAcceptedRuleHasNoBindings(t *testing.T) { rule := newBindingTestRule() rule.Status.Conditions = nil r, c := newBindingWriter(t, rule, newBackendAttachment(testVPCRef)) @@ -310,10 +312,10 @@ func TestNetworkRuleBindingReconciler_NeverAcceptedRuleHasNoBindings(t *testing. } } -// TestNetworkRuleBindingReconciler_LeavesOtherBindingsAlone covers bindings +// TestNetworkServiceBindingReconciler_LeavesOtherBindingsAlone covers bindings // this node did not write: one written by hand, and one another node // generated for the same rule. Neither is deleted. -func TestNetworkRuleBindingReconciler_LeavesOtherBindingsAlone(t *testing.T) { +func TestNetworkServiceBindingReconciler_LeavesOtherBindingsAlone(t *testing.T) { rule := newBindingTestRule() handWritten := newTestServiceVIPBinding(bgpv1alpha1.ServiceVIPBindingEgressKindVeth, testComputeNodeName) handWritten.Labels = map[string]string{networkRuleLabel: testRuleName} @@ -337,9 +339,9 @@ func TestNetworkRuleBindingReconciler_LeavesOtherBindingsAlone(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_TapBackend covers a VM backend: its +// TestNetworkServiceBindingReconciler_TapBackend covers a VM backend: its // attachment's Hypervisor mode selects the tap mechanism. -func TestNetworkRuleBindingReconciler_TapBackend(t *testing.T) { +func TestNetworkServiceBindingReconciler_TapBackend(t *testing.T) { attachment := newBackendAttachment(testVPCRef) attachment.Spec.Interface.Mode = cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor r, c := newBindingWriter(t, newBindingTestRule(), attachment) @@ -352,10 +354,10 @@ func TestNetworkRuleBindingReconciler_TapBackend(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_BackendsBoundCondition covers the rule's +// TestNetworkServiceBindingReconciler_BackendsBoundCondition covers the rule's // per-node summary: False naming each binding not yet Bound, then True once // every binding reports Bound for its current generation. -func TestNetworkRuleBindingReconciler_BackendsBoundCondition(t *testing.T) { +func TestNetworkServiceBindingReconciler_BackendsBoundCondition(t *testing.T) { r, c := newBindingWriter(t, newBindingTestRule(), newBackendAttachment(testVPCRef)) condType := backendsBoundConditionType(testComputeNodeName) @@ -381,7 +383,7 @@ func TestNetworkRuleBindingReconciler_BackendsBoundCondition(t *testing.T) { // binding writer, each with the text its InvalidRule message must contain. var invalidRuleEdits = []struct { name string - edit func(*bgpv1alpha1.NetworkRule) + edit func(*bgpv1alpha1.NetworkService) message string }{ { @@ -389,14 +391,14 @@ var invalidRuleEdits = []struct { // with two IPv6 VIPs gets no bindings rather than one per VIP of // which all but one could only report Conflict. name: "second IPv6 VIP", - edit: func(rule *bgpv1alpha1.NetworkRule) { + edit: func(rule *bgpv1alpha1.NetworkService) { rule.Spec.VIPAddresses = append(rule.Spec.VIPAddresses, "2001:db8:100::33") }, message: "2 IPv6 VIPs", }, { name: "invalid selector", - edit: func(rule *bgpv1alpha1.NetworkRule) { + edit: func(rule *bgpv1alpha1.NetworkService) { rule.Spec.BackendSelector = metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{ {Key: testBackendLabel, Operator: "Bogus"}, }} @@ -405,12 +407,12 @@ var invalidRuleEdits = []struct { }, } -// TestNetworkRuleBindingReconciler_InvalidRuleOnServingNode covers a rule +// TestNetworkServiceBindingReconciler_InvalidRuleOnServingNode covers a rule // made invalid while this node holds bindings for it: the bindings, and with // them the node's vip_xlat_table rows, stay untouched while the node reports // InvalidRule on every pass, and reverting the edit returns the rule to Bound // on the same bindings. -func TestNetworkRuleBindingReconciler_InvalidRuleOnServingNode(t *testing.T) { +func TestNetworkServiceBindingReconciler_InvalidRuleOnServingNode(t *testing.T) { for _, tt := range invalidRuleEdits { t.Run(tt.name, func(t *testing.T) { r, c := newBindingWriter(t, newBindingTestRule(), newBackendAttachment(testVPCRef)) @@ -468,11 +470,11 @@ func TestNetworkRuleBindingReconciler_InvalidRuleOnServingNode(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_InvalidRuleOnIdleNode covers an invalid +// TestNetworkServiceBindingReconciler_InvalidRuleOnIdleNode covers an invalid // rule on a node that holds no bindings for it and reports no condition: the // gateways report the error, so this node writes no status at all, rather // than every router node in the cell writing its own copy. -func TestNetworkRuleBindingReconciler_InvalidRuleOnIdleNode(t *testing.T) { +func TestNetworkServiceBindingReconciler_InvalidRuleOnIdleNode(t *testing.T) { for _, tt := range invalidRuleEdits { t.Run(tt.name, func(t *testing.T) { rule := newBindingTestRule() @@ -494,11 +496,11 @@ func TestNetworkRuleBindingReconciler_InvalidRuleOnIdleNode(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_InvalidRuleRemovesStaleCondition covers an +// TestNetworkServiceBindingReconciler_InvalidRuleRemovesStaleCondition covers an // invalid rule on a node that holds no bindings for it but still carries a // condition from when it did: the node no longer serves the rule, so the // condition goes. -func TestNetworkRuleBindingReconciler_InvalidRuleRemovesStaleCondition(t *testing.T) { +func TestNetworkServiceBindingReconciler_InvalidRuleRemovesStaleCondition(t *testing.T) { for _, tt := range invalidRuleEdits { t.Run(tt.name, func(t *testing.T) { rule := newBindingTestRule() @@ -551,10 +553,10 @@ func TestVPCRuleRequests(t *testing.T) { } } -// TestNetworkRuleBindingReconciler_BindingRuleRequests covers the watch +// TestNetworkServiceBindingReconciler_BindingRuleRequests covers the watch // mapping: only this node's generated bindings requeue their rule. -func TestNetworkRuleBindingReconciler_BindingRuleRequests(t *testing.T) { - r := &NetworkRuleBindingReconciler{NodeName: testComputeNodeName} +func TestNetworkServiceBindingReconciler_BindingRuleRequests(t *testing.T) { + r := &NetworkServiceBindingReconciler{NodeName: testComputeNodeName} mine := &bgpv1alpha1.ServiceVIPBinding{ObjectMeta: metav1.ObjectMeta{ Namespace: testNamespace, Labels: map[string]string{ @@ -586,7 +588,7 @@ const ( ) // reconcileRule reconciles this node's bindings for the rule named name. -func reconcileRule(t *testing.T, r *NetworkRuleBindingReconciler, name string) { +func reconcileRule(t *testing.T, r *NetworkServiceBindingReconciler, name string) { t.Helper() req := ctrl.Request{NamespacedName: client.ObjectKey{Namespace: testNamespace, Name: name}} if _, err := r.Reconcile(context.Background(), req); err != nil { @@ -597,7 +599,7 @@ func reconcileRule(t *testing.T, r *NetworkRuleBindingReconciler, name string) { // newSharingBindingRule returns an accepted rule named name with the IPv6 VIP // vip, created at second created and using backendPort, selecting // newBackendAttachment's attachments in testVPCRef. -func newSharingBindingRule(name, vip string, created int64, backendPort int32) *bgpv1alpha1.NetworkRule { +func newSharingBindingRule(name, vip string, created int64, backendPort int32) *bgpv1alpha1.NetworkService { rule := newTestRule(name, testVPCRef, vip) rule.UID = types.UID(name + "-uid") rule.CreationTimestamp = metav1.NewTime(time.Unix(created, 0)) @@ -606,12 +608,12 @@ func newSharingBindingRule(name, vip string, created int64, backendPort int32) * return rule } -// TestNetworkRuleBindingReconciler_SharedBackendServesOldestRule covers two +// TestNetworkServiceBindingReconciler_SharedBackendServesOldestRule covers two // rules selecting one backend on one backend port: the node can translate the // backend's replies back to only one VIP, so only the older rule gets a // binding, and the newer one reports the backend as served by the older rule // rather than writing a binding that could only report Conflict. -func TestNetworkRuleBindingReconciler_SharedBackendServesOldestRule(t *testing.T) { +func TestNetworkServiceBindingReconciler_SharedBackendServesOldestRule(t *testing.T) { tests := []struct { name string newerBackendPort int32 @@ -644,7 +646,7 @@ func TestNetworkRuleBindingReconciler_SharedBackendServesOldestRule(t *testing.T t.Errorf("rule newer bindings = %d, want %d", byRule[testNewerRule], wantNewer) } - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} key := client.ObjectKey{Namespace: testNamespace, Name: testNewerRule} if err := c.Get(context.Background(), key, current); err != nil { t.Fatalf("get rule newer: %v", err) @@ -657,18 +659,18 @@ func TestNetworkRuleBindingReconciler_SharedBackendServesOldestRule(t *testing.T return } if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonBindingsClaimed || - !strings.Contains(cond.Message, "served by NetworkRule "+testNamespace+"/"+testOlderRule) { - t.Errorf("rule newer BackendsBound = %+v, want False/%s naming NetworkRule %s/older", + !strings.Contains(cond.Message, "served by NetworkService "+testNamespace+"/"+testOlderRule) { + t.Errorf("rule newer BackendsBound = %+v, want False/%s naming NetworkService %s/older", cond, reasonBindingsClaimed, testNamespace) } }) } } -// TestNetworkRuleBindingReconciler_TakesOverBackendFromDeletedRule covers the +// TestNetworkServiceBindingReconciler_TakesOverBackendFromDeletedRule covers the // older rule going away: the newer rule's next reconcile, which the rule watch // triggers for every rule in the namespace, writes its binding. -func TestNetworkRuleBindingReconciler_TakesOverBackendFromDeletedRule(t *testing.T) { +func TestNetworkServiceBindingReconciler_TakesOverBackendFromDeletedRule(t *testing.T) { older := newSharingBindingRule(testOlderRule, testBindingVIP, 100, testBackendPort) newer := newSharingBindingRule(testNewerRule, "2001:db8:100::33", 200, testBackendPort) r, c := newBindingWriter(t, newer, older, newBackendAttachment(testVPCRef)) diff --git a/internal/controller/networkrule_controller.go b/internal/controller/networkservice_controller.go similarity index 86% rename from internal/controller/networkrule_controller.go rename to internal/controller/networkservice_controller.go index 37dabaf8..8eb41b2c 100644 --- a/internal/controller/networkrule_controller.go +++ b/internal/controller/networkservice_controller.go @@ -25,13 +25,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/gateway" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) -// networkRuleFinalizer orders teardown on NetworkRule deletion: BGP-route +// networkRuleFinalizer orders teardown on NetworkService deletion: BGP-route // withdrawal must complete before NAT state is released. See reconcileDelete. -const networkRuleFinalizer = "galactic.datum.net/networkrule-teardown" +const networkRuleFinalizer = "galactic.datum.net/networkservice-teardown" // networkRuleLabel is set to the rule's name on every BGPAdvertisement it owns, // one per gateway node per non-empty VIP address family, so teardown can find @@ -81,7 +81,7 @@ func gatewayNodeLabelValue(nodeName string) string { return prefix + "-" + suffix } -// NetworkRuleReconciler owns the per-object NetworkRule lifecycle that +// NetworkServiceReconciler owns the per-object NetworkService lifecycle that // NetworkGatewayReconciler's aggregate, list-driven loop is the wrong place // for: setting the Accepted condition once gateway nodes exist, and the // finalizer-guarded teardown ordering on deletion. @@ -95,7 +95,7 @@ func gatewayNodeLabelValue(nodeName string) string { // Both jobs are safe to run from every gateway node's process without leader // election, none existing anywhere in this codebase, because they are // idempotent. -type NetworkRuleReconciler struct { +type NetworkServiceReconciler struct { client.Client Scheme *runtime.Scheme NodeName string @@ -110,17 +110,17 @@ type NetworkRuleReconciler struct { // again after it found an advertisement its cache had missed. const staleAdvertisementRequeue = time.Second -// Reconcile reconciles one NetworkRule's finalizer and Accepted condition. +// Reconcile reconciles one NetworkService's finalizer and Accepted condition. // Engine convergence and advertisement wiring for accepted rules happen in -// NetworkGatewayReconciler, which watches NetworkRule and re-lists on every +// NetworkGatewayReconciler, which watches NetworkService and re-lists on every // change, so this reconciler never touches the gateway engine. -func (r *NetworkRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - rule := &bgpv1alpha1.NetworkRule{} +func (r *NetworkServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + rule := &bgpv1alpha1.NetworkService{} if err := r.Get(ctx, req.NamespacedName, rule); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } - return ctrl.Result{}, fmt.Errorf("get NetworkRule %s: %w", req.NamespacedName, err) + return ctrl.Result{}, fmt.Errorf("get NetworkService %s: %w", req.NamespacedName, err) } isGateway, err := isGatewayNode(ctx, r.Client, rule.Namespace, r.NodeName) @@ -139,12 +139,12 @@ func (r *NetworkRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) patchBase := rule.DeepCopy() controllerutil.AddFinalizer(rule, networkRuleFinalizer) if err := r.Patch(ctx, rule, client.MergeFrom(patchBase)); err != nil { - return ctrl.Result{}, fmt.Errorf("add finalizer to NetworkRule %s: %w", req.NamespacedName, err) + return ctrl.Result{}, fmt.Errorf("add finalizer to NetworkService %s: %w", req.NamespacedName, err) } } if err := r.updateAcceptedCondition(ctx, rule); err != nil { - return ctrl.Result{}, fmt.Errorf("update Accepted condition for NetworkRule %s: %w", req.NamespacedName, err) + return ctrl.Result{}, fmt.Errorf("update Accepted condition for NetworkService %s: %w", req.NamespacedName, err) } return ctrl.Result{}, nil @@ -154,12 +154,14 @@ func (r *NetworkRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) // for this namespace. // // Accepted means only that: a gateway node is registered to serve the rule. It -// is not an ownership check. NetworkRule is internal, written by the platform +// is not an ownership check. NetworkService is internal, written by the platform // API that serves tenants, which verifies the tenant owns the VPC before it // writes the rule; who may write it here is decided by RBAC on its namespace. // NetworkGatewayReconciler programs only accepted rules, and nothing else sets // the condition. -func (r *NetworkRuleReconciler) updateAcceptedCondition(ctx context.Context, rule *bgpv1alpha1.NetworkRule) error { +func (r *NetworkServiceReconciler) updateAcceptedCondition( + ctx context.Context, rule *bgpv1alpha1.NetworkService, +) error { nodes, err := gatewayNodeNames(ctx, r.Client, rule.Namespace) if err != nil { return err @@ -197,7 +199,7 @@ func (r *NetworkRuleReconciler) updateAcceptedCondition(ctx context.Context, rul return r.Status().Update(ctx, ruleCopy) } -// reconcileDelete orders teardown on NetworkRule deletion: +// reconcileDelete orders teardown on NetworkService deletion: // // 1. Withdraw the BGP routes, by deleting this rule's BGPAdvertisements, // before touching NAT state. Removing that state while the route is still @@ -224,8 +226,8 @@ func (r *NetworkRuleReconciler) updateAcceptedCondition(ctx context.Context, rul // deletes it itself once its cache drops the rule (sweepOrphanedAdvertisements), // and the advertisement's owner reference to the rule lets Kubernetes garbage // collection delete it as a last resort. -func (r *NetworkRuleReconciler) reconcileDelete( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, +func (r *NetworkServiceReconciler) reconcileDelete( + ctx context.Context, rule *bgpv1alpha1.NetworkService, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -254,17 +256,17 @@ func (r *NetworkRuleReconciler) reconcileDelete( // the implementation behind it here is a no-op. Real per-tenant quota // release is deferred. if err := (gateway.NoopQuotaEnforcer{}).Release(ctx, rule.Namespace+"/"+rule.Name); err != nil { - return ctrl.Result{}, fmt.Errorf("release NAT/conntrack state for NetworkRule %s/%s: %w", + return ctrl.Result{}, fmt.Errorf("release NAT/conntrack state for NetworkService %s/%s: %w", rule.Namespace, rule.Name, err) } - logger.Info("NetworkRule BGP route withdrawn; each gateway node drops it from vip_table after its drain delay", + logger.Info("NetworkService BGP route withdrawn; each gateway node drops it from vip_table after its drain delay", "networkRule", rule.Name) patchBase := rule.DeepCopy() controllerutil.RemoveFinalizer(rule, networkRuleFinalizer) if err := r.Patch(ctx, rule, client.MergeFrom(patchBase)); err != nil { - return ctrl.Result{}, fmt.Errorf("remove finalizer from NetworkRule %s/%s: %w", rule.Namespace, rule.Name, err) + return ctrl.Result{}, fmt.Errorf("remove finalizer from NetworkService %s/%s: %w", rule.Namespace, rule.Name, err) } return ctrl.Result{}, nil } @@ -273,14 +275,14 @@ func (r *NetworkRuleReconciler) reconcileDelete( // deletes each one through c that is not already being deleted. It returns how // many it deleted. func deleteRuleAdvertisements( - ctx context.Context, reader client.Reader, c client.Client, rule *bgpv1alpha1.NetworkRule, + ctx context.Context, reader client.Reader, c client.Client, rule *bgpv1alpha1.NetworkService, ) (int, error) { advList := &bgpv1alpha1.BGPAdvertisementList{} if err := reader.List(ctx, advList, client.InNamespace(rule.Namespace), client.MatchingLabels{networkRuleLabel: rule.Name}, ); err != nil { - return 0, fmt.Errorf("list BGPAdvertisements for NetworkRule %s/%s teardown: %w", + return 0, fmt.Errorf("list BGPAdvertisements for NetworkService %s/%s teardown: %w", rule.Namespace, rule.Name, err) } deleted := 0 @@ -303,24 +305,24 @@ func deleteRuleAdvertisements( // SetupWithManager registers the reconciler with the manager. // // The NetworkGateway watch is the mirror of NetworkGatewayReconciler's -// NetworkRule watch: this reconciler's work depends on the namespace's +// NetworkService watch: this reconciler's work depends on the namespace's // gateway-node pool, so a rule must be re-examined whenever that pool changes // rather than waiting on its own periodic resync. -func (r *NetworkRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { +func (r *NetworkServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&bgpv1alpha1.NetworkRule{}). + For(&bgpv1alpha1.NetworkService{}). Watches(&bgpv1alpha1.NetworkGateway{}, handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []ctrlreconcile.Request { return gatewayToRuleRequests(ctx, r.Client, obj) }), ). - Named("networkrule"). + Named("networkservice"). Complete(r) } -// gatewayToRuleRequests maps a NetworkGateway change to every NetworkRule in +// gatewayToRuleRequests maps a NetworkGateway change to every NetworkService in // its namespace, the inverse of ruleToGatewayRequests and a deliberate -// broadcast for the same reason: a NetworkRule carries no gateway reference, +// broadcast for the same reason: a NetworkService carries no gateway reference, // and its Accepted condition depends on the whole gateway-node pool. Without // it, a rule created before any gateway node registered stays unaccepted, and // so excluded from every node's rule gathering, until the informer's periodic @@ -332,9 +334,9 @@ func gatewayToRuleRequests(ctx context.Context, c client.Client, obj client.Obje } logger := log.FromContext(ctx) - ruleList := &bgpv1alpha1.NetworkRuleList{} + ruleList := &bgpv1alpha1.NetworkServiceList{} if err := c.List(ctx, ruleList, client.InNamespace(gw.Namespace)); err != nil { - logger.Error(err, "list NetworkRules for NetworkGateway change", "networkGateway", gw.Name) + logger.Error(err, "list NetworkServices for NetworkGateway change", "networkGateway", gw.Name) return nil } reqs := make([]ctrlreconcile.Request, 0, len(ruleList.Items)) diff --git a/internal/controller/networkrule_controller_test.go b/internal/controller/networkservice_controller_test.go similarity index 84% rename from internal/controller/networkrule_controller_test.go rename to internal/controller/networkservice_controller_test.go index 7a0ab878..eaa2f8d9 100644 --- a/internal/controller/networkrule_controller_test.go +++ b/internal/controller/networkservice_controller_test.go @@ -18,13 +18,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Shared fixture identifiers for internal/controller's NetworkGateway/ -// NetworkRule tests (this file, networkgateway_controller_test.go, and +// NetworkService tests (this file, networkgateway_controller_test.go, and // usidresolver_test.go). const ( testNamespace = "ns" @@ -88,13 +88,13 @@ func newTestGateway(node string) *bgpv1alpha1.NetworkGateway { } } -func newTestRule(name, vpcRef string, vips ...string) *bgpv1alpha1.NetworkRule { - return &bgpv1alpha1.NetworkRule{ +func newTestRule(name, vpcRef string, vips ...string) *bgpv1alpha1.NetworkService { + return &bgpv1alpha1.NetworkService{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: name}, - Spec: bgpv1alpha1.NetworkRuleSpec{ + Spec: bgpv1alpha1.NetworkServiceSpec{ VPCRef: vpcRef, VIPAddresses: vips, - Protocol: bgpv1alpha1.NetworkRuleProtocolTCP, + Protocol: bgpv1alpha1.NetworkServiceProtocolTCP, Port: 443, BackendSelector: metav1.LabelSelector{ MatchLabels: map[string]string{testBackendLabel: testBackendLabelValue}, @@ -203,7 +203,7 @@ func testRuleKey(name string) types.NamespacedName { return types.NamespacedName{Namespace: testNamespace, Name: name} } -func TestNetworkRuleReconciler_SetsFinalizerAndAccepted(t *testing.T) { +func TestNetworkServiceReconciler_SetsFinalizerAndAccepted(t *testing.T) { scheme := newRuleTestScheme(t) gwA := newTestGateway(testNodeGWA) gwB := newTestGateway(testNodeGWB) @@ -211,18 +211,18 @@ func TestNetworkRuleReconciler_SetsFinalizerAndAccepted(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, gwB, rule). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), req.NamespacedName, got); err != nil { t.Fatalf("get rule: %v", err) } @@ -243,7 +243,7 @@ func TestNetworkRuleReconciler_SetsFinalizerAndAccepted(t *testing.T) { if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("second Reconcile: unexpected error: %v", err) } - got2 := &bgpv1alpha1.NetworkRule{} + got2 := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), req.NamespacedName, got2); err != nil { t.Fatalf("get rule after second reconcile: %v", err) } @@ -252,11 +252,11 @@ func TestNetworkRuleReconciler_SetsFinalizerAndAccepted(t *testing.T) { } } -// TestNetworkRuleReconciler_AcceptedFollowsSpecGeneration covers a spec edit to +// TestNetworkServiceReconciler_AcceptedFollowsSpecGeneration covers a spec edit to // an accepted rule: Accepted stays True but records the new generation, so // anything waiting on Accepted for the current spec, kubectl wait among them, // does not see a stale condition. -func TestNetworkRuleReconciler_AcceptedFollowsSpecGeneration(t *testing.T) { +func TestNetworkServiceReconciler_AcceptedFollowsSpecGeneration(t *testing.T) { scheme := newRuleTestScheme(t) rule := newTestRule(testRuleName, "vpc-1", testVIP) rule.Generation = 2 @@ -268,17 +268,17 @@ func TestNetworkRuleReconciler_AcceptedFollowsSpecGeneration(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), rule). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), req.NamespacedName, got); err != nil { t.Fatalf("get rule: %v", err) } @@ -288,7 +288,7 @@ func TestNetworkRuleReconciler_AcceptedFollowsSpecGeneration(t *testing.T) { } } -// TestNetworkRuleReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes +// TestNetworkServiceReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes // covers the converse of the above directly against updateAcceptedCondition: // with no NetworkGateway registered in the namespace, Accepted must be // False, not merely absent, so NetworkGatewayReconciler's @@ -303,22 +303,22 @@ func TestNetworkRuleReconciler_AcceptedFollowsSpecGeneration(t *testing.T) { // is exercised directly, the way it would be reached by a genuine // list-changed-between-calls race rather than through the reconciler's // normal entrypoint. -func TestNetworkRuleReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes(t *testing.T) { +func TestNetworkServiceReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes(t *testing.T) { scheme := newRuleTestScheme(t) rule := newTestRule(testRuleName, "vpc-1", testVIP) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(rule). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} if err := r.updateAcceptedCondition(context.Background(), rule); err != nil { t.Fatalf("updateAcceptedCondition: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), testRuleKey(testRuleName), got); err != nil { t.Fatalf("get rule: %v", err) } @@ -331,32 +331,32 @@ func TestNetworkRuleReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes(t } } -// TestNetworkRuleReconciler_GatewayAppearanceRequeuesParkedRule covers the +// TestNetworkServiceReconciler_GatewayAppearanceRequeuesParkedRule covers the // full recovery path for a rule created before any gateway node registered: // it parks at Accepted=False, and the NetworkGateway watch's mapper // (gatewayToRuleRequests) enqueues it as soon as a gateway registers, at // which point the ordinary reconcile flips it to Accepted=True. Without that // watch the rule waits for the informer's periodic resync (#367). -func TestNetworkRuleReconciler_GatewayAppearanceRequeuesParkedRule(t *testing.T) { +func TestNetworkServiceReconciler_GatewayAppearanceRequeuesParkedRule(t *testing.T) { ctx := context.Background() scheme := newRuleTestScheme(t) rule := newTestRule(testRuleName, "vpc-1", testVIP) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(rule). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} // No NetworkGateway exists yet, so this is the zero-nodes branch (see - // TestNetworkRuleReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes + // TestNetworkServiceReconciler_UpdateAcceptedCondition_FalseWithNoGatewayNodes // for why it is reached directly rather than through Reconcile). if err := r.updateAcceptedCondition(ctx, rule); err != nil { t.Fatalf("updateAcceptedCondition: unexpected error: %v", err) } - parked := &bgpv1alpha1.NetworkRule{} + parked := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(ctx, testRuleKey(testRuleName), parked); err != nil { t.Fatalf("get rule: %v", err) } @@ -390,7 +390,7 @@ func TestNetworkRuleReconciler_GatewayAppearanceRequeuesParkedRule(t *testing.T) if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: want}); err != nil { t.Fatalf("Reconcile after gateway registration: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(ctx, want, got); err != nil { t.Fatalf("get rule after requeue: %v", err) } @@ -399,25 +399,27 @@ func TestNetworkRuleReconciler_GatewayAppearanceRequeuesParkedRule(t *testing.T) } } -func TestNetworkRuleReconciler_NoOpOnNonGatewayNode(t *testing.T) { +func TestNetworkServiceReconciler_NoOpOnNonGatewayNode(t *testing.T) { scheme := newRuleTestScheme(t) gwA := newTestGateway(testNodeGWA) rule := newTestRule(testRuleName, "vpc-1", testVIP) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule). Build() // compute-node-1 is not a registered NetworkGateway target. - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testComputeNodeName} + r := &NetworkServiceReconciler{ + Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testComputeNodeName, + } req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) } - got := &bgpv1alpha1.NetworkRule{} + got := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(context.Background(), req.NamespacedName, got); err != nil { t.Fatalf("get rule: %v", err) } @@ -429,7 +431,7 @@ func TestNetworkRuleReconciler_NoOpOnNonGatewayNode(t *testing.T) { } } -func TestNetworkRuleReconciler_TeardownOrder_WithdrawsBGPBeforeRemovingFinalizer(t *testing.T) { +func TestNetworkServiceReconciler_TeardownOrder_WithdrawsBGPBeforeRemovingFinalizer(t *testing.T) { scheme := newRuleTestScheme(t) gwA := newTestGateway(testNodeGWA) rule := newTestRule(testRuleName, "vpc-1", testVIP) @@ -452,11 +454,11 @@ func TestNetworkRuleReconciler_TeardownOrder_WithdrawsBGPBeforeRemovingFinalizer fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule, adv). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) @@ -466,25 +468,25 @@ func TestNetworkRuleReconciler_TeardownOrder_WithdrawsBGPBeforeRemovingFinalizer gotAdv := &bgpv1alpha1.BGPAdvertisement{} err := fakeClient.Get(context.Background(), testRuleKey(testRuleAdv), gotAdv) if err == nil { - t.Fatal("BGPAdvertisement was not withdrawn on NetworkRule deletion") + t.Fatal("BGPAdvertisement was not withdrawn on NetworkService deletion") } - // The NetworkRule itself should now be gone (finalizer removed and no + // The NetworkService itself should now be gone (finalizer removed and no // other finalizers/owners keeping it around in the fake client). - gotRule := &bgpv1alpha1.NetworkRule{} + gotRule := &bgpv1alpha1.NetworkService{} err = fakeClient.Get(context.Background(), req.NamespacedName, gotRule) if err == nil { - t.Fatal("NetworkRule was not removed after finalizer teardown completed") + t.Fatal("NetworkService was not removed after finalizer teardown completed") } } -// TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementForDepartedNode is +// TestNetworkServiceReconciler_TeardownWithdrawsAdvertisementForDepartedNode is // the regression test for issue #367: an advertisement created for a node // that has since left the namespace (no corresponding NetworkGateway object // exists any more) must still be withdrawn on rule teardown. Discovery by // networkRuleLabel doesn't depend on the namespace's current gateway-node // membership the way reconstructing the name from gatewayNodeNames did. -func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementForDepartedNode(t *testing.T) { +func TestNetworkServiceReconciler_TeardownWithdrawsAdvertisementForDepartedNode(t *testing.T) { scheme := newRuleTestScheme(t) // Only gw-a is registered now; the advertisement below was created for // gw-b, which has since left. @@ -510,11 +512,11 @@ func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementForDepartedNode(t * fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule, adv). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) @@ -523,14 +525,14 @@ func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementForDepartedNode(t * gotAdv := &bgpv1alpha1.BGPAdvertisement{} err := fakeClient.Get(context.Background(), testRuleKey(departedNodeAdvName), gotAdv) if err == nil { - t.Fatal("BGPAdvertisement for a departed node was not withdrawn on NetworkRule deletion") + t.Fatal("BGPAdvertisement for a departed node was not withdrawn on NetworkService deletion") } } -// TestNetworkRuleReconciler_TeardownIgnoresAdvertisementForDifferentRule +// TestNetworkServiceReconciler_TeardownIgnoresAdvertisementForDifferentRule // guards against a label selector broad enough to sweep up another rule's // advertisement in the same namespace. -func TestNetworkRuleReconciler_TeardownIgnoresAdvertisementForDifferentRule(t *testing.T) { +func TestNetworkServiceReconciler_TeardownIgnoresAdvertisementForDifferentRule(t *testing.T) { scheme := newRuleTestScheme(t) gwA := newTestGateway(testNodeGWA) rule := newTestRule(testRuleName, "vpc-1", testVIP) @@ -555,11 +557,11 @@ func TestNetworkRuleReconciler_TeardownIgnoresAdvertisementForDifferentRule(t *t fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule, otherAdv). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error: %v", err) @@ -571,7 +573,7 @@ func TestNetworkRuleReconciler_TeardownIgnoresAdvertisementForDifferentRule(t *t } } -func TestNetworkRuleReconciler_TeardownIsIdempotentWhenAdvertisementAlreadyGone(t *testing.T) { +func TestNetworkServiceReconciler_TeardownIsIdempotentWhenAdvertisementAlreadyGone(t *testing.T) { scheme := newRuleTestScheme(t) gwA := newTestGateway(testNodeGWA) rule := newTestRule(testRuleName, "vpc-1", testVIP) @@ -581,11 +583,11 @@ func TestNetworkRuleReconciler_TeardownIsIdempotentWhenAdvertisementAlreadyGone( fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(gwA, rule). Build() - r := &NetworkRuleReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} + r := &NetworkServiceReconciler{Client: fakeClient, APIReader: fakeClient, Scheme: scheme, NodeName: testNodeGWA} req := ctrl.Request{NamespacedName: testRuleKey(testRuleName)} if _, err := r.Reconcile(context.Background(), req); err != nil { t.Fatalf("Reconcile: unexpected error when no BGPAdvertisement exists: %v", err) @@ -607,7 +609,7 @@ func (c staleListClient) List(ctx context.Context, list client.ObjectList, opts if c.hideAdvertisements { return nil } - case *bgpv1alpha1.NetworkRuleList: + case *bgpv1alpha1.NetworkServiceList: if c.hideRules { return nil } @@ -615,11 +617,11 @@ func (c staleListClient) List(ctx context.Context, list client.ObjectList, opts return c.Client.List(ctx, list, opts...) } -// TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementMissingFromCache is +// TestNetworkServiceReconciler_TeardownWithdrawsAdvertisementMissingFromCache is // the regression test for #763: an advertisement another gateway node created // after this node's cache was last updated must be found through APIReader // and withdrawn, and the finalizer kept until a pass finds none. -func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementMissingFromCache(t *testing.T) { +func TestNetworkServiceReconciler_TeardownWithdrawsAdvertisementMissingFromCache(t *testing.T) { scheme := newRuleTestScheme(t) rule := newTestRule(testRuleName, "vpc-1", testVIP) rule.Finalizers = []string{networkRuleFinalizer} @@ -629,11 +631,11 @@ func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementMissingFromCache(t fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}). WithObjects(newTestGateway(testNodeGWA), rule, adv). Build() - r := &NetworkRuleReconciler{ + r := &NetworkServiceReconciler{ Client: staleListClient{Client: fakeClient, hideAdvertisements: true}, APIReader: fakeClient, Scheme: scheme, @@ -649,9 +651,9 @@ func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementMissingFromCache(t if err := fakeClient.Get(ctx, testRuleKey(adv.Name), &bgpv1alpha1.BGPAdvertisement{}); !apierrors.IsNotFound(err) { t.Fatalf("BGPAdvertisement %s still exists (err=%v), want withdrawn", adv.Name, err) } - gotRule := &bgpv1alpha1.NetworkRule{} + gotRule := &bgpv1alpha1.NetworkService{} if err := fakeClient.Get(ctx, req.NamespacedName, gotRule); err != nil { - t.Fatalf("NetworkRule removed on the pass that found a missed advertisement: %v", err) + t.Fatalf("NetworkService removed on the pass that found a missed advertisement: %v", err) } if !controllerutil.ContainsFinalizer(gotRule, networkRuleFinalizer) { t.Fatal("finalizer removed on the pass that found a missed advertisement") @@ -663,7 +665,7 @@ func TestNetworkRuleReconciler_TeardownWithdrawsAdvertisementMissingFromCache(t if _, err := r.Reconcile(ctx, req); err != nil { t.Fatalf("second Reconcile: unexpected error: %v", err) } - if err := fakeClient.Get(ctx, req.NamespacedName, &bgpv1alpha1.NetworkRule{}); !apierrors.IsNotFound(err) { - t.Fatalf("NetworkRule still exists after a pass found no advertisements (err=%v)", err) + if err := fakeClient.Get(ctx, req.NamespacedName, &bgpv1alpha1.NetworkService{}); !apierrors.IsNotFound(err) { + t.Fatalf("NetworkService still exists after a pass found no advertisements (err=%v)", err) } } diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index 23b8fccd..590cbded 100644 --- a/internal/controller/node_controller.go +++ b/internal/controller/node_controller.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // NodeReconciler watches Node resources for IPv6 address changes and enqueues diff --git a/internal/controller/node_departure_controller.go b/internal/controller/node_departure_controller.go index a89296a4..bbc064ca 100644 --- a/internal/controller/node_departure_controller.go +++ b/internal/controller/node_departure_controller.go @@ -28,7 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // defaultNodeDepartureInterval is how often NodeDepartureReconciler sweeps @@ -48,7 +48,7 @@ var nodeDepartureRequest = ctrl.Request{NamespacedName: types.NamespacedName{Nam // there is nothing left to tear down. // // It runs on every router and every pass is idempotent: each sweep removes -// the departed nodes' BackendsBound conditions from every NetworkRule, +// the departed nodes' BackendsBound conditions from every NetworkService, // deletes their generated bindings and removes the teardown finalizer from // every binding targeting them, hand-written ones included. Writes retry on // conflict and treat NotFound as done, so routers sweeping at once converge. @@ -67,7 +67,7 @@ type NodeDepartureReconciler struct { Interval time.Duration } -// Reconcile sweeps every NetworkRule and ServiceVIPBinding for state left by +// Reconcile sweeps every NetworkService and ServiceVIPBinding for state left by // departed nodes. It returns the joined errors of any cleanup that failed, so // the sweep is retried, and otherwise requeues after Interval. func (r *NodeDepartureReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { @@ -76,9 +76,9 @@ func (r *NodeDepartureReconciler) Reconcile(ctx context.Context, _ ctrl.Request) interval = defaultNodeDepartureInterval } - rules := &bgpv1alpha1.NetworkRuleList{} + rules := &bgpv1alpha1.NetworkServiceList{} if err := r.List(ctx, rules); err != nil { - return ctrl.Result{}, fmt.Errorf("list NetworkRules: %w", err) + return ctrl.Result{}, fmt.Errorf("list NetworkServices: %w", err) } bindings := &bgpv1alpha1.ServiceVIPBindingList{} if err := r.List(ctx, bindings); err != nil { @@ -120,7 +120,7 @@ func (r *NodeDepartureReconciler) Reconcile(ctx context.Context, _ ctrl.Request) // node missing from the cached Node list counts only once APIReader reports // it NotFound. func (r *NodeDepartureReconciler) departedNodes( - ctx context.Context, rules []bgpv1alpha1.NetworkRule, bindings []bgpv1alpha1.ServiceVIPBinding, + ctx context.Context, rules []bgpv1alpha1.NetworkService, bindings []bgpv1alpha1.ServiceVIPBinding, ) (map[string]bool, error) { nodes := &corev1.NodeList{} if err := r.List(ctx, nodes); err != nil { @@ -162,14 +162,14 @@ func (r *NodeDepartureReconciler) departedNodes( // from rule, writing its status only if one was present. A rule deleted in // the meantime counts as done. func (r *NodeDepartureReconciler) clearRuleConditions( - ctx context.Context, rule *bgpv1alpha1.NetworkRule, departed map[string]bool, + ctx context.Context, rule *bgpv1alpha1.NetworkService, departed map[string]bool, ) error { if !hasDepartedCondition(rule.Status.Conditions, departed) { return nil } key := client.ObjectKeyFromObject(rule) err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - current := &bgpv1alpha1.NetworkRule{} + current := &bgpv1alpha1.NetworkService{} if err := r.Get(ctx, key, current); err != nil { return err } @@ -185,13 +185,13 @@ func (r *NodeDepartureReconciler) clearRuleConditions( return r.Status().Update(ctx, current) }) if err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("clear departed nodes' %s conditions on NetworkRule %s: %w", + return fmt.Errorf("clear departed nodes' %s conditions on NetworkService %s: %w", conditionTypeBackendsBound, key, err) } return nil } -// releaseBinding deletes binding if NetworkRuleBindingReconciler generated it, +// releaseBinding deletes binding if NetworkServiceBindingReconciler generated it, // then removes its teardown finalizer, which only the departed node it targets // would otherwise remove. A binding already gone counts as done. func (r *NodeDepartureReconciler) releaseBinding(ctx context.Context, binding *bgpv1alpha1.ServiceVIPBinding) error { diff --git a/internal/controller/node_departure_controller_test.go b/internal/controller/node_departure_controller_test.go index 5ab06532..c9a5c66b 100644 --- a/internal/controller/node_departure_controller_test.go +++ b/internal/controller/node_departure_controller_test.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( @@ -65,7 +65,7 @@ func newDepartureTestBinding(name, node string, generated bool) *bgpv1alpha1.Ser // newDepartureTestRule returns testRuleName with a BackendsBound condition // for each of nodes. -func newDepartureTestRule(nodes ...string) *bgpv1alpha1.NetworkRule { +func newDepartureTestRule(nodes ...string) *bgpv1alpha1.NetworkService { rule := newBindingTestRule() for _, node := range nodes { meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{ @@ -81,7 +81,7 @@ func newDepartureReconciler(t *testing.T, objs ...client.Object) (*NodeDeparture t.Helper() c := fake.NewClientBuilder(). WithScheme(newDepartureTestScheme(t)). - WithStatusSubresource(&bgpv1alpha1.NetworkRule{}, &bgpv1alpha1.ServiceVIPBinding{}). + WithStatusSubresource(&bgpv1alpha1.NetworkService{}, &bgpv1alpha1.ServiceVIPBinding{}). WithObjects(objs...). Build() return &NodeDepartureReconciler{Client: c, APIReader: c, Interval: testDepartureSweep}, c diff --git a/internal/controller/peer_events.go b/internal/controller/peer_events.go index 43391286..e4ec71f4 100644 --- a/internal/controller/peer_events.go +++ b/internal/controller/peer_events.go @@ -14,8 +14,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/model" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Event reason values this emitter raises, named so callers and tests share one diff --git a/internal/controller/peer_events_test.go b/internal/controller/peer_events_test.go index 39653a23..f4c72742 100644 --- a/internal/controller/peer_events_test.go +++ b/internal/controller/peer_events_test.go @@ -16,8 +16,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/model" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const testEmitterPeerAddr = "2607:ed40:1fb::2" diff --git a/internal/controller/routing.go b/internal/controller/routing.go index 5412646f..dbc6e1ef 100644 --- a/internal/controller/routing.go +++ b/internal/controller/routing.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // enqueueRoutersForTarget returns reconcile.Requests for BGPRouters in namespace diff --git a/internal/controller/rulebackends.go b/internal/controller/rulebackends.go index 50b98a0d..148dc822 100644 --- a/internal/controller/rulebackends.go +++ b/internal/controller/rulebackends.go @@ -20,12 +20,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) -// ruleBackend is one backend a NetworkRule's BackendSelector picks: one IPv6 +// ruleBackend is one backend a NetworkService's BackendSelector picks: one IPv6 // interface address of one selected VPCAttachment, on the rule's BackendPort. // // galactic-gateway load-balances to it and galactic-router on node writes its @@ -46,7 +46,7 @@ func (b ruleBackend) String() string { } // listVPCAttachments lists the VPCAttachments opts select, cluster-wide. -// Attachments live in tenant namespaces while NetworkRules live in +// Attachments live in tenant namespaces while NetworkServices live in // galactic-system, so the list is never namespaced; selectRuleBackends scopes // it to one rule's VPC. // @@ -97,7 +97,7 @@ func vpcAttachmentBackendChanged() predicate.Predicate { } } -// ruleSelection is what selectRuleBackends picks for one NetworkRule. +// ruleSelection is what selectRuleBackends picks for one NetworkService. type ruleSelection struct { // backends are the rule's backends, sorted by address. backends []ruleBackend @@ -119,7 +119,7 @@ type claimedBackend struct { // String returns the backend and its owner, the form status messages use. func (c claimedBackend) String() string { - return fmt.Sprintf("%s: backend %s is served by NetworkRule %s", c.attachment, c.ruleBackend, c.owner) + return fmt.Sprintf("%s: backend %s is served by NetworkService %s", c.attachment, c.ruleBackend, c.owner) } // listVPCAttachmentsInVPC lists the VPCAttachments whose observed VPC is vpc, @@ -156,7 +156,7 @@ func groupAttachmentsByVPC(attachments []*cloudv1alpha1.VPCAttachment) map[strin // // An invalid selector is a spec error and fails the rule. func selectRuleBackends( - rule *bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, owners backendOwners, + rule *bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, owners backendOwners, ) (ruleSelection, error) { candidates, pending, err := ruleCandidateBackends(rule, attachments) if err != nil { @@ -179,13 +179,13 @@ func selectRuleBackends( // selected attachments that are not backends yet, as "namespace/name: reason". // See selectRuleBackends for which attachments qualify. func ruleCandidateBackends( - rule *bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, + rule *bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, ) (backends []ruleBackend, pending []string, err error) { selector, err := ruleBackendSelector(rule) if err != nil { return nil, nil, err } - //nolint:gosec // BackendPort is CRD-validated to [1,65535] (Minimum/Maximum markers on NetworkRuleSpec.BackendPort) + //nolint:gosec // BackendPort is CRD-validated to [1,65535] (Minimum/Maximum markers on NetworkServiceSpec.BackendPort) port := uint16(rule.Spec.BackendPort) for _, attachment := range attachments { @@ -237,11 +237,11 @@ type backendClaim struct { vpc string addr netip.Addr port uint16 - protocol bgpv1alpha1.NetworkRuleProtocol + protocol bgpv1alpha1.NetworkServiceProtocol } // backendClaimFor returns the claim backend b of rule makes. -func backendClaimFor(rule *bgpv1alpha1.NetworkRule, b ruleBackend) backendClaim { +func backendClaimFor(rule *bgpv1alpha1.NetworkService, b ruleBackend) backendClaim { return backendClaim{vpc: rule.Spec.VPCRef, addr: b.addr, port: b.port, protocol: rule.Spec.Protocol} } @@ -256,9 +256,9 @@ type backendOwners map[backendClaim]types.NamespacedName // it has exactly one IPv6 VIP and a valid selector. A rule being deleted keeps // its claims, since its bindings, and their rows, last until it is gone. func ruleBackendOwners( - rules []bgpv1alpha1.NetworkRule, attachments []*cloudv1alpha1.VPCAttachment, + rules []bgpv1alpha1.NetworkService, attachments []*cloudv1alpha1.VPCAttachment, ) backendOwners { - ordered := make([]*bgpv1alpha1.NetworkRule, 0, len(rules)) + ordered := make([]*bgpv1alpha1.NetworkService, 0, len(rules)) for i := range rules { ordered = append(ordered, &rules[i]) } @@ -285,7 +285,7 @@ func ruleBackendOwners( // ruleOlder reports whether a was created before b, breaking a tie on // creation time, which has one-second resolution, by namespace and name. -func ruleOlder(a, b *bgpv1alpha1.NetworkRule) bool { +func ruleOlder(a, b *bgpv1alpha1.NetworkService) bool { if !a.CreationTimestamp.Equal(&b.CreationTimestamp) { return a.CreationTimestamp.Before(&b.CreationTimestamp) } @@ -342,7 +342,7 @@ func dropConflictingBackends(backends []ruleBackend) (kept []ruleBackend, reject // VIP's flows would be lost. For the same reason a backend serves only one // rule on a given backendPort (ruleBackendOwners). IPv4 VIPs are not // translated (#705). -func ruleTranslatedVIP(rule *bgpv1alpha1.NetworkRule) (netip.Addr, bool, error) { +func ruleTranslatedVIP(rule *bgpv1alpha1.NetworkService) (netip.Addr, bool, error) { var found []netip.Addr for _, v := range rule.Spec.VIPAddresses { vip, err := netip.ParseAddr(v) @@ -370,7 +370,7 @@ func ruleTranslatedVIP(rule *bgpv1alpha1.NetworkRule) (netip.Addr, bool, error) // ruleBackendSelector converts rule's BackendSelector to a labels.Selector. // The CRD rejects an empty selector; this rejects one anyway, since an empty // selector would otherwise match every attachment in the VPC. -func ruleBackendSelector(rule *bgpv1alpha1.NetworkRule) (labels.Selector, error) { +func ruleBackendSelector(rule *bgpv1alpha1.NetworkService) (labels.Selector, error) { sel := rule.Spec.BackendSelector if len(sel.MatchLabels) == 0 && len(sel.MatchExpressions) == 0 { return nil, errors.New("backendSelector is empty") diff --git a/internal/controller/rulebackends_predicate_test.go b/internal/controller/rulebackends_predicate_test.go index ce9e451c..f605a60a 100644 --- a/internal/controller/rulebackends_predicate_test.go +++ b/internal/controller/rulebackends_predicate_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/event" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" ) func TestVPCAttachmentBackendChanged(t *testing.T) { diff --git a/internal/controller/rulebackends_test.go b/internal/controller/rulebackends_test.go index f7031f19..25b97c6f 100644 --- a/internal/controller/rulebackends_test.go +++ b/internal/controller/rulebackends_test.go @@ -17,9 +17,9 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // collidingAddrs returns two addresses in fd00:10::/64 whose srv6.BackendSlot @@ -123,7 +123,7 @@ func TestSelectRuleBackends_DropsConflictingBackends(t *testing.T) { // newSharingRule returns a rule named name with the IPv6 VIP vip, created at // second created, selecting newBackendAttachment's attachments in testVPCRef. -func newSharingRule(name, vip string, created int64) *bgpv1alpha1.NetworkRule { +func newSharingRule(name, vip string, created int64) *bgpv1alpha1.NetworkService { rule := newTestRule(name, testVPCRef, vip) rule.CreationTimestamp = metav1.NewTime(time.Unix(created, 0)) return rule @@ -140,7 +140,7 @@ func TestRuleBackendOwners(t *testing.T) { tests := []struct { name string // change edits the older rule "a", then the newer rule "b". - changeA, changeB func(*bgpv1alpha1.NetworkRule) + changeA, changeB func(*bgpv1alpha1.NetworkService) wantOwner string wantBClaimed bool }{ @@ -151,41 +151,41 @@ func TestRuleBackendOwners(t *testing.T) { }, { name: "creation time decides before name", - changeA: func(r *bgpv1alpha1.NetworkRule) { r.CreationTimestamp = metav1.NewTime(time.Unix(300, 0)) }, + changeA: func(r *bgpv1alpha1.NetworkService) { r.CreationTimestamp = metav1.NewTime(time.Unix(300, 0)) }, wantOwner: "b", wantBClaimed: false, }, { name: "a creation time tie falls to the name", - changeB: func(r *bgpv1alpha1.NetworkRule) { r.CreationTimestamp = metav1.NewTime(time.Unix(100, 0)) }, + changeB: func(r *bgpv1alpha1.NetworkService) { r.CreationTimestamp = metav1.NewTime(time.Unix(100, 0)) }, wantOwner: "a", wantBClaimed: true, }, { name: "different backend ports do not collide", - changeB: func(r *bgpv1alpha1.NetworkRule) { r.Spec.BackendPort = testBackendPort + 1 }, + changeB: func(r *bgpv1alpha1.NetworkService) { r.Spec.BackendPort = testBackendPort + 1 }, wantOwner: "a", }, { name: "different protocols do not collide", - changeB: func(r *bgpv1alpha1.NetworkRule) { r.Spec.Protocol = bgpv1alpha1.NetworkRuleProtocolUDP }, + changeB: func(r *bgpv1alpha1.NetworkService) { r.Spec.Protocol = bgpv1alpha1.NetworkServiceProtocolUDP }, wantOwner: "a", }, { name: "a rule with two IPv6 VIPs claims nothing", - changeA: func(r *bgpv1alpha1.NetworkRule) { + changeA: func(r *bgpv1alpha1.NetworkService) { r.Spec.VIPAddresses = append(r.Spec.VIPAddresses, "2001:db8:100::34") }, wantOwner: "b", }, { name: "a rule with no IPv6 VIP claims nothing", - changeA: func(r *bgpv1alpha1.NetworkRule) { r.Spec.VIPAddresses = []string{testVIP4} }, + changeA: func(r *bgpv1alpha1.NetworkService) { r.Spec.VIPAddresses = []string{testVIP4} }, wantOwner: "b", }, { name: "a rule being deleted keeps its claim", - changeA: func(r *bgpv1alpha1.NetworkRule) { + changeA: func(r *bgpv1alpha1.NetworkService) { now := metav1.NewTime(time.Unix(400, 0)) r.DeletionTimestamp = &now }, @@ -205,7 +205,7 @@ func TestRuleBackendOwners(t *testing.T) { } attachments := []*cloudv1alpha1.VPCAttachment{newBackendAttachment(testVPCRef)} // The list order must not matter: the newer rule comes first. - owners := ruleBackendOwners([]bgpv1alpha1.NetworkRule{*b, *a}, attachments) + owners := ruleBackendOwners([]bgpv1alpha1.NetworkService{*b, *a}, attachments) owner := owners[backendClaimFor(a, ruleBackend{ addr: netip.MustParseAddr(testBackendAddr), port: testBackendPort, @@ -226,8 +226,8 @@ func TestRuleBackendOwners(t *testing.T) { t.Errorf("rule b backends = %+v, want none", selB.backends) } msg := selB.claimed[0].String() - if !strings.Contains(msg, "[fd00:10::1]:8443") || !strings.Contains(msg, "NetworkRule ns/a") { - t.Errorf("claimed message = %q, want it to name the backend and NetworkRule ns/a", msg) + if !strings.Contains(msg, "[fd00:10::1]:8443") || !strings.Contains(msg, "NetworkService ns/a") { + t.Errorf("claimed message = %q, want it to name the backend and NetworkService ns/a", msg) } } else if len(selB.backends) != 1 { t.Errorf("rule b backends = %+v, want its one backend", selB.backends) diff --git a/internal/controller/secret_controller.go b/internal/controller/secret_controller.go index b4c4b447..de15db96 100644 --- a/internal/controller/secret_controller.go +++ b/internal/controller/secret_controller.go @@ -15,7 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // SecretReconciler watches Secrets and enqueues BGPRouter(s) that reference diff --git a/internal/controller/serviceroute_access.go b/internal/controller/serviceroute_access.go index a9d73621..8adf1b59 100644 --- a/internal/controller/serviceroute_access.go +++ b/internal/controller/serviceroute_access.go @@ -12,9 +12,9 @@ import ( "k8s.io/apimachinery/pkg/types" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/serviceroute" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" ) const maxServiceAuthorizationDuration = 2 * time.Minute diff --git a/internal/controller/serviceroute_access_test.go b/internal/controller/serviceroute_access_test.go index 031e2e90..ac0dc0be 100644 --- a/internal/controller/serviceroute_access_test.go +++ b/internal/controller/serviceroute_access_test.go @@ -14,8 +14,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( @@ -191,7 +191,7 @@ func pscFixture(t *testing.T) ( }, Spec: networkv1alpha1.ServiceEndpointSpec{ Address: "fd70::10", - Protocol: networkv1alpha1.NetworkRuleProtocolUDP, + Protocol: networkv1alpha1.NetworkServiceProtocolUDP, Port: 53, DeliveryMode: networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal, AttachmentSelector: &metav1.LabelSelector{ diff --git a/internal/controller/serviceroute_controller.go b/internal/controller/serviceroute_controller.go index 6488deb4..e8628862 100644 --- a/internal/controller/serviceroute_controller.go +++ b/internal/controller/serviceroute_controller.go @@ -28,12 +28,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/predicate" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/serviceroute" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" ) const reconcileResultError = "error" diff --git a/internal/controller/serviceroute_controller_test.go b/internal/controller/serviceroute_controller_test.go index 9d950abe..8e1a08c9 100644 --- a/internal/controller/serviceroute_controller_test.go +++ b/internal/controller/serviceroute_controller_test.go @@ -18,11 +18,11 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/serviceroute" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( @@ -271,7 +271,7 @@ func TestServiceRouteStartupPermanentApplyFailureDoesNotRetryFinalizedGlobalSync Namespace: testServiceRoutePolicyNamespace, Name: testServiceRouteName, UID: "endpoint-uid", }, Spec: networkv1alpha1.ServiceEndpointSpec{ - Address: "10.0.0.53", Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53, + Address: "10.0.0.53", Protocol: networkv1alpha1.NetworkServiceProtocolUDP, Port: 53, DeliveryMode: networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal, AttachmentSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"producer": "yes"}}, }, diff --git a/internal/controller/serviceroute_programming.go b/internal/controller/serviceroute_programming.go index 67167426..6ea3b29d 100644 --- a/internal/controller/serviceroute_programming.go +++ b/internal/controller/serviceroute_programming.go @@ -13,9 +13,9 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/serviceroute" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" ) const serviceProgrammingAckDuration = 60 * time.Second diff --git a/internal/controller/serviceroute_programming_test.go b/internal/controller/serviceroute_programming_test.go index c7ba2254..98ce4ece 100644 --- a/internal/controller/serviceroute_programming_test.go +++ b/internal/controller/serviceroute_programming_test.go @@ -11,9 +11,9 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" ) const testPSCRemoteNode = "psc-node-b" diff --git a/internal/controller/servicevipbinding_conflict_test.go b/internal/controller/servicevipbinding_conflict_test.go index c7c8593d..95760dd3 100644 --- a/internal/controller/servicevipbinding_conflict_test.go +++ b/internal/controller/servicevipbinding_conflict_test.go @@ -20,8 +20,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Both backends fall inside testIPv6BackendPrefix, which newConflictReconciler diff --git a/internal/controller/servicevipbinding_controller.go b/internal/controller/servicevipbinding_controller.go index 2c877c6e..a1f0d0d0 100644 --- a/internal/controller/servicevipbinding_controller.go +++ b/internal/controller/servicevipbinding_controller.go @@ -26,12 +26,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/vipxlatmap" "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/plumbing/vip" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // serviceVIPBindingFinalizer guards teardown: the veth or tap unbind must @@ -679,13 +679,13 @@ func (e *vipRowConflictError) Error() string { return "not programmed: " + strings.Join(parts, "; ") } -// ipProtocolNumber maps a NetworkRuleProtocol to the IANA protocol number +// ipProtocolNumber maps a NetworkServiceProtocol to the IANA protocol number // vip_xlat_table's key uses (usid.c's USID_IPPROTO_TCP/UDP). -func ipProtocolNumber(proto bgpv1alpha1.NetworkRuleProtocol) (uint8, error) { +func ipProtocolNumber(proto bgpv1alpha1.NetworkServiceProtocol) (uint8, error) { switch proto { - case bgpv1alpha1.NetworkRuleProtocolTCP: + case bgpv1alpha1.NetworkServiceProtocolTCP: return ipProtoTCP, nil - case bgpv1alpha1.NetworkRuleProtocolUDP: + case bgpv1alpha1.NetworkServiceProtocolUDP: return ipProtoUDP, nil default: return 0, fmt.Errorf( diff --git a/internal/controller/servicevipbinding_controller_test.go b/internal/controller/servicevipbinding_controller_test.go index 9a457a4c..644071ba 100644 --- a/internal/controller/servicevipbinding_controller_test.go +++ b/internal/controller/servicevipbinding_controller_test.go @@ -20,8 +20,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/vipxlatmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( @@ -41,7 +41,7 @@ func newTestServiceVIPBinding( VPCRef: testVPCRef, VIPAddress: testVIPBindingVIPAddr, Port: 8080, - Protocol: bgpv1alpha1.NetworkRuleProtocolTCP, + Protocol: bgpv1alpha1.NetworkServiceProtocolTCP, BackendAddress: testVIPBindingBackendAddr, BackendPort: 30080, EgressKind: egressKind, diff --git a/internal/controller/servicevipbinding_xlat_test.go b/internal/controller/servicevipbinding_xlat_test.go index 32497bc4..90502147 100644 --- a/internal/controller/servicevipbinding_xlat_test.go +++ b/internal/controller/servicevipbinding_xlat_test.go @@ -18,10 +18,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/ebpf/vipxlatmap" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // testIPv6BackendPrefix is an advertised prefix containing diff --git a/internal/controller/status.go b/internal/controller/status.go index 2095cee4..b5b2be71 100644 --- a/internal/controller/status.go +++ b/internal/controller/status.go @@ -8,7 +8,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // Condition types used across BGP resources. @@ -124,10 +124,10 @@ func setBindingCondition(binding *bgpv1alpha1.ServiceVIPBinding, condition metav meta.SetStatusCondition(&binding.Status.Conditions, condition) } -// setRuleCondition sets or updates a condition on a NetworkRule. It uses two +// setRuleCondition sets or updates a condition on a NetworkService. It uses two // types: Accepted, once gateway nodes exist to serve it, and Ready, from the // engine's convergence result for that rule. -func setRuleCondition(rule *bgpv1alpha1.NetworkRule, condition metav1.Condition) { +func setRuleCondition(rule *bgpv1alpha1.NetworkService, condition metav1.Condition) { condition.ObservedGeneration = rule.Generation meta.SetStatusCondition(&rule.Status.Conditions, condition) } diff --git a/internal/controller/usidresolver.go b/internal/controller/usidresolver.go index b3647671..b120367d 100644 --- a/internal/controller/usidresolver.go +++ b/internal/controller/usidresolver.go @@ -11,13 +11,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) -// backendSIDIndex resolves a NetworkRule backend's address to the SRv6 uSID of +// backendSIDIndex resolves a NetworkService backend's address to the SRv6 uSID of // the worker node it is reachable through, by listing BGPAdvertisement, // BGPRouter, and BGPVRFInstance CRDs and matching by prefix containment, then // confirming tenant ownership of the match before trusting it. diff --git a/internal/controller/usidresolver_test.go b/internal/controller/usidresolver_test.go index eb921cd3..506f4ff4 100644 --- a/internal/controller/usidresolver_test.go +++ b/internal/controller/usidresolver_test.go @@ -12,9 +12,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // testVPCRef is the tenant identity used by the single-VPC fixtures below. @@ -80,7 +80,7 @@ func TestBackendSIDIndex_NoMatchingPrefixIsError(t *testing.T) { } // TestBackendSIDIndex_ExcludesAdvertisementsWithoutVRFIDOrFunction verifies -// that an advertisement with no SRv6 VRFID/Function (e.g. a NetworkRule VIP +// that an advertisement with no SRv6 VRFID/Function (e.g. a NetworkService VIP // advertisement -- see NetworkGatewayReconciler.applyBGPAdvertisements) // is excluded from matching even if its prefix would otherwise contain the // address, since it carries no SRv6 decap behavior of its own. diff --git a/internal/controller/vipxlat_sweep.go b/internal/controller/vipxlat_sweep.go index 60e328f2..71e82e32 100644 --- a/internal/controller/vipxlat_sweep.go +++ b/internal/controller/vipxlat_sweep.go @@ -10,9 +10,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/gc" "go.datum.net/galactic/internal/plumbing/ebpf/vipxlatmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // VIPXlatSweepTable is the part of *vipxlatmap.VipXlatTable VIPXlatSweeper diff --git a/internal/controller/vipxlat_sweep_test.go b/internal/controller/vipxlat_sweep_test.go index b30fb221..21efb2c1 100644 --- a/internal/controller/vipxlat_sweep_test.go +++ b/internal/controller/vipxlat_sweep_test.go @@ -15,9 +15,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/vipxlatmap" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/controller/vpcattachment_index.go b/internal/controller/vpcattachment_index.go index 09275bca..58d3edf7 100644 --- a/internal/controller/vpcattachment_index.go +++ b/internal/controller/vpcattachment_index.go @@ -10,7 +10,7 @@ import ( "k8s.io/apimachinery/pkg/types" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" ) // VPCAttachmentIndex indexes Cloud API attachments by their observed node. diff --git a/internal/controller/vpcattachment_index_test.go b/internal/controller/vpcattachment_index_test.go index 962657bd..8d804e02 100644 --- a/internal/controller/vpcattachment_index_test.go +++ b/internal/controller/vpcattachment_index_test.go @@ -9,7 +9,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" ) func TestVPCAttachmentIndexTracksObservedNode(t *testing.T) { diff --git a/internal/crdnames/crdnames.go b/internal/crdnames/crdnames.go index 4686b696..3d95c124 100644 --- a/internal/crdnames/crdnames.go +++ b/internal/crdnames/crdnames.go @@ -199,7 +199,7 @@ func BGPAdvertisementName(vpc, vpcAttachment, nodeName string) string { const serviceVIPBindingHashLen = 16 // ServiceVIPBindingName returns the deterministic name of the ServiceVIPBinding -// galactic-router writes for one backend of one NetworkRule VIP on nodeName: +// galactic-router writes for one backend of one NetworkService VIP on nodeName: // "--", where the hash covers every part of the identity, so // two backends of one VIP on one node, the case a selector makes routine, get // distinct names. The readable prefix is cut to keep the result within an diff --git a/internal/fabric/api/v1alpha1/fabricquery_types.go b/internal/fabric/api/v1alpha1/fabricquery_types.go index d1004831..b6c07a2d 100644 --- a/internal/fabric/api/v1alpha1/fabricquery_types.go +++ b/internal/fabric/api/v1alpha1/fabricquery_types.go @@ -12,9 +12,9 @@ import ( // requests without reading their specs. const ( // LabelRequestID carries spec.requestID. - LabelRequestID = "network.datumapis.com/fabric-request-id" + LabelRequestID = "galactic.datumapis.com/fabric-request-id" // LabelExpiresAt carries spec.expiresAt as Unix seconds. - LabelExpiresAt = "network.datumapis.com/fabric-expires-at" + LabelExpiresAt = "galactic.datumapis.com/fabric-expires-at" ) // Condition types. Their truth values and reasons are part of the contract. diff --git a/internal/fabric/api/v1alpha1/groupversion_info.go b/internal/fabric/api/v1alpha1/groupversion_info.go index 05744385..80e57c82 100644 --- a/internal/fabric/api/v1alpha1/groupversion_info.go +++ b/internal/fabric/api/v1alpha1/groupversion_info.go @@ -3,14 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Package v1alpha1 contains the FabricQuery API, the internal -// network.datumapis.com/v1alpha1 kind NSO writes on the federation hub, one +// galactic.datumapis.com/v1alpha1 kind NSO writes on the federation hub, one // per resolved edge cell, and the cell's fabric-api gateway executes. // // It belongs in the network repository beside EgressShard; it is defined // here until it ships there, with the same group and version, so galactic can // switch to the network import without a wire change. // +kubebuilder:object:generate=true -// +groupName=network.datumapis.com +// +groupName=galactic.datumapis.com package v1alpha1 import ( @@ -21,7 +21,7 @@ import ( var ( // GroupVersion is the group version used to register these objects. - GroupVersion = schema.GroupVersion{Group: "network.datumapis.com", Version: "v1alpha1"} + GroupVersion = schema.GroupVersion{Group: "galactic.datumapis.com", Version: "v1alpha1"} // SchemeBuilder adds this package's kinds to a scheme. SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) diff --git a/internal/fabric/gateway/reconciler_test.go b/internal/fabric/gateway/reconciler_test.go index bbc55a66..755b5b8e 100644 --- a/internal/fabric/gateway/reconciler_test.go +++ b/internal/fabric/gateway/reconciler_test.go @@ -429,7 +429,7 @@ func TestReconcileRetriesStatusConflictWithoutRerunning(t *testing.T) { ) error { fq := obj.(*fabricapi.FabricQuery) if terminal(fq) && conflicts.Add(1) <= 2 { - gr := schema.GroupResource{Group: "network.datumapis.com", Resource: "fabricqueries"} + gr := schema.GroupResource{Group: "galactic.datumapis.com", Resource: "fabricqueries"} return apierrors.NewConflict(gr, fq.Name, nil) } return c.SubResource(sub).Update(ctx, obj, opts...) diff --git a/internal/gateway/doc.go b/internal/gateway/doc.go index 392ec417..de0d7c8c 100644 --- a/internal/gateway/doc.go +++ b/internal/gateway/doc.go @@ -4,7 +4,7 @@ // Package gateway implements the in-process edge Maglev direct-server-return // load-balancing engine. One Engine runs per gateway-role node, converging that -// node's accepted NetworkRule resources into calls against a pluggable +// node's accepted NetworkService resources into calls against a pluggable // Datapath. // // It backs onto the edge XDP program directly: no tunnel, no per-tenant diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 3b697102..11b8c336 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -30,13 +30,13 @@ func (b DesiredBackend) Key() string { return fmt.Sprintf("%s:%d", b.Address, b.Port) } -// DesiredRule is the engine's representation of one NetworkRule, assembled by +// DesiredRule is the engine's representation of one NetworkService, assembled by // the reconcilers from the CRD. There is no tunnel identifier or VRF table here, // this engine having no VRF dependency, and no placement field either, every // gateway node serving every rule identically under anycast. type DesiredRule struct { // Key uniquely identifies the rule (namespace/name of the source - // NetworkRule), used as the map key in Engine's convergence pass. + // NetworkService), used as the map key in Engine's convergence pass. Key string // VPCRef is the tenant's opaque VPC identifier, used to count rules against @@ -48,7 +48,7 @@ type DesiredRule struct { VIPAddresses []netip.Addr // Protocol is "tcp" or "udp", matching - // network.datumapis.com/v1alpha1's NetworkRuleProtocol enum values. + // galactic.datumapis.com/v1alpha1's NetworkServiceProtocol enum values. Protocol string Port uint16 Backends []DesiredBackend diff --git a/internal/gc/datapath_repair.go b/internal/gc/datapath_repair.go index dc55d4c1..d02c73aa 100644 --- a/internal/gc/datapath_repair.go +++ b/internal/gc/datapath_repair.go @@ -19,12 +19,12 @@ import ( "golang.org/x/sys/unix" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // hostLinkNameRegex matches the host-side interface name the master plugins diff --git a/internal/gc/datapath_repair_root_test.go b/internal/gc/datapath_repair_root_test.go index 468cb7b7..308ed5c6 100644 --- a/internal/gc/datapath_repair_root_test.go +++ b/internal/gc/datapath_repair_root_test.go @@ -24,6 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/cni/tap" "go.datum.net/galactic/internal/cni/veth" "go.datum.net/galactic/internal/cniipam" @@ -36,7 +37,6 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/vrf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // rebuiltMaps are the pinned maps RepairAttachmentDatapath rebuilds. diff --git a/internal/gc/datapath_repair_test.go b/internal/gc/datapath_repair_test.go index 46664558..0c53941f 100644 --- a/internal/gc/datapath_repair_test.go +++ b/internal/gc/datapath_repair_test.go @@ -20,6 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attachreg" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" @@ -27,7 +28,6 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/intf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // memTable is an in-memory usidmap.Table keyed by any comparable key, copying diff --git a/internal/gc/gc.go b/internal/gc/gc.go index 4f9f7875..89bdf31f 100644 --- a/internal/gc/gc.go +++ b/internal/gc/gc.go @@ -19,13 +19,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/nptv6map" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/nptv6" "go.datum.net/galactic/internal/plumbing/vrf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // annotationNetNS is the annotation key prefix the CNI plugin uses to record diff --git a/internal/gc/gc_ebpf_test.go b/internal/gc/gc_ebpf_test.go index 67461069..a964a51e 100644 --- a/internal/gc/gc_ebpf_test.go +++ b/internal/gc/gc_ebpf_test.go @@ -16,11 +16,11 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/nptv6map" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // testRouteTarget is the import/export route target value shared by every diff --git a/internal/gc/gc_test.go b/internal/gc/gc_test.go index 1b802cda..39b5d81a 100644 --- a/internal/gc/gc_test.go +++ b/internal/gc/gc_test.go @@ -9,9 +9,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/intf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/ingresssidecar/gateway.go b/internal/ingresssidecar/gateway.go index 8268e945..b7a653cb 100644 --- a/internal/ingresssidecar/gateway.go +++ b/internal/ingresssidecar/gateway.go @@ -18,10 +18,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/intf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // ingressVPCAttachment is the synthetic attachment segment diff --git a/internal/ingresssidecar/gateway_test.go b/internal/ingresssidecar/gateway_test.go index 2a82ec3e..203adfd6 100644 --- a/internal/ingresssidecar/gateway_test.go +++ b/internal/ingresssidecar/gateway_test.go @@ -18,9 +18,9 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/intf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/ingresssidecar/nodesourceaddress.go b/internal/ingresssidecar/nodesourceaddress.go index 62058e16..73d2959d 100644 --- a/internal/ingresssidecar/nodesourceaddress.go +++ b/internal/ingresssidecar/nodesourceaddress.go @@ -12,8 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // NodeSourceAddressResolver resolves this node's own SRv6 SID base, the value diff --git a/internal/ingresssidecar/nodesourceaddress_test.go b/internal/ingresssidecar/nodesourceaddress_test.go index 25da4ab0..56840b0f 100644 --- a/internal/ingresssidecar/nodesourceaddress_test.go +++ b/internal/ingresssidecar/nodesourceaddress_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client/fake" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( diff --git a/internal/installer/egressgroup.go b/internal/installer/egressgroup.go index dd276953..6cf5e8dc 100644 --- a/internal/installer/egressgroup.go +++ b/internal/installer/egressgroup.go @@ -24,13 +24,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/metrics" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // egressGroupDebounce is how long a burst of route, neighbor or EgressShard diff --git a/internal/installer/egressgroup_test.go b/internal/installer/egressgroup_test.go index 07d2a628..ade1b27b 100644 --- a/internal/installer/egressgroup_test.go +++ b/internal/installer/egressgroup_test.go @@ -18,13 +18,13 @@ import ( "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/metrics" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) var ( diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 5fb29ccd..99422c03 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -30,6 +30,7 @@ import ( "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/gc" "go.datum.net/galactic/internal/hostconf" @@ -40,7 +41,6 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/radv" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // ebpfHealthCheckInterval controls how often Run polls attach.Health once the diff --git a/internal/installer/locatorroute.go b/internal/installer/locatorroute.go index 60ecf250..032c97c2 100644 --- a/internal/installer/locatorroute.go +++ b/internal/installer/locatorroute.go @@ -16,8 +16,8 @@ import ( "golang.org/x/sys/unix" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // ensureLocatorLocalRoute installs a local route for this node's uSID locator diff --git a/internal/installer/locatorroute_test.go b/internal/installer/locatorroute_test.go index adf631f1..5f24cdc3 100644 --- a/internal/installer/locatorroute_test.go +++ b/internal/installer/locatorroute_test.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( diff --git a/internal/installer/sidecarreap.go b/internal/installer/sidecarreap.go index 0c4ad26b..136a78b4 100644 --- a/internal/installer/sidecarreap.go +++ b/internal/installer/sidecarreap.go @@ -19,10 +19,10 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/sidecarmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Removing what a deleted ingress sidecar pod leaves behind. diff --git a/internal/installer/sidecarreap_test.go b/internal/installer/sidecarreap_test.go index 3bd7909a..61912aa3 100644 --- a/internal/installer/sidecarreap_test.go +++ b/internal/installer/sidecarreap_test.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // Host-side interface indexes the tests' advertisements record: one that names diff --git a/internal/installer/sidecarreturn.go b/internal/installer/sidecarreturn.go index 066223c1..2882dcbb 100644 --- a/internal/installer/sidecarreturn.go +++ b/internal/installer/sidecarreturn.go @@ -18,11 +18,11 @@ import ( "golang.org/x/sys/unix" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // The ingress sidecar's return path, host side. diff --git a/internal/installer/sidecarreturn_test.go b/internal/installer/sidecarreturn_test.go index 8f9941f7..bbf268c7 100644 --- a/internal/installer/sidecarreturn_test.go +++ b/internal/installer/sidecarreturn_test.go @@ -17,10 +17,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/vrf" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/model/types.go b/internal/model/types.go index f429ce28..e75c11f4 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // Re-export BGP API enum types for use throughout galactic-router. diff --git a/internal/plumbing/ebpf/edgeattach/attach.go b/internal/plumbing/ebpf/edgeattach/attach.go index 7186067f..e0eb4801 100644 --- a/internal/plumbing/ebpf/edgeattach/attach.go +++ b/internal/plumbing/ebpf/edgeattach/attach.go @@ -114,7 +114,7 @@ func Load(pinDir string, dispatchMaps map[string]*ebpf.Map) (*edgeprog.EdgedsrOb pinDir, loadErr) } slog.Warn("edgeattach: pinned eBPF maps incompatible with the newly compiled map spec, recreating them "+ - "(control-plane state will repopulate on the next NetworkRule reconcile)", + "(control-plane state will repopulate on the next NetworkService reconcile)", "pinDir", pinDir, "maps", unpinned, "err", loadErr) if loadErr = spec.LoadAndAssign(&loaded, opts); loadErr != nil { loadErr = fmt.Errorf("after recreating %v: %w", unpinned, loadErr) diff --git a/internal/plumbing/ebpf/edgemap/viptable.go b/internal/plumbing/ebpf/edgemap/viptable.go index d354a357..87b18cf3 100644 --- a/internal/plumbing/ebpf/edgemap/viptable.go +++ b/internal/plumbing/ebpf/edgemap/viptable.go @@ -405,7 +405,7 @@ func (t *VIPTable) List() ([]VIPEntry, error) { } // Reconcile brings vip_table into agreement with live, the caller's current set -// of keys backed by a live NetworkRule, removing every entry whose key is absent +// of keys backed by a live NetworkService, removing every entry whose key is absent // from live except one whose generation is at or above cutoff, which was written // after the caller's snapshot and could race a fresh Register. func (t *VIPTable) Reconcile(live map[VIPKey]struct{}, cutoff uint64) (removed []VIPEntry, err error) { diff --git a/internal/plumbing/ebpf/edgemap/viptable_test.go b/internal/plumbing/ebpf/edgemap/viptable_test.go index aae19155..15e7efbb 100644 --- a/internal/plumbing/ebpf/edgemap/viptable_test.go +++ b/internal/plumbing/ebpf/edgemap/viptable_test.go @@ -273,7 +273,7 @@ func TestVIPTable_ReconcileRemovesStaleNotLive(t *testing.T) { // The cutoff snapshot is taken strictly after both entries were // written (generation 100 < cutoff 150), matching the real ordering // contract: capture Generation() immediately before listing the live - // NetworkRule CRDs, i.e. after whatever already-registered state + // NetworkService CRDs, i.e. after whatever already-registered state // exists, not before it. fakeClock = 150 cutoff := vt.Generation() diff --git a/internal/plumbing/srv6/usid.go b/internal/plumbing/srv6/usid.go index 5a9afe5d..6c832648 100644 --- a/internal/plumbing/srv6/usid.go +++ b/internal/plumbing/srv6/usid.go @@ -9,8 +9,8 @@ import ( "hash/fnv" "net/netip" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // functionNibble maps an SRv6Function to its 4-bit uFMT 48+16 Function value. diff --git a/internal/plumbing/srv6/usid_test.go b/internal/plumbing/srv6/usid_test.go index 1f74710a..af48d466 100644 --- a/internal/plumbing/srv6/usid_test.go +++ b/internal/plumbing/srv6/usid_test.go @@ -8,8 +8,8 @@ import ( "net/netip" "testing" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const testUSIDLocator = "2001:db8:ff01::/48" diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go index db6a046c..84626fcb 100644 --- a/internal/reconcile/reconcile.go +++ b/internal/reconcile/reconcile.go @@ -18,9 +18,9 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/model" "go.datum.net/galactic/internal/plumbing/srv6" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) // legacySRv6SIDAnnotation carries a SID directly, for a BGPAdvertisement or diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go index b6866596..c66529c8 100644 --- a/internal/reconcile/reconcile_test.go +++ b/internal/reconcile/reconcile_test.go @@ -13,8 +13,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/model" - bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) const ( diff --git a/internal/runtime/gobgp/monitor_test.go b/internal/runtime/gobgp/monitor_test.go index 3fd0786f..7f5fcacf 100644 --- a/internal/runtime/gobgp/monitor_test.go +++ b/internal/runtime/gobgp/monitor_test.go @@ -63,7 +63,7 @@ func TestMatchTableID_UnknownVRFSkipped(t *testing.T) { // anycast ingress-VIP advertisements (buildEVPNPaths never attaches the // attribute when adv.Communities is empty) -- must resolve to a plain // route (plain=true), not be silently dropped. This is the exact bug found -// live in containerlab: an ns60 NetworkRule's VIP BGPAdvertisement reported +// live in containerlab: an ns60 NetworkService's VIP BGPAdvertisement reported // Advertised/Ready, but no receiving node anywhere in the mesh ever // installed a route for it. func TestMatchTableID_RTLessPathIsPlain(t *testing.T) { diff --git a/internal/serviceroute/conflicts_test.go b/internal/serviceroute/conflicts_test.go index d473a606..209993b9 100644 --- a/internal/serviceroute/conflicts_test.go +++ b/internal/serviceroute/conflicts_test.go @@ -9,7 +9,7 @@ import ( "k8s.io/apimachinery/pkg/types" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) func TestTranslatedConflictScopePreservesDistinctAttachments(t *testing.T) { diff --git a/internal/serviceroute/dataplane.go b/internal/serviceroute/dataplane.go index 0e974152..5d978879 100644 --- a/internal/serviceroute/dataplane.go +++ b/internal/serviceroute/dataplane.go @@ -20,9 +20,9 @@ import ( "github.com/cilium/ebpf" "github.com/vishvananda/netlink" + api "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/serviceroutemap" - api "go.datum.net/network/api/v1alpha1" ) type routeRef struct { @@ -918,11 +918,11 @@ func (p *EBPFRouteProgrammer) release(entry appliedEntry) error { return nil } -func protocolNumber(protocol api.NetworkRuleProtocol) (uint8, error) { +func protocolNumber(protocol api.NetworkServiceProtocol) (uint8, error) { switch protocol { - case api.NetworkRuleProtocolTCP: + case api.NetworkServiceProtocolTCP: return serviceroutemap.ProtocolTCP, nil - case api.NetworkRuleProtocolUDP: + case api.NetworkServiceProtocolUDP: return serviceroutemap.ProtocolUDP, nil default: return 0, fmt.Errorf("unsupported service route protocol %q", protocol) diff --git a/internal/serviceroute/dataplane_test.go b/internal/serviceroute/dataplane_test.go index f4642255..522e9290 100644 --- a/internal/serviceroute/dataplane_test.go +++ b/internal/serviceroute/dataplane_test.go @@ -17,9 +17,9 @@ import ( "github.com/cilium/ebpf" + api "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/serviceroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" - api "go.datum.net/network/api/v1alpha1" ) type recordingTable struct { @@ -142,7 +142,7 @@ func localTestIntent() RouteIntent { ConsumerDevice: testConsumerDeviceName, ServiceDevice: testProducerDeviceName, Ports: []api.ServiceRouteProtocolPort{{ - Protocol: api.NetworkRuleProtocolTCP, + Protocol: api.NetworkServiceProtocolTCP, Port: 443, }}, } diff --git a/internal/serviceroute/planner.go b/internal/serviceroute/planner.go index 4cbe21f0..6a84f808 100644 --- a/internal/serviceroute/planner.go +++ b/internal/serviceroute/planner.go @@ -18,8 +18,8 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) type RouteIntentKind string diff --git a/internal/serviceroute/planner_test.go b/internal/serviceroute/planner_test.go index 46611e38..8ed6d711 100644 --- a/internal/serviceroute/planner_test.go +++ b/internal/serviceroute/planner_test.go @@ -12,8 +12,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) const ( @@ -52,7 +52,7 @@ func testPolicyAndEndpoint(mode networkv1alpha1.ServiceEndpointDeliveryMode) ( endpoint := &networkv1alpha1.ServiceEndpoint{ ObjectMeta: metav1.ObjectMeta{Name: "dns", Namespace: "platform", UID: "endpoint-uid"}, Spec: networkv1alpha1.ServiceEndpointSpec{ - Address: "fd00::53", Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53, DeliveryMode: mode, + Address: "fd00::53", Protocol: networkv1alpha1.NetworkServiceProtocolUDP, Port: 53, DeliveryMode: mode, AttachmentSelector: &metav1.LabelSelector{MatchLabels: testLabels(testProducerLabel, "yes")}, }, } @@ -248,7 +248,7 @@ func TestCompileUsesEndpointPortByDefault(t *testing.T) { policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal) policy.Spec.ProtocolPorts = nil endpoint.Spec.Address = "192.0.2.10" - endpoint.Spec.Protocol = networkv1alpha1.NetworkRuleProtocolTCP + endpoint.Spec.Protocol = networkv1alpha1.NetworkServiceProtocolTCP consumer := readyAttachment( "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), ) @@ -261,7 +261,7 @@ func TestCompileUsesEndpointPortByDefault(t *testing.T) { t.Fatalf("Compile: %v", err) } if len(got) != 1 || len(got[0].Ports) != 1 || got[0].Ports[0].Port != 53 || - got[0].Ports[0].Protocol != networkv1alpha1.NetworkRuleProtocolTCP { + got[0].Ports[0].Protocol != networkv1alpha1.NetworkServiceProtocolTCP { t.Fatalf("ports = %+v", got[0].Ports) } } diff --git a/internal/serviceroute/programming.go b/internal/serviceroute/programming.go index 5e54ee9d..b45696f2 100644 --- a/internal/serviceroute/programming.go +++ b/internal/serviceroute/programming.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) // ProgrammingRequirements fences acknowledgments to a complete topology snapshot. diff --git a/internal/serviceroute/programming_test.go b/internal/serviceroute/programming_test.go index df80ca6c..6ea22559 100644 --- a/internal/serviceroute/programming_test.go +++ b/internal/serviceroute/programming_test.go @@ -6,8 +6,8 @@ package serviceroute import ( "testing" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" - networkv1alpha1 "go.datum.net/network/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" ) func TestProgrammingPlanFencesTopologyAndIgnoresUnrelatedVPC(t *testing.T) { diff --git a/internal/trafficpolicy/cell/reconcilers.go b/internal/trafficpolicy/cell/reconcilers.go index b79b9271..4b689058 100644 --- a/internal/trafficpolicy/cell/reconcilers.go +++ b/internal/trafficpolicy/cell/reconcilers.go @@ -26,7 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/trafficpolicy" "go.datum.net/galactic/internal/trafficpolicy/webhook" diff --git a/internal/trafficpolicy/node/reconciler.go b/internal/trafficpolicy/node/reconciler.go index 782c9450..6ac81b01 100644 --- a/internal/trafficpolicy/node/reconciler.go +++ b/internal/trafficpolicy/node/reconciler.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/tpmap" diff --git a/internal/trafficpolicy/node/reconciler_test.go b/internal/trafficpolicy/node/reconciler_test.go index f3ed01e2..f2ea0610 100644 --- a/internal/trafficpolicy/node/reconciler_test.go +++ b/internal/trafficpolicy/node/reconciler_test.go @@ -11,7 +11,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" "sync/atomic" @@ -35,7 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/envtest" - cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" galacticv1alpha1 "go.datum.net/galactic/api/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/tpmap" @@ -163,15 +162,6 @@ type fixture struct { token uint64 } -func moduleDir(t *testing.T, module string) string { - t.Helper() - out, err := exec.CommandContext(context.Background(), "go", "list", "-m", "-f", "{{.Dir}}", module).Output() - if err != nil { - t.Fatalf("locate module %s: %v", module, err) - } - return strings.TrimSpace(string(out)) -} - func newFixture(t *testing.T) *fixture { t.Helper() requireRoot(t) @@ -190,7 +180,6 @@ func newFixture(t *testing.T) *fixture { te := &envtest.Environment{ CRDDirectoryPaths: []string{ filepath.Join("..", "..", "..", "config", "crd"), - filepath.Join(moduleDir(t, "go.datum.net/cloud"), "config", "crd"), }, ErrorIfCRDPathMissing: true, } diff --git a/scripts/ci.sh b/scripts/ci.sh index e3b813cc..bac3868d 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -85,22 +85,8 @@ case "$COMMAND" in docker exec "$node" sh -c 'mkdir -p /sys/fs/bpf && (mountpoint -q /sys/fs/bpf || mount -t bpf bpf /sys/fs/bpf)' done - echo "--- Installing BGP CRDs (datum-cloud/network)" - # Extract the datum-cloud/network git ref from go.mod (a release tag, or a - # pseudo-version's commit suffix after the last hyphen), same approach as - # deploy/containerlab/scripts/deploy-system.sh. $1 must match the - # require line's module path exactly, not just a substring, so an - # unrelated line can't corrupt NETWORK_SHA. - NETWORK_SHA=$(awk '$1 == "go.datum.net/network" {print $2}' go.mod | sed 's/.*-//') - NETWORK_CRD_URL="https://raw.githubusercontent.com/datum-cloud/network/${NETWORK_SHA}/config/crd" - for crd in \ - network.datumapis.com_bgpadvertisements.yaml \ - network.datumapis.com_bgppeers.yaml \ - network.datumapis.com_bgppolicies.yaml \ - network.datumapis.com_bgprouters.yaml \ - network.datumapis.com_bgpvrfinstances.yaml; do - curl -sL "${NETWORK_CRD_URL}/${crd}" | kubectl apply -f - - done + echo "--- Installing Galactic CRDs" + kubectl apply -k config/crd echo "--- Applying galactic-system namespace and CNI RBAC" kubectl apply -k config/galactic-system @@ -115,7 +101,7 @@ case "$COMMAND" in # tap mode too. E2E_NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') cat < ../network` (see that line's own comment) - # inside the Dockerfile's "network" build stage -- see - # containers/galactic-cni/Dockerfile's own comment on that stage for - # the full mechanism. Only passed when ../network actually exists: - # docker eagerly stats every --build-context path before looking at - # the Dockerfile at all, so passing it unconditionally broke CI's - # single-repo checkout (no sibling ../network there) even with no - # replace directive active -- it's the *Dockerfile* stage that's - # harmless to leave unused, not this flag. - BUILD_ARGS=() - if [ -d ../network ]; then - BUILD_ARGS+=(--build-context "network=../network") - fi - docker build "${BUILD_ARGS[@]}" -t "$IMG" -f containers/galactic-cni/Dockerfile . + docker build -t "$IMG" -f containers/galactic-cni/Dockerfile . echo "--- Loading image into cluster" kind load docker-image "$IMG" --name "$CLUSTER_NAME" diff --git a/tests/psc/example.yaml b/tests/psc/example.yaml index abc918f5..3a6514e5 100644 --- a/tests/psc/example.yaml +++ b/tests/psc/example.yaml @@ -1,7 +1,7 @@ # Apply in a disposable edge API with existing VPCs and attachments. # Consumer attachments need access: a or access: b; the shared producer # attachment needs role: producer. Use envsubst to fill the variables. -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceEndpoint metadata: name: service-a @@ -17,7 +17,7 @@ spec: matchLabels: role: producer --- -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceEndpoint metadata: name: service-b @@ -31,7 +31,7 @@ spec: matchLabels: role: producer --- -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceRoutePolicy metadata: name: access-a @@ -58,7 +58,7 @@ spec: - protocol: tcp port: 8443 --- -apiVersion: network.datumapis.com/v1alpha1 +apiVersion: galactic.datumapis.com/v1alpha1 kind: ServiceRoutePolicy metadata: name: access-b diff --git a/tests/psc/fixture/main_linux.go b/tests/psc/fixture/main_linux.go index 4074ca20..42cf62ad 100644 --- a/tests/psc/fixture/main_linux.go +++ b/tests/psc/fixture/main_linux.go @@ -30,12 +30,12 @@ import ( controllerconfig "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/metrics/server" - cloudapi "go.datum.net/cloud/api/v1alpha1" + cloudapi "go.datum.net/galactic/api/cloud/v1alpha1" + networkapi "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/controller" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/serviceroute" "go.datum.net/galactic/tests/psc/internal/traffic" - networkapi "go.datum.net/network/api/v1alpha1" ) type fixtureConfig struct { diff --git a/tests/psc/fixture/network_linux.go b/tests/psc/fixture/network_linux.go index 3804a23d..99d5a215 100644 --- a/tests/psc/fixture/network_linux.go +++ b/tests/psc/fixture/network_linux.go @@ -22,9 +22,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - cloudapi "go.datum.net/cloud/api/v1alpha1" + cloudapi "go.datum.net/galactic/api/cloud/v1alpha1" + networkapi "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/plumbing/ebpf/attach" - networkapi "go.datum.net/network/api/v1alpha1" ) type labState struct { diff --git a/tests/psc/fixture/remote_linux.go b/tests/psc/fixture/remote_linux.go index 4612eaf6..ea88e020 100644 --- a/tests/psc/fixture/remote_linux.go +++ b/tests/psc/fixture/remote_linux.go @@ -17,14 +17,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - cloudapi "go.datum.net/cloud/api/v1alpha1" + cloudapi "go.datum.net/galactic/api/cloud/v1alpha1" + networkapi "go.datum.net/galactic/api/network/v1alpha1" "go.datum.net/galactic/internal/crdnames" "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/srv6" - networkapi "go.datum.net/network/api/v1alpha1" ) const (