diff --git a/Dockerfile b/Dockerfile
index 96300da..ee62ad8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,11 +14,13 @@ COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o vpc-controller cmd/main.go
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o fabric-identity-controller cmd/fabric-identity-controller/main.go
+RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o egress-address-controller cmd/egress-address-controller/main.go
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=builder /workspace/vpc-controller .
COPY --from=builder /workspace/fabric-identity-controller .
+COPY --from=builder /workspace/egress-address-controller .
USER 65532:65532
ENTRYPOINT ["/vpc-controller"]
diff --git a/cmd/egress-address-controller/main.go b/cmd/egress-address-controller/main.go
new file mode 100644
index 0000000..4b7e61d
--- /dev/null
+++ b/cmd/egress-address-controller/main.go
@@ -0,0 +1,169 @@
+/*
+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 .
+*/
+
+// Command egress-address-controller gives each egress shard in a cell the
+// public IPv6 address it translates to.
+//
+// It runs in the cell, unlike fabric-identity-controller, which allocates
+// centrally because a network spans locations and its identity must be the same
+// in all of them. A shard is the opposite case: it names the Node it executes
+// on, so it exists only where that Node does, and nothing about its address has
+// to agree with any other location.
+//
+// It is a binary of its own rather than a reconciler inside vpc-controller
+// because it needs a credential vpc-controller does not have. vpc-controller
+// writes the attachment state of every workload in the cell and serves an
+// admission webhook; giving that pod a credential into the platform's own
+// tenancy widens the blast radius of the one component the cell cannot run
+// without, and a missing or expired address credential would stop workloads
+// attaching. Split out, an address that cannot be claimed costs new shards
+// their addresses and costs nothing else.
+package main
+
+import (
+ "flag"
+ "os"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/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/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+ "go.datum.net/cloud/internal/controller"
+ "go.datum.net/cloud/internal/ipam"
+ bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
+)
+
+var scheme = runtime.NewScheme()
+
+func init() {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(bgpv1alpha1.AddToScheme(scheme))
+}
+
+func main() {
+ var metricsAddr, probeAddr string
+ var addressClass, claimNamespace, location, platformProject, ipamKubeconfig string
+ var enableLeaderElection bool
+
+ flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metric endpoint binds to.")
+ flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the probe endpoint binds to.")
+ flag.BoolVar(&enableLeaderElection, "leader-elect", true,
+ "Enable leader election. A single writer is what keeps one shard to one address.")
+ flag.StringVar(&addressClass, "address-class-ipv6", "",
+ "Required. The IPClass that hands out shard addresses. It draws from announceable public space shared by every shard in a location.")
+ flag.StringVar(&claimNamespace, "claim-namespace", "default",
+ "Namespace in the platform's own tenancy that address claims are written to.")
+ flag.StringVar(&location, "location", "",
+ "Required. The location this cell serves. It selects the shared public range addresses come from; two cells serving one location draw from the same range.")
+ flag.StringVar(&platformProject, "platform-project", "",
+ "Required. The project control plane the platform allocates its own values in. A shard's address is not a consumer's address and must not be drawn from any one consumer's space or counted against their quota.")
+ flag.StringVar(&ipamKubeconfig, "ipam-kubeconfig", "",
+ "Required. Path to a kubeconfig for the cluster serving the IPAM API.")
+
+ opts := zap.Options{Development: false}
+ opts.BindFlags(flag.CommandLine)
+ flag.Parse()
+
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+ setupLog := ctrl.Log.WithName("setup")
+
+ // Every one of these is fatal at startup rather than per shard. A shard's
+ // address cannot be corrected once written, so a deployment that would draw
+ // from the wrong space, or from no space, must not reach a single shard.
+ switch {
+ case addressClass == "":
+ setupLog.Error(nil, "-address-class-ipv6 is required")
+ os.Exit(1)
+ case location == "":
+ // A claim carrying the wrong location is the dangerous case, not the
+ // missing one: it succeeds, and hands this cell an address that another
+ // location's fabric attracts.
+ setupLog.Error(nil, "-location is required")
+ os.Exit(1)
+ case platformProject == "":
+ setupLog.Error(nil, "-platform-project is required")
+ os.Exit(1)
+ case ipamKubeconfig == "":
+ setupLog.Error(nil, "-ipam-kubeconfig is required")
+ os.Exit(1)
+ }
+
+ // The manager runs against the cell this is scheduled on, which is also
+ // where the shards are. There is no second cluster: an EgressShard names a
+ // Node, so it is never anywhere but the cell holding that Node.
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsserver.Options{BindAddress: metricsAddr},
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "egress-address-controller.cloud.datumapis.com",
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start manager")
+ os.Exit(1)
+ }
+
+ ipamRestConfig, err := clientcmd.BuildConfigFromFlags("", ipamKubeconfig)
+ if err != nil {
+ setupLog.Error(err, "unable to load the IPAM kubeconfig")
+ os.Exit(1)
+ }
+
+ ipamScheme, err := ipam.Scheme()
+ if err != nil {
+ setupLog.Error(err, "unable to build the IPAM scheme")
+ os.Exit(1)
+ }
+
+ ipamClients, err := ipam.NewClientFactory(ipamRestConfig, ipamScheme, platformProject)
+ if err != nil {
+ setupLog.Error(err, "unable to build the IPAM client factory")
+ os.Exit(1)
+ }
+
+ if err := (&controller.EgressShardAddressReconciler{
+ Client: mgr.GetClient(),
+ IPAM: ipamClients,
+ AddressClassIPv6: addressClass,
+ ClaimNamespace: claimNamespace,
+ PlatformProject: platformProject,
+ Location: location,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "EgressShardAddress")
+ os.Exit(1)
+ }
+
+ if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up health check")
+ os.Exit(1)
+ }
+ if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up ready check")
+ os.Exit(1)
+ }
+
+ setupLog.Info("starting egress address controller", "location", location)
+ if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+}
diff --git a/config/components/egress-address/deployment.yaml b/config/components/egress-address/deployment.yaml
new file mode 100644
index 0000000..25405a9
--- /dev/null
+++ b/config/components/egress-address/deployment.yaml
@@ -0,0 +1,145 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: egress-address-controller
+ namespace: system
+ labels:
+ app.kubernetes.io/name: egress-address-controller
+ app.kubernetes.io/component: egress-address-controller
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ # A shard's address is decided once and cannot be reassigned. Leader election
+ # is what keeps that true across a rollout; the replica count is not.
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: egress-address-controller
+ template:
+ metadata:
+ annotations:
+ kubectl.kubernetes.io/default-container: manager
+ labels:
+ app.kubernetes.io/name: egress-address-controller
+ app.kubernetes.io/component: egress-address-controller
+ spec:
+ serviceAccountName: vpc-controller
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: manager
+ image: ghcr.io/datum-cloud/vpc-controller
+ command:
+ - /egress-address-controller
+ # 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:
+ - --leader-elect
+ - --health-probe-bind-address=:8081
+ - --metrics-bind-address=:8080
+ - --address-class-ipv6=$(ADDRESS_CLASS_IPV6)
+ - --claim-namespace=$(CLAIM_NAMESPACE)
+ - --location=$(LOCATION)
+ - --platform-project=$(PLATFORM_PROJECT)
+ - --ipam-kubeconfig=/etc/egress-address-ipam/kubeconfig
+ env:
+ # The class that hands out shard addresses. Required; a deployment
+ # naming no class refuses to start rather than draw from a default
+ # that would hand a shard a private address nothing routes.
+ - name: ADDRESS_CLASS_IPV6
+ value: datum-egress-shard-address-ipv6
+ - name: CLAIM_NAMESPACE
+ value: default
+ # The location this cell serves, which must equal the cell's own
+ # topology.datum.net/location label. Required and deployment
+ # specific: a cell claiming under another location's name is handed
+ # an address that location's fabric attracts, and the assignment
+ # cannot be taken back.
+ - name: LOCATION
+ value: ""
+ # A shard's address is not a consumer's address, so the claim is
+ # written in a project the platform owns. 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: egress-address-ipam-kubeconfig
+ mountPath: /etc/egress-address-ipam
+ readOnly: true
+ volumes:
+ # BLAST RADIUS OF THIS CREDENTIAL. Read this before widening it.
+ #
+ # It is a client certificate authenticating to Milo as
+ # system:egress-address@cloud.datumapis.com, and it is what lets this
+ # component claim addresses in the platform's own project -- the tenancy
+ # holding every platform-owned allocation, including the fabric
+ # identities a network's forwarding state is keyed on. There is one of
+ # these per cell, and a cell is the least trusted cluster in the fleet.
+ #
+ # The narrowest scope the platform can express is (project) x (resource
+ # type) x (verb), and RBAC alone cannot even do the project: Milo
+ # carries the project in the caller's user extras, which RBAC does not
+ # read. Per-namespace scoping does not exist at all -- IPAM declares
+ # Project as the only parent of an IPClaim -- so "claims in one
+ # namespace" is not a grant that can be written. See
+ # ../../egress-address/milo-grant.yaml for the grant and the reasoning.
+ #
+ # What a compromised holder could therefore do: create, read and delete
+ # IPClaims, and read IPAllocations, in ANY project, under any class it
+ # can name. Deleting a claim of a class whose reclaim policy is Delete
+ # releases that address for reissue, so the reachable damage includes
+ # taking another shard's or another consumer's address out from under
+ # it.
+ #
+ # What it could not do: change a pool or a class, because no grant here
+ # includes them; take an address already written into a shard's spec,
+ # because those fields are write-once; or reach any consumer's workload
+ # or control plane, because nothing else is bound to this identity.
+ #
+ # It is deliberately NOT the cell controller's ipam-cluster-kubeconfig,
+ # which authenticates as system:nso-cell and additionally carries delete
+ # on IPAllocations. Sharing that Secret name would silently hand this
+ # component the broader identity.
+ #
+ # Not optional. This component does one thing and cannot do it without
+ # the address service. A pod waiting in ContainerCreating for a
+ # credential that has not landed says so plainly; one started against an
+ # empty dir crashloops until the kubelet's next volume resync, which
+ # reads as a broken image rather than a missing secret.
+ #
+ # There is deliberately no second mount. Unlike the central fabric
+ # identity controller, everything this reads and writes in the cluster
+ # is local: an EgressShard names a Node, so it never exists anywhere but
+ # the cell holding that Node.
+ - name: egress-address-ipam-kubeconfig
+ secret:
+ secretName: egress-address-ipam-kubeconfig
+ terminationGracePeriodSeconds: 10
diff --git a/config/components/egress-address/kustomization.yaml b/config/components/egress-address/kustomization.yaml
new file mode 100644
index 0000000..82fa7cb
--- /dev/null
+++ b/config/components/egress-address/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1alpha1
+kind: Component
+resources:
+ - deployment.yaml
+ - metrics_service.yaml
diff --git a/config/components/egress-address/metrics_service.yaml b/config/components/egress-address/metrics_service.yaml
new file mode 100644
index 0000000..8279a80
--- /dev/null
+++ b/config/components/egress-address/metrics_service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: egress-address-metrics
+ namespace: system
+ labels:
+ app.kubernetes.io/name: egress-address-controller
+ app.kubernetes.io/component: egress-address-controller
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ ports:
+ - name: metrics
+ port: 8080
+ protocol: TCP
+ targetPort: metrics
+ selector:
+ app.kubernetes.io/name: egress-address-controller
diff --git a/config/egress-address/kustomization.yaml b/config/egress-address/kustomization.yaml
new file mode 100644
index 0000000..6383d89
--- /dev/null
+++ b/config/egress-address/kustomization.yaml
@@ -0,0 +1,42 @@
+# The claimer of an egress shard's public address. It runs in the cell, beside
+# the shards it writes, which is what separates it from the fabric identity
+# overlay next door: a network spans locations and its identity cannot be
+# decided in any one of them, while a shard names the Node it executes on and
+# exists only where that Node does.
+#
+# It is its own overlay rather than a reconciler inside the cell manager because
+# it holds a credential the cell manager does not. The cell manager writes the
+# attachment state of every workload here and serves an admission webhook;
+# giving that pod a credential into the platform's own tenancy widens the blast
+# radius of the one component a cell cannot run without, and an address
+# credential that expired would stop workloads attaching. Split out, an address
+# that cannot be claimed costs new shards their addresses and nothing else.
+#
+# RBAC comes from ../rbac unchanged. The repo generates one ClusterRole from
+# every marker under ./internal/..., so every role this image runs shares a role
+# and a ServiceAccount name.
+#
+# The authorization this component needs is NOT here. It is applied to the Milo
+# control plane rather than to a cell, so it would be wrong for this overlay to
+# carry it: see milo-grant.yaml beside this file for the grant, the certificate
+# and Secret that have to exist with it, and why the scope cannot be narrowed
+# to one project by RBAC or to one namespace by anything.
+#
+# The EgressShard CRD is not included here. It belongs to
+# go.datum.net/network and is installed by the data plane that owns it, not by
+# a consumer of it.
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: egress-address-system
+resources:
+ - ../rbac
+components:
+ - ../components/egress-address
+
+# Pinned here rather than inherited from ../manager: this overlay does not
+# include it, and the publish workflow rewrites the tag in every overlay it is
+# given.
+images:
+ - name: ghcr.io/datum-cloud/vpc-controller
+ newName: ghcr.io/datum-cloud/vpc-controller
+ newTag: latest
diff --git a/config/egress-address/milo-grant.yaml b/config/egress-address/milo-grant.yaml
new file mode 100644
index 0000000..f2686aa
--- /dev/null
+++ b/config/egress-address/milo-grant.yaml
@@ -0,0 +1,114 @@
+# The authorization this component needs, and the ceiling on how narrow it can
+# be made.
+#
+# APPLY DESTINATION: THE MILO CONTROL PLANE, NOT A CELL. This is deliberately
+# absent from kustomization.yaml beside it, so applying the cell overlay cannot
+# apply this by accident. Its home is the infrastructure repository, next to the
+# two grants it mirrors:
+#
+# apps/vpc-system/control-plane/milo-ipam-rbac/rbac.yaml (central
+# fabric
+# identity)
+# apps/network-services-operator/control-plane/milo-cell-rbac/ (the NSO
+# rbac.yaml cell)
+#
+# It is authored here because the component that needs it is here. Moving it is
+# an open item.
+#
+# ---------------------------------------------------------------------------
+# HOW NARROW THIS CAN BE, WHICH IS LESS NARROW THAN IT LOOKS
+#
+# The grant is cluster-wide across every project, and that is not an oversight.
+# Milo carries the project in the caller's user extras, and RBAC does not read
+# extras, so these verbs are authorized on every project path at once. Both
+# existing IPAM grants say the same thing and are written the same way.
+#
+# Scoping to the platform project alone requires an iam.miloapis.com
+# PolicyBinding whose resourceSelector.resourceRef names that Project, not
+# RBAC. That is available and is the tighter form; it is not used here only
+# because it would be the first of its kind for an IPAM grant and the two
+# existing grants would still be wide. Whether to convert all three together is
+# a decision for whoever owns the identity configuration.
+#
+# Scoping to one NAMESPACE within a project CANNOT BE EXPRESSED AT ALL. IPAM
+# declares Project as the only parentResource of an IPClaim
+# (config/components/iam/protected-resources/ipclaim.yaml), so the model has no
+# namespace dimension to bind against. A namespaced Role and RoleBinding would
+# authorize that namespace NAME in every project, which narrows nothing and
+# reads as though it does. The narrowest scope the platform can express is
+# therefore (project) x (resource type) x (verb).
+#
+# ---------------------------------------------------------------------------
+# THE IDENTITY, WHICH HAS TO BE CREATED TOO
+#
+# The subject below is the CN of a client certificate that does not exist yet.
+# Three objects are needed, mirroring the NSO cell's own:
+#
+# 1. A Certificate on the control plane issuing CN
+# system:egress-address@cloud.datumapis.com, pushed to GCP Secret Manager
+# under the key egress-address-ipam-client-cert. Mirror
+# apps/network-services-operator/control-plane/staging/
+# cell-ipam-client-certificate.yaml
+#
+# 2. An ExternalSecret in each cell syncing it into a Secret named
+# egress-address-ipam-kubeconfig, with a templated kubeconfig whose server
+# is Milo's TLS-passthrough base URL -- NOT a per-project endpoint, because
+# the client appends the project path to every request. Mirror
+# apps/network-services-operator/cell/ipam-client-cert.yaml, changing the
+# Secret name and the user name.
+#
+# The Secret name must NOT be ipam-cluster-kubeconfig. That name is the NSO
+# cell controller's own credential, which authenticates as system:nso-cell
+# and additionally carries delete on IPAllocations; reusing it would hand
+# this component a broader identity silently.
+#
+# 3. This grant.
+#
+# ---------------------------------------------------------------------------
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: egress-address-ipam
+rules:
+ # get, to find the address a shard already holds before drawing another --
+ # the claim is the record, and reading it first is what makes the allocation
+ # idempotent. create, to draw one. delete, to give it back once the shard it
+ # was named for is gone.
+ - apiGroups: ["ipam.miloapis.com"]
+ resources: ["ipclaims"]
+ verbs: ["get", "create", "delete"]
+ # Read only, and only to recover the address an allocation retained by an
+ # earlier claim of the same name still holds. The service refuses the create
+ # and names that allocation, so without this the address is unreachable and
+ # the shard stays unaddressed behind a conflict that never clears.
+ #
+ # Deliberately no delete, unlike the NSO cell's grant. Claims here are written
+ # with reclaim policy Delete, so removing the claim frees the address; nothing
+ # this component does needs to reach past it to the allocation.
+ - apiGroups: ["ipam.miloapis.com"]
+ resources: ["ipallocations"]
+ verbs: ["get"]
+ # No ipclasses rule, unlike both existing grants. This component never reads
+ # a class: it names one on a claim and the service resolves it server-side.
+ # The "use" check on a class applies to creating a cross-project class
+ # reference, which nothing here does.
+ #
+ # No pools and no classes under any verb. Address space is operator
+ # inventory, and a component that allocates from it has no reason to be able
+ # to change its shape.
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: egress-address-ipam
+subjects:
+ # CN of the certificate described above. Every cell shares this identity;
+ # nothing here distinguishes one cell from another, so the grant cannot be
+ # narrowed per location either.
+ - kind: User
+ apiGroup: rbac.authorization.k8s.io
+ name: system:egress-address@cloud.datumapis.com
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: egress-address-ipam
diff --git a/config/ipam/egress-shard-ipv6.yaml b/config/ipam/egress-shard-ipv6.yaml
new file mode 100644
index 0000000..f4081d6
--- /dev/null
+++ b/config/ipam/egress-shard-ipv6.yaml
@@ -0,0 +1,167 @@
+# The public IPv6 space an egress shard's translation address is drawn from,
+# consumed by egress-address-controller.
+#
+# APPLY DESTINATION. This is platform-project IPAM content. Its sibling — the
+# fabric identity space in infra's
+# apps/network-services-operator/platform-project/fabric-identity.yaml — is
+# applied from infra by the installer scoped to the `datum-cloud` project, and
+# this belongs beside it. It is authored here because the controller that reads
+# it is here; moving it is an open item, not a second home.
+#
+# IPv4 IS DELIBERATELY ABSENT. A shard performs NAT64 only when its spec carries
+# both an IPv4 address and a NAT64 prefix, and the platform pairs no resolver
+# with a translator yet, so an IPv4 egress address would be reachability the API
+# reports and the data plane does not deliver. The register that would say which
+# public IPv4 prefix is egress space also still reads `status: TODO` throughout
+# (infra/network/ipam/ipv4.yaml). Defining the IPv4 half when both are settled
+# costs one commit; defining it now is a permanent choice made to avoid a gap.
+#
+# NOTHING HERE CAN BE CORRECTED IN PLACE. `uniqueWithin` and `poolPer` are
+# rejected on update, and a pool's `spec.cidr` is carried by every allocation
+# beneath it. Read the note on each field before changing one.
+
+# ---------------------------------------------------------------------------
+# The aggregate. One root pool, carved into one /48 per location.
+# ---------------------------------------------------------------------------
+apiVersion: ipam.miloapis.com/v1alpha1
+kind: IPPool
+metadata:
+ name: datum-egress-shard-ipv6-root
+spec:
+ # REQUIRED, AND DELIBERATELY EMPTY. The service refuses a root pool with no
+ # CIDR, so applying this file unedited is rejected rather than seeding a
+ # permanent aggregate from a plausible-looking guess. That is the same reason
+ # PLATFORM_PROJECT ships empty in the deployment beside it.
+ #
+ # The value must be a /40 inside the platform's public supernet
+ # 2607:ed40::/32 that overlaps no entry in infra/network/ipam/ipv6.yaml. A /40
+ # carves 256 locations at the /48 below; the platform serves 16 today.
+ #
+ # Assigning it is a human decision that has not been made. Every entry in that
+ # register reads `status: TODO` and the file is marked a draft for review, so
+ # no prefix in it can be read as free. Record the assignment there first, then
+ # set it here.
+ cidr: ""
+ ipFamily: IPv6
+ visibility: shared
+ # The chain ROOT, never the leaf. A pool naming the leaf would hand an
+ # interface the whole aggregate.
+ classNames:
+ - datum-egress-shard-ipv6
+ allocation:
+ # Fixed at the per-location carve. A pool free to hand out other sizes would
+ # let one location take space sized for several.
+ minPrefixLength: 48
+ maxPrefixLength: 48
+ strategy: FirstFit
+
+# ---------------------------------------------------------------------------
+# The per-location range. This class provisions the pools; it binds nothing.
+# ---------------------------------------------------------------------------
+---
+apiVersion: ipam.miloapis.com/v1alpha1
+kind: IPClass
+metadata:
+ name: datum-egress-shard-ipv6
+spec:
+ ipFamily: IPv6
+ # One pool per location, shared by every cell and every consumer in it.
+ #
+ # Keyed on location rather than on cell because two cells already serve one
+ # location: us-central-1 and us-east-1 each carry two clusters labelled
+ # topology.datum.net/location with the same value. Keying on the cell would
+ # give one physical site two announceable aggregates, and would need a `cell`
+ # scope role that no class in the platform defines, where `location` is
+ # already the role every other claim carries.
+ #
+ # `project` is deliberately absent, which is what makes this shared. A shard's
+ # address serves every network the class places on it, and per-consumer blocks
+ # would exhaust a public aggregate after one block per project instead of one
+ # per location. IMMUTABLE: a class that separated per consumer could not be
+ # corrected, only replaced.
+ poolPer:
+ - location
+ # Empty: one address space platform-wide. Two shards must never hold one
+ # address — the datapath claims a reply by exact match against the address it
+ # translates to, so two holders split each other's return traffic. Emptiness
+ # makes the space tenant-free, and the constraint that enforces it is a
+ # database exclusion over (pool, address space, prefix), not Go.
+ #
+ # The shard is deliberately NOT in this scope. Naming it would make each shard
+ # its own address space and let two shards be handed the same address, which
+ # is the exact failure the field exists to prevent.
+ uniqueWithin: []
+ # The size of the range each location holds. A /48 is the granularity the
+ # platform's own register already assigns public space at per site, and it is
+ # the unit that can be announced: prefixes longer than /48 are widely
+ # filtered, so a smaller carve would hold addresses nothing outside the site
+ # could route to. The space a /48 wastes on a handful of shards costs nothing
+ # in IPv6; an unannounceable egress range costs the feature.
+ allowedPrefixLengths:
+ min: 48
+ max: 48
+ defaultPrefixLength: 48
+ # The /48 leaves the location as one route; the addresses inside it never
+ # appear outside it. An aggregate must be originated with a discard route —
+ # see the dependency note at the foot of this file, because nothing originates
+ # it today.
+ routing:
+ external: Aggregate
+ # Governs a ScopeRange claim of this class, and nothing makes one: the first
+ # shard's address claim provisions the location's /48 through the cascade. It
+ # is stated so that a range later claimed directly is not released by default,
+ # and it is inert until something claims one.
+ reclaimPolicy: Retain
+
+# ---------------------------------------------------------------------------
+# The address. This is the class a shard's claim names.
+# ---------------------------------------------------------------------------
+---
+apiVersion: ipam.miloapis.com/v1alpha1
+kind: IPClass
+metadata:
+ name: datum-egress-shard-address-ipv6
+ # No is-default-class marker. This space is reached only by naming it: a claim
+ # that landed here by default would be handed a public address, and the
+ # platform's default IPv6 class hands out private endpoint space.
+spec:
+ ipFamily: IPv6
+ parentClassName: datum-egress-shard-ipv6
+ # No poolPer: this is the leaf. It provisions nothing and binds addresses
+ # directly inside the /48 its parent carved for the location.
+ uniqueWithin: []
+ # Exactly one address. A shard translates to a single address and the datapath
+ # matches it exactly, so a block would hand the shard space it cannot use and
+ # hold the rest out of circulation with nothing reporting the difference.
+ allowedPrefixLengths:
+ min: 128
+ max: 128
+ defaultPrefixLength: 128
+ # Freed when the claim goes, which happens only when the shard it is named for
+ # is gone.
+ #
+ # NOT Retain. A shard holding the wrong address is fixed by deleting and
+ # recreating the shard, because the address in its spec is write-once. Under
+ # Retain the recreated shard is handed the same address back, so the only
+ # remedy the API leaves for a wrong address would silently not work. Retain
+ # also never returns the address at all: the service implements no lease
+ # expiry, so a retained allocation outlives the decommissioned node forever
+ # and an announceable aggregate is scarce enough for that to matter. Reissuing
+ # an address costs nothing a consumer was promised — this class is shared, so
+ # the address is reported as one nobody may rely on or allow-list.
+ reclaimPolicy: Delete
+ # A distinct route inside the location, reached by the shard's own
+ # advertisement into the fabric.
+ routing:
+ internal: Host
+
+# ---------------------------------------------------------------------------
+# WHAT THIS DOES NOT DELIVER
+#
+# Nothing announces the per-location /48. The shard's own address becomes
+# reachable inside its location by the advertisement the shard makes into the
+# fabric; the aggregate leaving the location toward the internet is an upstream
+# announcement, paired with a discard route, that no component here originates.
+# Until it exists, a shard holds a correctly allocated address that the internet
+# cannot reply to.
+# ---------------------------------------------------------------------------
diff --git a/config/ipam/kustomization.yaml b/config/ipam/kustomization.yaml
new file mode 100644
index 0000000..bceafc3
--- /dev/null
+++ b/config/ipam/kustomization.yaml
@@ -0,0 +1,6 @@
+# Operator-applied IPAM content, not part of any deployment overlay. It is
+# listed so the file is built and validated rather than only linted.
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - egress-shard-ipv6.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 8600ec8..764aa14 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -75,6 +75,16 @@ rules:
- get
- list
- watch
+- apiGroups:
+ - network.datumapis.com
+ resources:
+ - egressshards
+ verbs:
+ - get
+ - list
+ - patch
+ - update
+ - watch
- apiGroups:
- networking.datumapis.com
resources:
diff --git a/go.mod b/go.mod
index 0431086..6f7a160 100644
--- a/go.mod
+++ b/go.mod
@@ -78,3 +78,7 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+
+replace go.datum.net/network => github.com/datum-cloud/network v0.1.1-0.20260923215140-1ed44c853eea
+
+replace go.datum.net/network-services-operator => github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2
diff --git a/go.sum b/go.sum
index 6c1b0e7..961026a 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,10 @@ 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/datum-cloud/network v0.1.1-0.20260923215140-1ed44c853eea h1:/a67sX+DD7+tRpaQSDAuoFuOfoMz9hC22wi4AGakiuk=
+github.com/datum-cloud/network v0.1.1-0.20260923215140-1ed44c853eea/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE=
+github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2 h1:2yKJV4XRmoQMNM+VrOJdUgNkP1pM5z6l2P/2qF+K5yI=
+github.com/datum-cloud/network-services-operator v0.27.2-0.20260917225730-eccf0e8922b2/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -121,10 +125,6 @@ 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/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 v0.1.0 h1:AmYSwxUWOk26UnK6S6NA7OuucGJniKo/CWqjs+VcSCs=
-go.datum.net/network v0.1.0/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE=
-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=
diff --git a/internal/controller/egressshardaddress_controller.go b/internal/controller/egressshardaddress_controller.go
new file mode 100644
index 0000000..f052c6e
--- /dev/null
+++ b/internal/controller/egressshardaddress_controller.go
@@ -0,0 +1,277 @@
+/*
+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"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "go.datum.net/cloud/internal/egressaddress"
+ "go.datum.net/cloud/internal/ipam"
+ bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
+)
+
+// EgressShardAddressReconciler gives each egress shard in this cell the public
+// IPv6 address it translates to.
+//
+// It runs in the cell, beside the shards it writes. A shard names the Node it
+// executes on, so it exists only where that Node does, and a controller reading
+// it from anywhere else would be reading a federated copy of an object whose
+// whole purpose is local. That also keeps the write and the object it lands on
+// in one cluster, which is what makes the write-once field below safe to
+// attempt: there is no copy of the shard that could be carrying a different
+// value.
+//
+// The shard does not claim its own address. A shard runs on every translating
+// node, including hardware at the edge of the network, and the process that
+// would make the claim is the one serving the datapath -- so an
+// address-service credential would sit on every such node, reachable from the
+// process that also handles tenant packets, and the allocation request would
+// sit beside the path that attaches a workload. One controller per cell moves
+// the credential count from the number of translating nodes to the number of
+// cells and takes the allocation off that path entirely: an address is claimed
+// when a shard object appears, which is when a node is commissioned, not when a
+// workload arrives.
+type EgressShardAddressReconciler struct {
+ // Shards reads and writes the EgressShards in this cell.
+ Client client.Client
+
+ // IPAM reaches the address service.
+ IPAM ipam.ClientFactory
+
+ // AddressClassIPv6 is the class that hands out shard addresses.
+ AddressClassIPv6 string
+
+ // ClaimNamespace is the namespace in the platform's own tenancy that
+ // address claims are written to.
+ ClaimNamespace string
+
+ // PlatformProject is the project whose control plane serves the claims.
+ //
+ // It is carried here as well as inside the IPAM client factory because the
+ // reference written onto a shard has to name it: a claim is namespaced
+ // within a project, and the reference is read from outside every project,
+ // so a namespace alone does not identify one.
+ PlatformProject string
+
+ // Location is the location this cell serves. It selects the shared public
+ // range the address comes from, and two cells serving one location draw
+ // from the same range.
+ Location string
+}
+
+// Reconcile assigns the shard its address, once.
+//
+// Reading the shard is what separates "deleted" from "not addressed yet". Only
+// a shard that is actually gone releases its claim.
+func (r *EgressShardAddressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ shard := &bgpv1alpha1.EgressShard{}
+ err := r.Client.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, shard)
+ switch {
+ case apierrors.IsNotFound(err):
+ return ctrl.Result{}, r.release(ctx, req.Namespace, req.Name)
+ case err != nil:
+ // A read that failed says nothing about whether the shard is still
+ // there, and releasing on it would put a live shard's address back in
+ // circulation.
+ return ctrl.Result{}, err
+ }
+
+ // A shard on its way out is not given an address it would never program,
+ // and keeps the one it has until it is actually gone: the flows it is
+ // translating are still there while it drains.
+ if !shard.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, nil
+ }
+
+ if shard.Spec.ShardAddressIPv6 != "" {
+ // Already addressed. The family label restates the assignment for the
+ // selectors that place traffic on this shard, and cannot be written
+ // with the address itself: a shard whose label write failed would
+ // otherwise stay unselectable forever, because the address it would be
+ // rewritten with cannot be written twice.
+ return ctrl.Result{}, r.markFamilyServed(ctx, shard)
+ }
+
+ holding, err := r.claim(ctx, shard)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // The value comes from a bound claim and from nowhere else. Every path that
+ // could not produce one returned above, because spec.shardAddressIPv6 is
+ // write-once: a placeholder written here is not correctable, and the shard
+ // would have to be deleted and recreated to be rid of it.
+ //
+ // One update carries everything the shard gains from this allocation, so
+ // that a rule pairing the address with the record holding it is satisfied
+ // by the write rather than by a second one that could fail on its own.
+ shard.Spec.ShardAddressIPv6 = holding.Address.String()
+ shard.Spec.ShardAddressIPv6ClaimRef = r.reference(holding)
+ if shard.Labels == nil {
+ shard.Labels = map[string]string{}
+ }
+ shard.Labels[bgpv1alpha1.LabelEgressShardIPv6] = bgpv1alpha1.LabelValueEgressFamilyServed
+
+ // An ordinary update on the object just read, not a server-side apply with
+ // forced ownership. The field's own validation compares against the stored
+ // value, so a conflicting write has to be refused rather than won: forcing
+ // ownership of a field that cannot be reassigned is the one thing that must
+ // not happen quietly here.
+ if err := r.Client.Update(ctx, shard); err != nil {
+ return ctrl.Result{}, fmt.Errorf("assign egress shard %q the address %s held by %s %q: %w",
+ shard.Name, holding.Address, holding.Kind, holding.Name, err)
+ }
+
+ log.FromContext(ctx).Info("assigned an egress shard its public IPv6 address",
+ "shard", shard.Name, "address", holding.Address.String(), "location", r.Location,
+ "holderKind", holding.Kind, "holderNamespace", holding.Namespace, "holderName", holding.Name)
+ return ctrl.Result{}, nil
+}
+
+// reference is the trail from the address back to what holds it.
+//
+// It names the record that actually exists, which is not always a claim. An
+// address recovered from a retained allocation is held by an IPAllocation and
+// by no claim: the service rolls its transaction back before refusing, so the
+// claim it refused was never stored. Kind carries that difference rather than
+// taking its default, because the field is write-once and a reference naming a
+// claim that does not exist is permanent for the life of the shard.
+func (r *EgressShardAddressReconciler) reference(holding egressaddress.Holding) *bgpv1alpha1.AddressClaimRef {
+ return &bgpv1alpha1.AddressClaimRef{
+ APIGroup: ipamv1alpha1.GroupName,
+ Kind: holding.Kind,
+ Project: r.PlatformProject,
+ Namespace: holding.Namespace,
+ Name: holding.Name,
+ }
+}
+
+// markFamilyServed records that this shard translates IPv6, for the selectors
+// that place traffic on it. A selector matches labels and cannot read a spec
+// field, so whoever assigns the address states it here too.
+func (r *EgressShardAddressReconciler) markFamilyServed(ctx context.Context, shard *bgpv1alpha1.EgressShard) error {
+ if shard.Labels[bgpv1alpha1.LabelEgressShardIPv6] == bgpv1alpha1.LabelValueEgressFamilyServed {
+ return nil
+ }
+ if shard.Labels == nil {
+ shard.Labels = map[string]string{}
+ }
+ shard.Labels[bgpv1alpha1.LabelEgressShardIPv6] = bgpv1alpha1.LabelValueEgressFamilyServed
+ if err := r.Client.Update(ctx, shard); err != nil {
+ return fmt.Errorf("mark egress shard %q as serving IPv6: %w", shard.Name, err)
+ }
+ return nil
+}
+
+func (r *EgressShardAddressReconciler) claim(
+ ctx context.Context,
+ shard *bgpv1alpha1.EgressShard,
+) (egressaddress.Holding, error) {
+ ipamClient, err := r.IPAM.ClientForPlatform()
+ if err != nil {
+ return egressaddress.Holding{}, fmt.Errorf("reach the public address space: %w", err)
+ }
+
+ holding, err := egressaddress.Claim(ctx, ipamClient, egressaddress.Request{
+ ClassName: r.AddressClassIPv6,
+ Namespace: r.ClaimNamespace,
+ Location: r.Location,
+ ShardNamespace: shard.Namespace,
+ ShardName: shard.Name,
+ })
+ if err != nil {
+ // An unusable answer is a wait on an operator, not on the service:
+ // retrying reaches the same allocation. Fail closed either way -- a
+ // shard given an address it cannot translate to is worse than one given
+ // none, because the assignment cannot be taken back.
+ var unusable *egressaddress.UnusableError
+ if errors.As(err, &unusable) {
+ log.FromContext(ctx).Error(err, "the public address space handed out something no shard address can be read from",
+ "shard", shard.Name, "location", r.Location)
+ }
+ return egressaddress.Holding{}, fmt.Errorf("claim a public IPv6 address for egress shard %q: %w", shard.Name, err)
+ }
+ return holding, nil
+}
+
+// release gives back the address of a shard that is gone.
+//
+// Triggered by the shard's absence rather than by a finalizer. A finalizer would
+// close the window in which a missed delete leaks a claim, and would open a
+// larger one: a shard whose address could not be released would refuse to
+// finish deleting, which is how a node is kept from being decommissioned. A
+// leaked claim is an operator deleting one object; a wedged shard is a node
+// nobody can retire.
+func (r *EgressShardAddressReconciler) release(ctx context.Context, namespace, name string) error {
+ ipamClient, err := r.IPAM.ClientForPlatform()
+ if err != nil {
+ return fmt.Errorf("reach the public address space: %w", err)
+ }
+ if err := egressaddress.Release(ctx, ipamClient, r.ClaimNamespace, namespace, name); err != nil {
+ return err
+ }
+ log.FromContext(ctx).Info("released the public IPv6 address of a shard that is gone",
+ "shard", name, "location", r.Location)
+ return nil
+}
+
+// This controller writes EgressShards in its own cell and claims addresses on
+// the platform's behalf in IPAM, which it reaches under a separate credential
+// and which no marker here covers.
+//
+// +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=network.datumapis.com,resources=egressshards,verbs=get;list;patch;update;watch
+
+// SetupWithManager registers the reconciler.
+func (r *EgressShardAddressReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ if r.AddressClassIPv6 == "" {
+ return errors.New("an address class is required")
+ }
+ if r.ClaimNamespace == "" {
+ return errors.New("a namespace to write claims in is required")
+ }
+ if r.PlatformProject == "" {
+ // Every reference written onto a shard names it, and the field is
+ // write-once: a reference missing the project is permanent and
+ // resolves nowhere.
+ return errors.New("the project serving the claims is required")
+ }
+ if r.Location == "" {
+ // A claim carrying no location is refused by the service, and one
+ // carrying the wrong location draws from another location's range and
+ // hands this cell an address nothing routes to it.
+ return errors.New("the location this cell serves is required")
+ }
+ if r.IPAM == nil {
+ return errors.New("an address space is required")
+ }
+
+ return ctrl.NewControllerManagedBy(mgr).
+ Named("egressshardaddress").
+ For(&bgpv1alpha1.EgressShard{}).
+ Complete(r)
+}
diff --git a/internal/controller/egressshardaddress_controller_test.go b/internal/controller/egressshardaddress_controller_test.go
new file mode 100644
index 0000000..e776735
--- /dev/null
+++ b/internal/controller/egressshardaddress_controller_test.go
@@ -0,0 +1,688 @@
+/*
+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"
+ "testing"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ "go.miloapis.com/ipam/pkg/ipamerrors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "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"
+
+ "go.datum.net/cloud/internal/egressaddress"
+ "go.datum.net/cloud/internal/ipam"
+ bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
+)
+
+const (
+ testShardNamespace = "galactic-system"
+ testShardName = "worker-8b4e1647-dfw"
+ testLocation = "us-central-1"
+ testAddressClass = "datum-egress-shard-address-ipv6"
+ testClaimNamespace = "default"
+ testPlatformProject = "datum-cloud"
+)
+
+// fakeAddressIPAM stands in for the address service. Allocation is synchronous
+// there, so the create response already carries the address.
+type fakeAddressIPAM struct {
+ client client.Client
+ // next is the host index the location's range hands out.
+ next int
+ // created records every claim name the service was asked to bind, so a
+ // second claim for one shard is visible rather than merely harmless.
+ created []string
+ // deleted records every claim name released.
+ deleted []string
+ // retained maps an allocation name to the address 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
+ // unbound holds the allocation back, which is what a claim looks like
+ // between being accepted and being bound.
+ unbound bool
+}
+
+func allocationNameForClaim(claimName string) string { return "alloc-" + claimName }
+
+// refuseWhatTheAddressServerWouldRefuse mirrors the parts of the service's admission
+// this depends on. The fake would otherwise bind anything, which is how a claim
+// no real server has ever accepted passes every test here.
+func refuseWhatTheAddressServerWouldRefuse(ipClaim *ipamv1alpha1.IPClaim) error {
+ invalid := func(detail string) error {
+ return apierrors.NewInvalid(
+ ipamv1alpha1.SchemeGroupVersion.WithKind("IPClaim").GroupKind(), ipClaim.Name,
+ field.ErrorList{field.Required(field.NewPath("spec"), detail)})
+ }
+ // The server bounds a claim's prefix length by the family stated on the
+ // claim, before it looks at the class at all.
+ if p := ipClaim.Spec.PrefixLength; p != nil {
+ maxLen := int32(32)
+ if ipClaim.Spec.IPFamily == ipamv1alpha1.IPv6 {
+ maxLen = 128
+ }
+ if *p > maxLen {
+ return invalid(fmt.Sprintf("prefixLength %d exceeds %d for family %q",
+ *p, maxLen, ipClaim.Spec.IPFamily))
+ }
+ }
+ // The class holding the per-location range names "location" in poolPer, so
+ // a claim omitting it cannot be resolved to a pool.
+ if _, ok := ipClaim.Spec.Scope[egressaddress.ScopeRoleLocation]; !ok {
+ return invalid("scope is missing role \"location\"")
+ }
+ return nil
+}
+
+func newFakeAddressIPAM(t *testing.T) *fakeAddressIPAM {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ if err := ipamv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the IPAM scheme: %v", err)
+ }
+
+ service := &fakeAddressIPAM{retained: map[string]string{}}
+ service.client = fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithInterceptorFuncs(interceptor.Funcs{
+ Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error {
+ ipClaim, ok := object.(*ipamv1alpha1.IPClaim)
+ if !ok {
+ return c.Create(ctx, object, opts...)
+ }
+ if err := refuseWhatTheAddressServerWouldRefuse(ipClaim); err != nil {
+ return err
+ }
+ allocationName := allocationNameForClaim(ipClaim.Name)
+ if cidr, held := service.retained[allocationName]; held {
+ _ = cidr
+ return newRetainedAllocationConflict(ipClaim.Name, allocationName)
+ }
+ service.created = append(service.created, ipClaim.Name)
+ if !service.unbound {
+ service.next++
+ ipClaim.Status.Phase = ipamv1alpha1.ClaimPhase("Bound")
+ ipClaim.Status.AllocatedCIDR = fmt.Sprintf("2001:db8:100::%x/128", service.next)
+ } else {
+ ipClaim.Status.Phase = ipamv1alpha1.ClaimPhase("Pending")
+ }
+ return c.Create(ctx, ipClaim, opts...)
+ },
+ Delete: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.DeleteOption) error {
+ if ipClaim, ok := object.(*ipamv1alpha1.IPClaim); ok {
+ service.deleted = append(service.deleted, ipClaim.Name)
+ }
+ return c.Delete(ctx, object, opts...)
+ },
+ }).
+ Build()
+ return service
+}
+
+func (f *fakeAddressIPAM) ClientForPlatform() (client.Client, error) { return f.client, nil }
+
+func (f *fakeAddressIPAM) ClientForProject(string) (client.Client, error) {
+ return nil, errors.New("a shard's address is never drawn from a consumer's project")
+}
+
+var _ ipam.ClientFactory = (*fakeAddressIPAM)(nil)
+
+// newRetainedAllocationConflict is the refusal the service answers a claim with
+// when an allocation under the same identity is still held by a released claim.
+// It is built with the service's own constructor so the classifier the
+// controller depends on is the one under test, rather than a status this test
+// invented and only this test can read.
+func newRetainedAllocationConflict(claimName, allocationName string) error {
+ return ipamerrors.NewRetainedAllocation(
+ ipamv1alpha1.Resource("ipclaims"), claimName, allocationName,
+ fmt.Sprintf("an allocation under this identity already exists: IPAllocation %q, retained by an earlier claim of the same name", allocationName))
+}
+
+func newShardCell(t *testing.T, objects ...client.Object) client.Client {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ if err := bgpv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the cell scheme: %v", err)
+ }
+ return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
+}
+
+func shard(name string) *bgpv1alpha1.EgressShard {
+ return &bgpv1alpha1.EgressShard{
+ ObjectMeta: metav1.ObjectMeta{Namespace: testShardNamespace, Name: name},
+ Spec: bgpv1alpha1.EgressShardSpec{
+ TargetRef: bgpv1alpha1.TargetRef{Kind: "Node", Name: name},
+ },
+ }
+}
+
+func reconcilerFor(cell client.Client, service *fakeAddressIPAM) *EgressShardAddressReconciler {
+ return &EgressShardAddressReconciler{
+ Client: cell,
+ IPAM: service,
+ AddressClassIPv6: testAddressClass,
+ ClaimNamespace: testClaimNamespace,
+ PlatformProject: testPlatformProject,
+ Location: testLocation,
+ }
+}
+
+func requestFor(name string) ctrl.Request {
+ return ctrl.Request{NamespacedName: types.NamespacedName{Namespace: testShardNamespace, Name: name}}
+}
+
+func readShard(t *testing.T, cell client.Client, name string) *bgpv1alpha1.EgressShard {
+ t.Helper()
+ got := &bgpv1alpha1.EgressShard{}
+ if err := cell.Get(context.Background(), client.ObjectKey{Namespace: testShardNamespace, Name: name}, got); err != nil {
+ t.Fatalf("read the shard back: %v", err)
+ }
+ return got
+}
+
+// The whole point: a shard an operator never gave an address to gets one, in
+// spec, where galactic reads it.
+func TestAShardWithoutAnAddressIsGivenOne(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6 == "" {
+ t.Fatal("the shard was left with no address to translate to")
+ }
+ if got.Labels[bgpv1alpha1.LabelEgressShardIPv6] != bgpv1alpha1.LabelValueEgressFamilyServed {
+ t.Errorf("the shard is not selectable as serving IPv6: labels = %v", got.Labels)
+ }
+}
+
+// The claim is named for the shard, which is what makes a second reconcile find
+// the address already held rather than draw another one out of a shared public
+// range.
+func TestReconcilingTwiceClaimsOneAddress(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+ reconciler := reconcilerFor(cell, service)
+
+ for i := range 3 {
+ if _, err := reconciler.Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile %d: %v", i, err)
+ }
+ }
+
+ if len(service.created) != 1 {
+ t.Fatalf("the service was asked to bind %v; one shard holds one address", service.created)
+ }
+ want := egressaddress.ClaimName(testShardNamespace, testShardName)
+ if service.created[0] != want {
+ t.Errorf("claim name = %q, want the name derived from the shard %q", service.created[0], want)
+ }
+}
+
+// spec.shardAddressIPv6 cannot be corrected once written, so a claim that holds
+// no address yet must leave the field alone rather than write a blank or a
+// guess to be replaced later.
+func TestAnUnboundClaimWritesNothing(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+ service.unbound = true
+
+ _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName))
+ if err == nil {
+ t.Fatal("a claim holding no address reconciled successfully")
+ }
+
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6 != "" {
+ t.Fatalf("a write-once field was written with %q before an address existed", got.Spec.ShardAddressIPv6)
+ }
+ if _, marked := got.Labels[bgpv1alpha1.LabelEgressShardIPv6]; marked {
+ t.Error("the shard was marked as serving IPv6 while holding no address")
+ }
+}
+
+// An address an operator assigned by hand is what every shard carries today.
+// Nothing may claim a second one for it, and nothing may try to rewrite it.
+func TestAnOperatorAssignedAddressIsLeftAlone(t *testing.T) {
+ existing := shard(testShardName)
+ existing.Spec.ShardAddressIPv6 = "2001:db8:100::dead"
+ cell := newShardCell(t, existing)
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ if len(service.created) != 0 {
+ t.Errorf("an addressed shard drew %v from the public range", service.created)
+ }
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6 != "2001:db8:100::dead" {
+ t.Errorf("address = %q, want the operator's own value untouched", got.Spec.ShardAddressIPv6)
+ }
+ // The label still has to be caught up: an operator writing the address by
+ // hand is exactly the case where it is missing, and a shard carrying the
+ // address without the label is selected by nothing.
+ if got.Labels[bgpv1alpha1.LabelEgressShardIPv6] != bgpv1alpha1.LabelValueEgressFamilyServed {
+ t.Errorf("an addressed shard was left unselectable: labels = %v", got.Labels)
+ }
+}
+
+// A shard draining still carries flows the address is translating, and giving
+// one an address it will never program consumes public space for nothing.
+func TestAShardOnItsWayOutIsNotAddressed(t *testing.T) {
+ leaving := shard(testShardName)
+ leaving.DeletionTimestamp = &metav1.Time{Time: metav1.Now().Time}
+ leaving.Finalizers = []string{"test.datumapis.com/hold"}
+ cell := newShardCell(t, leaving)
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+ if len(service.created) != 0 {
+ t.Errorf("a shard being deleted drew %v from the public range", service.created)
+ }
+ if len(service.deleted) != 0 {
+ t.Errorf("a shard still draining released %v while still translating", service.deleted)
+ }
+}
+
+// Announceable public space is scarce, so a shard that is actually gone gives
+// its address back. The claim carries ReclaimPolicy Delete, so removing it is
+// what frees the address.
+func TestAShardThatIsGoneReleasesItsAddress(t *testing.T) {
+ cell := newShardCell(t)
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ want := egressaddress.ClaimName(testShardNamespace, testShardName)
+ if len(service.deleted) != 1 || service.deleted[0] != want {
+ t.Fatalf("released %v, want the claim named for the departed shard %q", service.deleted, want)
+ }
+}
+
+// A read that failed says nothing about whether the shard is still there.
+// Releasing on it would put a live shard's address back in circulation for
+// another shard to be handed while the first is still translating with it.
+func TestAFailedReadReleasesNothing(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := bgpv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the cell scheme: %v", err)
+ }
+ cell := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(shard(testShardName)).
+ WithInterceptorFuncs(interceptor.Funcs{
+ Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
+ return errors.New("the cell's API server is unreachable")
+ },
+ }).
+ Build()
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err == nil {
+ t.Fatal("an unreadable shard reconciled successfully")
+ }
+ if len(service.deleted) != 0 {
+ t.Errorf("an unreadable shard released %v", service.deleted)
+ }
+}
+
+// Two shards in one cell are two addresses. Sharing one would split each
+// other's return traffic, because the datapath claims a reply by exact match
+// against the address it translates to.
+func TestTwoShardsGetTwoAddresses(t *testing.T) {
+ cell := newShardCell(t,
+ shard("worker-a"),
+ shard("worker-b"))
+ service := newFakeAddressIPAM(t)
+ reconciler := reconcilerFor(cell, service)
+
+ for _, name := range []string{"worker-a", "worker-b"} {
+ if _, err := reconciler.Reconcile(context.Background(),
+ requestFor(name)); err != nil {
+ t.Fatalf("reconcile %s: %v", name, err)
+ }
+ }
+
+ first := readShard(t, cell, "worker-a").Spec.ShardAddressIPv6
+ second := readShard(t, cell, "worker-b").Spec.ShardAddressIPv6
+ if first == "" || second == "" {
+ t.Fatalf("a shard was left unaddressed: %q and %q", first, second)
+ }
+ if first == second {
+ t.Fatalf("two shards were given one address %q", first)
+ }
+}
+
+// The claim has to carry the location, because the class holding the shared
+// per-location range names it in poolPer. A claim without it is refused, and
+// one with the wrong value succeeds and hands this cell an address another
+// location's fabric attracts.
+func TestTheClaimCarriesTheLocationAndTheFamily(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ stored := &ipamv1alpha1.IPClaim{}
+ if err := service.client.Get(context.Background(), client.ObjectKey{
+ Namespace: testClaimNamespace,
+ Name: egressaddress.ClaimName(testShardNamespace, testShardName),
+ }, stored); err != nil {
+ t.Fatalf("read the claim back: %v", err)
+ }
+
+ if got := stored.Spec.Scope[egressaddress.ScopeRoleLocation].Name; got != testLocation {
+ t.Errorf("claim location = %q, want %q", got, testLocation)
+ }
+ if stored.Spec.IPFamily != ipamv1alpha1.IPv6 {
+ t.Errorf("claim family = %q; without it the server reads a /128 as an IPv4 length", stored.Spec.IPFamily)
+ }
+ if stored.Spec.ReclaimPolicy != ipamv1alpha1.ReclaimDelete {
+ t.Errorf("reclaimPolicy = %q; Retain would hand a recreated shard the same address back and defeat the only remedy for a wrong one",
+ stored.Spec.ReclaimPolicy)
+ }
+ // spec.ownerRef is overwritten by the server with the requesting project's
+ // identity, so the shard a claim is held for can only be recorded here.
+ if stored.Annotations[egressaddress.AnnotationShardName] != testShardName {
+ t.Errorf("the claim records no shard: annotations = %v", stored.Annotations)
+ }
+}
+
+// A deployment that cannot say which location it serves must not reach a shard
+// at all: the address it would write cannot be taken back.
+func TestSetupRefusesADeploymentThatCannotClaimCorrectly(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ reconciler *EgressShardAddressReconciler
+ }{
+ {"no class", &EgressShardAddressReconciler{ClaimNamespace: "default", PlatformProject: testPlatformProject, Location: testLocation, IPAM: &fakeAddressIPAM{}}},
+ {"no location", &EgressShardAddressReconciler{AddressClassIPv6: testAddressClass, ClaimNamespace: "default", PlatformProject: testPlatformProject, IPAM: &fakeAddressIPAM{}}},
+ {"no namespace", &EgressShardAddressReconciler{AddressClassIPv6: testAddressClass, PlatformProject: testPlatformProject, Location: testLocation, IPAM: &fakeAddressIPAM{}}},
+ {"no project", &EgressShardAddressReconciler{AddressClassIPv6: testAddressClass, ClaimNamespace: "default", Location: testLocation, IPAM: &fakeAddressIPAM{}}},
+ {"no address space", &EgressShardAddressReconciler{AddressClassIPv6: testAddressClass, ClaimNamespace: "default", PlatformProject: testPlatformProject, Location: testLocation}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := tc.reconciler.SetupWithManager(nil); err == nil {
+ t.Fatal("a deployment that cannot claim correctly started anyway")
+ }
+ })
+ }
+}
+
+// Nothing here writes Retain, but an operator can set it on the class or
+// release a claim by hand, and the service then refuses a claim of the same
+// name with a 409 rather than handing the address back (milo-os/ipam #107 --
+// no lease expiry and no replacement matching). The refusal names the
+// allocation, so the address is one read away: take it rather than leaving the
+// shard unaddressed forever behind a conflict that will never clear.
+func TestARetainedAddressIsAdoptedRatherThanLost(t *testing.T) {
+ claimName := egressaddress.ClaimName(testShardNamespace, testShardName)
+ allocationName := allocationNameForClaim(claimName)
+ const held = "2001:db8:100::abcd/128"
+
+ retained := &ipamv1alpha1.IPAllocation{
+ ObjectMeta: metav1.ObjectMeta{Namespace: testClaimNamespace, Name: allocationName},
+ Status: ipamv1alpha1.IPAllocationStatus{AllocatedCIDR: held},
+ }
+
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+ service.retained[allocationName] = held
+ if err := service.client.Create(context.Background(), retained); err != nil {
+ t.Fatalf("seed the retained allocation: %v", err)
+ }
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ got := readShard(t, cell, testShardName).Spec.ShardAddressIPv6
+ if got != "2001:db8:100::abcd" {
+ t.Fatalf("address = %q, want the retained address %q read out of the allocation the refusal named",
+ got, "2001:db8:100::abcd")
+ }
+}
+
+// The address is read out of status.allocatedCIDR and nowhere else. The API
+// also carries a status.address holding the single-address form, and no
+// released version of the service writes it, so a controller reading that
+// instead treats every successful allocation as unbound.
+func TestTheAddressIsReadFromAllocatedCIDRNotStatusAddress(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ stored := &ipamv1alpha1.IPClaim{}
+ if err := service.client.Get(context.Background(), client.ObjectKey{
+ Namespace: testClaimNamespace,
+ Name: egressaddress.ClaimName(testShardNamespace, testShardName),
+ }, stored); err != nil {
+ t.Fatalf("read the claim back: %v", err)
+ }
+ if stored.Status.Address != "" {
+ t.Fatal("the fake set status.address, so this no longer proves the controller ignores it")
+ }
+ if readShard(t, cell, testShardName).Spec.ShardAddressIPv6 == "" {
+ t.Fatal("the shard was left unaddressed by a claim whose allocatedCIDR was set")
+ }
+}
+
+// The address alone is unattributable: the service overwrites a claim's
+// ownerRef with the requesting project's identity, so the only trail from a
+// translating address to the allocation accountable for it is this reference.
+func TestAFreshlyClaimedAddressRecordsItsClaim(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ got := readShard(t, cell, testShardName)
+ ref := got.Spec.ShardAddressIPv6ClaimRef
+ if ref == nil {
+ t.Fatal("the address was assigned with no trail back to what holds it")
+ }
+ if ref.Kind != egressaddress.KindIPClaim {
+ t.Errorf("kind = %q, want %q for an address a live claim holds", ref.Kind, egressaddress.KindIPClaim)
+ }
+ if want := egressaddress.ClaimName(testShardNamespace, testShardName); ref.Name != want {
+ t.Errorf("name = %q, want the claim named for the shard %q", ref.Name, want)
+ }
+ if ref.Namespace != testClaimNamespace {
+ t.Errorf("namespace = %q, want %q", ref.Namespace, testClaimNamespace)
+ }
+ // Required by the API, and a reference without it resolves nowhere.
+ if ref.Project != testPlatformProject {
+ t.Errorf("project = %q, want %q", ref.Project, testPlatformProject)
+ }
+ if ref.APIGroup != ipamv1alpha1.GroupName {
+ t.Errorf("apiGroup = %q, want %q", ref.APIGroup, ipamv1alpha1.GroupName)
+ }
+ // The reference must name the claim the address actually came from, which
+ // is the one the service was asked to bind.
+ if len(service.created) != 1 || service.created[0] != ref.Name {
+ t.Errorf("the reference names %q but the claims created were %v", ref.Name, service.created)
+ }
+}
+
+// The case most likely to record something that does not exist. Adopting an
+// address held by a retained allocation means no claim was ever stored, so the
+// reference has to name the allocation. Both fields are write-once, so naming
+// the refused claim would be permanent for this shard's lifetime.
+func TestAnAdoptedAddressRecordsTheAllocationItCameFrom(t *testing.T) {
+ claimName := egressaddress.ClaimName(testShardNamespace, testShardName)
+ allocationName := allocationNameForClaim(claimName)
+ const held = "2001:db8:100::abcd/128"
+
+ retained := &ipamv1alpha1.IPAllocation{
+ ObjectMeta: metav1.ObjectMeta{Namespace: testClaimNamespace, Name: allocationName},
+ Status: ipamv1alpha1.IPAllocationStatus{AllocatedCIDR: held},
+ }
+
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+ service.retained[allocationName] = held
+ if err := service.client.Create(context.Background(), retained); err != nil {
+ t.Fatalf("seed the retained allocation: %v", err)
+ }
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ got := readShard(t, cell, testShardName)
+ ref := got.Spec.ShardAddressIPv6ClaimRef
+ if ref == nil {
+ t.Fatal("an adopted address was assigned with no trail back to what holds it")
+ }
+ if ref.Name == claimName {
+ t.Fatal("the reference names the claim the service refused and never stored")
+ }
+ if ref.Name != allocationName {
+ t.Errorf("name = %q, want the allocation the refusal named %q", ref.Name, allocationName)
+ }
+ if ref.Kind != egressaddress.KindIPAllocation {
+ t.Errorf("kind = %q, want %q; no claim exists to point at", ref.Kind, egressaddress.KindIPAllocation)
+ }
+ if got.Spec.ShardAddressIPv6 != "2001:db8:100::abcd" {
+ t.Errorf("address = %q, want the retained address", got.Spec.ShardAddressIPv6)
+ }
+}
+
+// Both fields are write-once, so a shard already carrying them is read and left
+// exactly as it is. Reconciling one must not draw a second address, and must
+// not attempt a rewrite the API would refuse.
+func TestAShardCarryingBothIsLeftUntouched(t *testing.T) {
+ existing := shard(testShardName)
+ existing.Spec.ShardAddressIPv6 = "2001:db8:100::dead"
+ existing.Spec.ShardAddressIPv6ClaimRef = &bgpv1alpha1.AddressClaimRef{
+ APIGroup: ipamv1alpha1.GroupName,
+ Kind: egressaddress.KindIPClaim,
+ Project: testPlatformProject,
+ Namespace: testClaimNamespace,
+ Name: "a-claim-someone-else-made",
+ }
+ existing.Labels = map[string]string{
+ bgpv1alpha1.LabelEgressShardIPv6: bgpv1alpha1.LabelValueEgressFamilyServed,
+ }
+ cell := newShardCell(t, existing)
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ if len(service.created) != 0 {
+ t.Errorf("an addressed shard drew %v from the public range", service.created)
+ }
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6 != "2001:db8:100::dead" {
+ t.Errorf("address = %q, want it untouched", got.Spec.ShardAddressIPv6)
+ }
+ if got.Spec.ShardAddressIPv6ClaimRef == nil || got.Spec.ShardAddressIPv6ClaimRef.Name != "a-claim-someone-else-made" {
+ t.Errorf("reference = %+v, want it untouched", got.Spec.ShardAddressIPv6ClaimRef)
+ }
+}
+
+// An address an operator assigned by hand has no claim behind it, so there is
+// nothing truthful to reference. It stays unattributable rather than gaining a
+// reference this controller invented for an allocation it never made -- which
+// would be permanent, and would name a claim that never existed.
+func TestAnOperatorAssignedAddressGainsNoInventedReference(t *testing.T) {
+ existing := shard(testShardName)
+ existing.Spec.ShardAddressIPv6 = "2001:db8:100::dead"
+ cell := newShardCell(t, existing)
+ service := newFakeAddressIPAM(t)
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6ClaimRef != nil {
+ t.Fatalf("a hand-assigned address gained the invented reference %+v",
+ got.Spec.ShardAddressIPv6ClaimRef)
+ }
+ if len(service.created) != 0 {
+ t.Errorf("a hand-assigned address caused %v to be claimed for the sake of a reference", service.created)
+ }
+}
+
+// Nothing is written at all while the claim holds no address, so a shard never
+// gains a reference whose address is still missing -- both fields are
+// write-once and a half-written pair cannot be completed.
+func TestAnUnboundClaimWritesNeitherAddressNorReference(t *testing.T) {
+ cell := newShardCell(t, shard(testShardName))
+ service := newFakeAddressIPAM(t)
+ service.unbound = true
+
+ if _, err := reconcilerFor(cell, service).Reconcile(context.Background(),
+ requestFor(testShardName)); err == nil {
+ t.Fatal("a claim holding no address reconciled successfully")
+ }
+
+ got := readShard(t, cell, testShardName)
+ if got.Spec.ShardAddressIPv6 != "" {
+ t.Errorf("address = %q, want nothing written", got.Spec.ShardAddressIPv6)
+ }
+ if got.Spec.ShardAddressIPv6ClaimRef != nil {
+ t.Errorf("reference = %+v, want nothing written", got.Spec.ShardAddressIPv6ClaimRef)
+ }
+}
diff --git a/internal/egressaddress/address.go b/internal/egressaddress/address.go
new file mode 100644
index 0000000..805fd34
--- /dev/null
+++ b/internal/egressaddress/address.go
@@ -0,0 +1,366 @@
+/*
+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 egressaddress holds the public address an egress shard translates to.
+//
+// A shard's address is drawn from announceable public space shared by every
+// shard in a location, not from a per-consumer prefix: one address serves every
+// network the class places on the shard, because per-network blocks exhaust a
+// public aggregate long before networks exhaust it.
+//
+// The claim is the record. Its name is derived from the shard, so the address a
+// shard holds is a permanent property of that shard's name in that namespace
+// for as long as the claim lives, and nothing here has to store a mapping of
+// its own. Attribution rides on the name and on annotations because the service
+// overwrites spec.ownerRef with the requesting project's identity, so a claim
+// cannot record which shard it is held for that way.
+package egressaddress
+
+import (
+ "context"
+ "crypto/sha256"
+ "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 (
+ // AddressBits is the prefix length a shard's address is claimed at. A
+ // shard translates to one address, and the datapath claims a reply by
+ // exact match against it, so anything shorter would hand the shard space
+ // it cannot use and would consume announceable public space at a multiple
+ // of what a shard needs.
+ AddressBits = 128
+
+ // ScopeRoleLocation is the scope role naming the location a claim is made
+ // for. The class holding the per-location pool names it in poolPer, so a
+ // claim omitting it is refused rather than drawn from another location's
+ // space.
+ ScopeRoleLocation = "location"
+
+ // locationAPIGroup and locationKind identify a Location the same way every
+ // other claim in the platform does. A scope reference is compared, never
+ // resolved, so the three fields only have to agree with what else claims
+ // against this space -- and disagreeing would carve a second pool for one
+ // location rather than return an error.
+ locationAPIGroup = "networking.datumapis.com"
+ locationKind = "Location"
+
+ // claimNamePrefix is what makes every claim of this kind recognisable to an
+ // operator reading the platform project, where claims for several purposes
+ // share one namespace.
+ claimNamePrefix = "egress-shard-ipv6"
+
+ // AnnotationShardNamespace and AnnotationShardName record the shard a claim
+ // is held for.
+ //
+ // Annotations rather than labels: a shard is named after the node it runs
+ // on and may exceed the 63 characters a label value allows, and nothing
+ // selects these claims -- the name is derived from the shard, so every
+ // lookup is a Get. A label would buy a selector nobody uses at the cost of
+ // refusing to record the shards with the longest names.
+ AnnotationShardNamespace = "cloud.datumapis.com/egress-shard-namespace"
+ AnnotationShardName = "cloud.datumapis.com/egress-shard-name"
+
+ // maxClaimNameLength is the ceiling on an object name in Kubernetes, which
+ // is all an IPClaim name is.
+ maxClaimNameLength = 253
+)
+
+// Holding is a shard's address together with the record in the addressing
+// service that holds it.
+//
+// The record has to be reported, not just the address, because the service
+// overwrites spec.ownerRef on a claim with the requesting project's identity.
+// A claim therefore cannot say which shard it was made for, and the only place
+// that link can live is on the shard, pointing back.
+//
+// Kind is part of it rather than assumed. An address recovered from a retained
+// allocation is held by an IPAllocation and by no claim at all: the service
+// rolls the transaction back before refusing, so the claim it refused was
+// never stored. A reference that named a claim in that case would name an
+// object that does not exist.
+type Holding struct {
+ // Address is the address the shard translates to.
+ Address netip.Addr
+
+ // Namespace is the namespace in the platform's tenancy holding the record.
+ Namespace string
+
+ // Kind is KindIPClaim or KindIPAllocation.
+ Kind string
+
+ // Name is the record's name. For a claim it is derived from the shard; for
+ // a retained allocation it is the name the service's refusal carried, and
+ // is never computed here.
+ Name string
+}
+
+// The kinds a Holding can name.
+const (
+ KindIPClaim = "IPClaim"
+ KindIPAllocation = "IPAllocation"
+)
+
+// HeldByClaim reports whether a live claim holds the address. False means the
+// address was recovered from an allocation a released claim retained, and no
+// claim object exists to point at.
+func (h Holding) HeldByClaim() bool { return h.Kind == KindIPClaim }
+
+// Request names one shard's claim on the public address space.
+type Request struct {
+ // ClassName is the class that hands out shard addresses.
+ ClassName string
+
+ // Namespace is the namespace in the platform's own tenancy the claim is
+ // written to.
+ Namespace string
+
+ // Location is the location whose shared public range the address comes
+ // from. Shards in one location draw from one range, and two cells serving
+ // the same location share it.
+ Location string
+
+ // ShardNamespace and ShardName identify the shard the address is for. The
+ // claim is named from the pair, so a shard reconciled twice finds the
+ // address it already holds rather than drawing a second one.
+ ShardNamespace string
+ ShardName string
+}
+
+// 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 shard "c" and
+// namespace "a" with shard "b-c" would collide, and a collision here is two
+// shards sharing one address.
+func ClaimName(shardNamespace, shardName string) string {
+ name := fmt.Sprintf("%s.%s.%s", claimNamePrefix, shardNamespace, shardName)
+ if len(name) <= maxClaimNameLength {
+ return name
+ }
+
+ sum := sha256.Sum256([]byte(shardNamespace + "/" + shardName))
+ suffix := "." + hex.EncodeToString(sum[:])[:16]
+ return name[:maxClaimNameLength-len(suffix)] + suffix
+}
+
+// Claim holds the address this shard translates to.
+//
+// The service 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.
+func Claim(ctx context.Context, ipamClient client.Client, request Request) (Holding, error) {
+ ipClaim := &ipamv1alpha1.IPClaim{}
+ ipClaim.Namespace = request.Namespace
+ ipClaim.Name = ClaimName(request.ShardNamespace, request.ShardName)
+ ipClaim.Annotations = map[string]string{
+ AnnotationShardNamespace: request.ShardNamespace,
+ AnnotationShardName: request.ShardName,
+ }
+ 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 /128 is read as an IPv4 length and
+ // refused, so every allocation fails.
+ IPFamily: ipamv1alpha1.IPv6,
+
+ Target: ipamv1alpha1.TargetBlock,
+ PrefixLength: ptr.To(int32(AddressBits)),
+
+ Scope: map[string]ipamv1alpha1.ScopeRef{
+ ScopeRoleLocation: {
+ APIGroup: locationAPIGroup,
+ Kind: locationKind,
+ Name: request.Location,
+ },
+ },
+
+ // Stated here rather than left to the class, because the reason for it
+ // is this controller's own. A shard holding the wrong address is fixed
+ // by deleting and recreating the shard, and a retained allocation would
+ // hand the replacement the same address back and silently defeat that
+ // remedy. Announceable public space is also scarce enough that an
+ // address held forever by a decommissioned node is a real loss, where
+ // reissue costs nothing a consumer was promised: the class is shared,
+ // so the address is reported as one nobody may rely on.
+ ReclaimPolicy: ipamv1alpha1.ReclaimDelete,
+ }
+
+ existing := &ipamv1alpha1.IPClaim{}
+ getErr := ipamClient.Get(ctx, client.ObjectKeyFromObject(ipClaim), existing)
+ if getErr != nil && !apierrors.IsNotFound(getErr) {
+ return Holding{}, fmt.Errorf("read the egress address 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
+ // shard's own address. The service refuses the create and names the
+ // allocation, so the address is one read away rather than lost: read it
+ // rather than treating the refusal as a failure. Nothing here writes
+ // Retain, so this is reached only where an operator set it on the class
+ // or released a claim by hand.
+ 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 Holding{}, fmt.Errorf("claim an egress address: %w", createErr)
+ }
+ ipClaim = raced
+ }
+
+ if ipClaim.Status.AllocatedCIDR == "" {
+ // Not an error about the address: the claim exists and holds nothing
+ // yet. The caller must write nothing, because the field it would write
+ // cannot be corrected afterwards.
+ return Holding{}, &UnboundError{
+ claimName: ipClaim.Name,
+ phase: string(ipClaim.Status.Phase),
+ }
+ }
+
+ address, err := FromAllocatedCIDR(ipClaim.Status.AllocatedCIDR)
+ if err != nil {
+ return Holding{}, err
+ }
+ // The claim this address was read out of, by the name it was actually
+ // stored under -- not the name a caller would recompute.
+ return Holding{
+ Address: address,
+ Namespace: ipClaim.Namespace,
+ Kind: KindIPClaim,
+ Name: ipClaim.Name,
+ }, nil
+}
+
+// Release gives the shard's address back.
+//
+// Deleting the claim is what frees it, because the claim is written with
+// ReclaimPolicy Delete. It is called only for a shard that is gone: releasing
+// while a shard still holds the address in its spec would put the address back
+// in circulation for another shard to be handed while the first is still
+// translating with it, and the two would split each other's return traffic.
+func Release(ctx context.Context, ipamClient client.Client, namespace, shardNamespace, shardName string) error {
+ ipClaim := &ipamv1alpha1.IPClaim{}
+ ipClaim.Namespace = namespace
+ ipClaim.Name = ClaimName(shardNamespace, shardName)
+
+ if err := ipamClient.Delete(ctx, ipClaim); err != nil && !apierrors.IsNotFound(err) {
+ return fmt.Errorf("release the egress address claim %q: %w", ipClaim.Name, err)
+ }
+ return nil
+}
+
+// adopt reads the address out of an allocation this shard already holds. The
+// allocation outlives the claim that made it, which is what retention is for,
+// so the address it names is the one this shard has always had.
+//
+// It returns the allocation as the holding record. No claim exists to return:
+// the service rolls its transaction back before answering with this refusal.
+func adopt(ctx context.Context, ipamClient client.Client, namespace, allocationName string) (Holding, error) {
+ allocation := &ipamv1alpha1.IPAllocation{}
+ if err := ipamClient.Get(ctx,
+ client.ObjectKey{Namespace: namespace, Name: allocationName}, allocation); err != nil {
+ return Holding{}, fmt.Errorf("read the retained allocation %q: %w", allocationName, err)
+ }
+ if allocation.Status.AllocatedCIDR == "" {
+ return Holding{}, &UnboundError{claimName: allocationName, phase: string(allocation.Status.Phase)}
+ }
+ address, err := FromAllocatedCIDR(allocation.Status.AllocatedCIDR)
+ if err != nil {
+ return Holding{}, err
+ }
+ // The allocation, never the claim. The service rolled its transaction back
+ // before refusing the create, so the claim whose name this was derived
+ // from was never stored and pointing at it would point at nothing. The
+ // name is the one the refusal carried, so it names the object that is
+ // actually there.
+ return Holding{
+ Address: address,
+ Namespace: namespace,
+ Kind: KindIPAllocation,
+ Name: allocationName,
+ }, nil
+}
+
+// FromAllocatedCIDR reads the shard's address out of what the service handed
+// out.
+//
+// status.allocatedCIDR is the only field read. The API also carries a
+// status.address holding the single-address form, and no released version of
+// the service writes it, so a controller reading it treats every successful
+// allocation as unbound.
+func FromAllocatedCIDR(cidr string) (netip.Addr, error) {
+ prefix, err := netip.ParsePrefix(cidr)
+ if err != nil {
+ return netip.Addr{}, &UnusableError{message: fmt.Sprintf(
+ "the address service answered with %q, which is not a prefix", cidr)}
+ }
+
+ address := prefix.Addr()
+ if !address.Is6() || address.Is4In6() {
+ return netip.Addr{}, &UnusableError{message: fmt.Sprintf(
+ "the address service answered with %q; a shard's IPv6 address is read out of an IPv6 space", cidr)}
+ }
+
+ // Anything shorter is a block rather than an address. A shard given one
+ // would translate to its first address while holding the rest out of
+ // circulation, and nothing would report the difference.
+ if prefix.Bits() != AddressBits {
+ return netip.Addr{}, &UnusableError{message: fmt.Sprintf(
+ "the address service answered with %q; a shard's address is read out of a /%d", cidr, AddressBits)}
+ }
+
+ return address, nil
+}
+
+// UnusableError says the address service answered, and its answer cannot be
+// used as a shard address. Retrying reaches the same allocation, 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 }
+
+// UnboundError says the claim exists and holds no address yet. Unlike
+// UnusableError this resolves on its own, so it is worth retrying and is never
+// worth writing anything on.
+type UnboundError struct {
+ claimName string
+ phase string
+}
+
+func (e *UnboundError) Error() string {
+ return fmt.Sprintf("the address service has allocated nothing for claim %q yet (phase %q)",
+ e.claimName, e.phase)
+}
diff --git a/internal/egressaddress/address_test.go b/internal/egressaddress/address_test.go
new file mode 100644
index 0000000..f8c197f
--- /dev/null
+++ b/internal/egressaddress/address_test.go
@@ -0,0 +1,249 @@
+/*
+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 egressaddress
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/netip"
+ "strings"
+ "testing"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ "go.miloapis.com/ipam/pkg/ipamerrors"
+ 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/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+)
+
+// A claim name collision is two shards sharing one address, so the split
+// between namespace and name has to be unambiguous. A dash would not be: the
+// two cases below would produce the same name.
+func TestClaimNameCannotCollideAcrossTheNamespaceBoundary(t *testing.T) {
+ first := ClaimName("a-b", "c")
+ second := ClaimName("a", "b-c")
+ if first == second {
+ t.Fatalf("two different shards share the claim name %q", first)
+ }
+}
+
+func TestClaimNameNamesTheShard(t *testing.T) {
+ got := ClaimName("galactic-system", "worker-8b4e1647-dfw")
+ if !strings.Contains(got, "worker-8b4e1647-dfw") {
+ t.Errorf("claim name %q does not name the shard it is held for", got)
+ }
+ if !strings.HasPrefix(got, claimNamePrefix) {
+ t.Errorf("claim name %q is not recognisable as an egress shard address claim", got)
+ }
+}
+
+// A shard is named after its node, and the name it produces still has to be a
+// name the API server will accept.
+func TestALongShardNameStillYieldsAValidClaimName(t *testing.T) {
+ long := strings.Repeat("n", 300)
+ got := ClaimName("galactic-system", long)
+ if len(got) > maxClaimNameLength {
+ t.Fatalf("claim name is %d characters, over the %d the API server allows", len(got), maxClaimNameLength)
+ }
+ // Two long names that share a prefix must not truncate to one claim.
+ other := ClaimName("galactic-system", long+"x")
+ if got == other {
+ t.Fatal("two shards with long names share one claim name")
+ }
+}
+
+func TestFromAllocatedCIDRReadsTheAddress(t *testing.T) {
+ got, err := FromAllocatedCIDR("2607:ed40:70::1/128")
+ if err != nil {
+ t.Fatalf("read the address: %v", err)
+ }
+ if got.String() != "2607:ed40:70::1" {
+ t.Errorf("address = %q, want the host address without its prefix length", got.String())
+ }
+}
+
+// Every one of these is an answer the service gave that cannot be used as a
+// shard address. Reading one anyway would write it into a field that cannot be
+// corrected.
+func TestFromAllocatedCIDRRefusesWhatIsNotAShardAddress(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cidr string
+ }{
+ {"not a prefix", "2607:ed40:70::1"},
+ {"empty", ""},
+ {"a block rather than an address", "2607:ed40:70::/64"},
+ {"the wrong family", "198.51.100.7/32"},
+ {"an IPv4 address in IPv6 clothing", "::ffff:198.51.100.7/128"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := FromAllocatedCIDR(tc.cidr)
+ if err == nil {
+ t.Fatalf("%q was accepted as a shard address (%s)", tc.cidr, got)
+ }
+ var unusable *UnusableError
+ if !errors.As(err, &unusable) {
+ t.Errorf("error = %v; an answer that cannot be used is a wait on an operator, not a retry", err)
+ }
+ })
+ }
+}
+
+const (
+ probeNamespace = "default"
+ probeShardNamespace = "galactic-system"
+ probeShardName = "worker-8b4e1647-dfw"
+)
+
+func probeRequest() Request {
+ return Request{
+ ClassName: "datum-egress-shard-address-ipv6",
+ Namespace: probeNamespace,
+ Location: "us-central-1",
+ ShardNamespace: probeShardNamespace,
+ ShardName: probeShardName,
+ }
+}
+
+func ipamScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := ipamv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the IPAM scheme: %v", err)
+ }
+ return scheme
+}
+
+// A fresh claim reports itself as the record holding the address, under the
+// name it was actually stored as.
+func TestAFreshClaimIsReportedAsTheHolder(t *testing.T) {
+ bind := interceptor.Funcs{
+ Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error {
+ ipClaim, ok := object.(*ipamv1alpha1.IPClaim)
+ if !ok {
+ return c.Create(ctx, object, opts...)
+ }
+ ipClaim.Status.Phase = ipamv1alpha1.ClaimPhase("Bound")
+ ipClaim.Status.AllocatedCIDR = "2001:db8:100::7/128"
+ return c.Create(ctx, ipClaim, opts...)
+ },
+ }
+ ipamClient := fake.NewClientBuilder().WithScheme(ipamScheme(t)).WithInterceptorFuncs(bind).Build()
+
+ holding, err := Claim(context.Background(), ipamClient, probeRequest())
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+ if !holding.HeldByClaim() {
+ t.Errorf("kind = %q, want the address held by a claim", holding.Kind)
+ }
+ if want := ClaimName(probeShardNamespace, probeShardName); holding.Name != want {
+ t.Errorf("holder name = %q, want the claim named for the shard %q", holding.Name, want)
+ }
+ if holding.Namespace != probeNamespace {
+ t.Errorf("holder namespace = %q, want %q", holding.Namespace, probeNamespace)
+ }
+ if holding.Address != netip.MustParseAddr("2001:db8:100::7") {
+ t.Errorf("address = %s, want the allocated address", holding.Address)
+ }
+}
+
+// The case most likely to record a reference to something that does not exist.
+//
+// The service rolls its transaction back before refusing a claim whose identity
+// a retained allocation already occupies, so the claim is NEVER stored. The
+// holder is the allocation, named as the refusal named it -- and the allocation
+// name is a hash of the claim's namespace and name, so it is not the claim name
+// and cannot be derived from the shard.
+func TestAnAdoptedAddressIsReportedAsHeldByTheAllocation(t *testing.T) {
+ claimName := ClaimName(probeShardNamespace, probeShardName)
+ // A hash, as the service computes it -- deliberately unlike the claim name.
+ const allocationName = "alloc-3f2a1b0c9d8e7f60"
+
+ retained := &ipamv1alpha1.IPAllocation{
+ ObjectMeta: metav1.ObjectMeta{Namespace: probeNamespace, Name: allocationName},
+ Status: ipamv1alpha1.IPAllocationStatus{AllocatedCIDR: "2001:db8:100::abcd/128"},
+ }
+
+ refuse := interceptor.Funcs{
+ Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error {
+ if _, ok := object.(*ipamv1alpha1.IPClaim); ok {
+ return ipamerrors.NewRetainedAllocation(
+ ipamv1alpha1.Resource("ipclaims"), claimName, allocationName,
+ fmt.Sprintf("an allocation under this identity already exists: IPAllocation %q", allocationName))
+ }
+ return c.Create(ctx, object, opts...)
+ },
+ }
+ ipamClient := fake.NewClientBuilder().
+ WithScheme(ipamScheme(t)).
+ WithObjects(retained).
+ WithInterceptorFuncs(refuse).
+ Build()
+
+ holding, err := Claim(context.Background(), ipamClient, probeRequest())
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+
+ if holding.HeldByClaim() {
+ t.Fatalf("kind = %q; no claim was stored, so naming one names an object that does not exist", holding.Kind)
+ }
+ if holding.Kind != KindIPAllocation {
+ t.Errorf("kind = %q, want %q", holding.Kind, KindIPAllocation)
+ }
+ if holding.Name != allocationName {
+ t.Fatalf("holder name = %q, want the allocation the refusal named %q", holding.Name, allocationName)
+ }
+ if holding.Name == claimName {
+ t.Fatal("the adopted address was attributed to the claim that was refused and never stored")
+ }
+ if holding.Address != netip.MustParseAddr("2001:db8:100::abcd") {
+ t.Errorf("address = %s, want the retained address", holding.Address)
+ }
+}
+
+// An unbound claim reports no holder at all, because there is nothing to
+// attribute yet and the fields it would be written into cannot be rewritten.
+func TestAnUnboundClaimReportsNoHolder(t *testing.T) {
+ hold := interceptor.Funcs{
+ Create: func(ctx context.Context, c client.WithWatch, object client.Object, opts ...client.CreateOption) error {
+ if ipClaim, ok := object.(*ipamv1alpha1.IPClaim); ok {
+ ipClaim.Status.Phase = ipamv1alpha1.ClaimPhase("Pending")
+ return c.Create(ctx, ipClaim, opts...)
+ }
+ return c.Create(ctx, object, opts...)
+ },
+ }
+ ipamClient := fake.NewClientBuilder().WithScheme(ipamScheme(t)).WithInterceptorFuncs(hold).Build()
+
+ holding, err := Claim(context.Background(), ipamClient, probeRequest())
+ if err == nil {
+ t.Fatal("an unbound claim was reported as holding an address")
+ }
+ var unbound *UnboundError
+ if !errors.As(err, &unbound) {
+ t.Errorf("error = %v, want an unbound claim worth retrying", err)
+ }
+ if holding != (Holding{}) {
+ t.Errorf("holding = %+v, want nothing to attribute", holding)
+ }
+}