diff --git a/Dockerfile b/Dockerfile
index 3144d46..96300da 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,10 +13,12 @@ COPY api/ api/
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
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=builder /workspace/vpc-controller .
+COPY --from=builder /workspace/fabric-identity-controller .
USER 65532:65532
ENTRYPOINT ["/vpc-controller"]
diff --git a/api/v1alpha1/networkfabricidentity_types.go b/api/v1alpha1/networkfabricidentity_types.go
new file mode 100644
index 0000000..1e30882
--- /dev/null
+++ b/api/v1alpha1/networkfabricidentity_types.go
@@ -0,0 +1,110 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// NetworkFabricIdentitySpec carries the identity the fabric knows one network
+// by.
+type NetworkFabricIdentitySpec struct {
+ // Identity is what the fabric knows the network by, the same in every
+ // location the network reaches. The Route Target is derived from it, which
+ // is what makes two locations of one network import each other's routes
+ // rather than behave as two networks that share a name. The VRF device is
+ // named from it for the same reason.
+ //
+ // It is an integer rather than an encoded string because a consumer builds
+ // `ASN:` from it and encodes it for its own use. It is 32 bits
+ // wide because that is what survives into the Route Target: the fabric
+ // truncates, so a wider value would be uniqueness the platform believes it
+ // has and the fabric does not.
+ //
+ // It is never zero and never changes. The fabric embeds it in import policy
+ // in every location the network reaches, so a network that changed identity
+ // would be a different network to everything already carrying its traffic.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=4294967295
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="identity is immutable"
+ Identity int64 `json:"identity"`
+
+ // NetworkRef names the network this identity belongs to.
+ //
+ // It carries a name and no UID, deliberately. The identity is a permanent
+ // property of a name in a namespace, not of one object's lifetime: a
+ // network deleted and recreated under the same name inherits it. A UID here
+ // would document the opposite of the rule.
+ //
+ // +kubebuilder:validation:Required
+ NetworkRef NetworkFabricIdentityNetworkRef `json:"networkRef"`
+}
+
+// NetworkFabricIdentityNetworkRef identifies the network an identity was
+// allocated for.
+type NetworkFabricIdentityNetworkRef struct {
+ // Name is the network's name.
+ //
+ // +kubebuilder:validation:Required
+ Name string `json:"name"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:printcolumn:name="Identity",type="integer",JSONPath=".spec.identity"
+// +kubebuilder:printcolumn:name="Network",type="string",JSONPath=".spec.networkRef.name"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// NetworkFabricIdentity tells a location what identity the fabric knows a
+// network by.
+//
+// There is one per network, not one per location. A VPC is the network's
+// realization at a single location and takes its identity from here, which is
+// what makes the locations of one network the same network on the fabric
+// instead of unrelated ones that happen to share a name.
+//
+// This is platform-internal. It is written centrally and carried to the cells
+// where the network is required; it never appears in a project control plane
+// and no consumer reads or writes one. The identity is a value the fabric acts
+// on directly, so it is kept to the platform rather than published beside the
+// network it belongs to.
+//
+// This object is managed for you. It follows the Network it was allocated for.
+type NetworkFabricIdentity struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Spec is the whole of this object. There is no status: federation carries
+ // configuration to a cell and deliberately does not carry status, so
+ // anything a cell has to read has to be here.
+ Spec NetworkFabricIdentitySpec `json:"spec,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// NetworkFabricIdentityList contains a list of NetworkFabricIdentity.
+type NetworkFabricIdentityList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []NetworkFabricIdentity `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&NetworkFabricIdentity{}, &NetworkFabricIdentityList{})
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 4bc77cf..b31bf5c 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -26,6 +26,95 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NetworkFabricIdentity) DeepCopyInto(out *NetworkFabricIdentity) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentity.
+func (in *NetworkFabricIdentity) DeepCopy() *NetworkFabricIdentity {
+ if in == nil {
+ return nil
+ }
+ out := new(NetworkFabricIdentity)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *NetworkFabricIdentity) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NetworkFabricIdentityList) DeepCopyInto(out *NetworkFabricIdentityList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]NetworkFabricIdentity, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentityList.
+func (in *NetworkFabricIdentityList) DeepCopy() *NetworkFabricIdentityList {
+ if in == nil {
+ return nil
+ }
+ out := new(NetworkFabricIdentityList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *NetworkFabricIdentityList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NetworkFabricIdentityNetworkRef) DeepCopyInto(out *NetworkFabricIdentityNetworkRef) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentityNetworkRef.
+func (in *NetworkFabricIdentityNetworkRef) DeepCopy() *NetworkFabricIdentityNetworkRef {
+ if in == nil {
+ return nil
+ }
+ out := new(NetworkFabricIdentityNetworkRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NetworkFabricIdentitySpec) DeepCopyInto(out *NetworkFabricIdentitySpec) {
+ *out = *in
+ out.NetworkRef = in.NetworkRef
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkFabricIdentitySpec.
+func (in *NetworkFabricIdentitySpec) DeepCopy() *NetworkFabricIdentitySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(NetworkFabricIdentitySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NetworkInterfaceRef) DeepCopyInto(out *NetworkInterfaceRef) {
*out = *in
diff --git a/cmd/fabric-identity-controller/main.go b/cmd/fabric-identity-controller/main.go
new file mode 100644
index 0000000..11828cb
--- /dev/null
+++ b/cmd/fabric-identity-controller/main.go
@@ -0,0 +1,192 @@
+/*
+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 fabric-identity-controller allocates the identity the fabric knows a
+// network by, and carries it to the cells where the network is required.
+//
+// It runs centrally rather than in a cell. A network spans locations, so the
+// one thing that has to be the same in all of them cannot be decided in any one
+// of them.
+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/cluster"
+ "sigs.k8s.io/controller-runtime/pkg/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/controller"
+ "go.datum.net/cloud/internal/ipam"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+var scheme = runtime.NewScheme()
+
+func init() {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(cloudv1alpha1.AddToScheme(scheme))
+ utilruntime.Must(networkingv1alpha.AddToScheme(scheme))
+}
+
+func main() {
+ var metricsAddr, probeAddr string
+ var identityClass, identityNamespace, platformProject, ipamKubeconfig, hubKubeconfig 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 network to one identity.")
+ flag.StringVar(&identityClass, "identity-class", "",
+ "Required. The IPClass that hands out fabric identities. It roots an identifier space that is never routed, and must not hand out its own first block.")
+ flag.StringVar(&identityNamespace, "identity-namespace", "default",
+ "Namespace in the platform's own tenancy that identity claims are written to.")
+ flag.StringVar(&platformProject, "platform-project", "",
+ "Required. The project control plane the platform allocates its own values in. A network's identity must be unique across every consumer, so it cannot be drawn from any one of them.")
+ flag.StringVar(&ipamKubeconfig, "ipam-kubeconfig", "",
+ "Required. Path to a kubeconfig for the cluster serving the IPAM API.")
+ flag.StringVar(&hubKubeconfig, "hub-kubeconfig", "",
+ "Required. Path to a kubeconfig for the federation hub. Networks and their NetworkContexts are read there as copies published by the operator that owns them, and the identity and its placement are written there. Leader election is not: that stays on the cluster this runs on.")
+
+ opts := zap.Options{Development: false}
+ opts.BindFlags(flag.CommandLine)
+ flag.Parse()
+
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+ setupLog := ctrl.Log.WithName("setup")
+
+ switch {
+ case identityClass == "":
+ setupLog.Error(nil, "-identity-class is required")
+ os.Exit(1)
+ case platformProject == "":
+ // A deployment naming an identifier space and nowhere platform-owned to
+ // draw from would hand out identities unique only within one consumer,
+ // which is not unique at all. Say so at startup rather than per network.
+ setupLog.Error(nil, "-platform-project is required")
+ os.Exit(1)
+ case ipamKubeconfig == "":
+ setupLog.Error(nil, "-ipam-kubeconfig is required")
+ os.Exit(1)
+ case hubKubeconfig == "":
+ // Nothing this component does happens anywhere else, so there is no
+ // degraded mode worth starting into.
+ setupLog.Error(nil, "-hub-kubeconfig is required")
+ os.Exit(1)
+ }
+
+ hubRestConfig, err := clientcmd.BuildConfigFromFlags("", hubKubeconfig)
+ if err != nil {
+ setupLog.Error(err, "unable to load the hub kubeconfig")
+ os.Exit(1)
+ }
+
+ // The manager runs against the cluster this is scheduled on, and reaches the
+ // hub as a second cluster. Only the leader election lease lives here.
+ //
+ // Putting the lease on the hub instead would write constantly to a control
+ // plane everything else federates through, and would make this component's
+ // leadership only as available as that plane: a hub hiccup would churn
+ // leadership and restart the controller. An unreachable hub has to cost
+ // this component its work. It does not have to cost it its identity.
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsserver.Options{BindAddress: metricsAddr},
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "fabric-identity-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)
+ }
+
+ hub, err := cluster.New(hubRestConfig, func(options *cluster.Options) {
+ options.Scheme = scheme
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to reach the federation hub")
+ os.Exit(1)
+ }
+ // A cluster carries a cache, so the manager puts it in the cache group
+ // rather than the leader election group: it starts on every replica, ahead
+ // of any election, and startup blocks until its cache has synced. That is
+ // why nothing below probes the hub for readiness. A pod cannot report ready
+ // while the hub is unreadable, because it has not finished starting.
+ if err := mgr.Add(hub); err != nil {
+ setupLog.Error(err, "unable to run the federation hub's cache")
+ os.Exit(1)
+ }
+
+ // Networks and Hub are both the hub. A Network lives in its consumer's
+ // project control plane, which this binary cannot reach, so it is read
+ // there as a copy the network operator publishes. They stay separate fields
+ // because a read failing and a write failing have to be distinguishable.
+ if err := (&controller.NetworkFabricIdentityReconciler{
+ Networks: hub.GetClient(),
+ Hub: hub.GetClient(),
+ HubCluster: hub,
+ IPAM: ipamClients,
+ IdentityClass: identityClass,
+ IdentityNamespace: identityNamespace,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "NetworkFabricIdentity")
+ 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 fabric identity controller")
+ if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+}
diff --git a/config/components/fabric-identity/deployment.yaml b/config/components/fabric-identity/deployment.yaml
new file mode 100644
index 0000000..a581878
--- /dev/null
+++ b/config/components/fabric-identity/deployment.yaml
@@ -0,0 +1,117 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: fabric-identity-controller
+ namespace: system
+ labels:
+ app.kubernetes.io/name: fabric-identity-controller
+ app.kubernetes.io/component: fabric-identity-controller
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ # A network's identity is decided once. Leader election is what keeps that
+ # true across a rollout; the replica count is not.
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: fabric-identity-controller
+ template:
+ metadata:
+ annotations:
+ kubectl.kubernetes.io/default-container: manager
+ labels:
+ app.kubernetes.io/name: fabric-identity-controller
+ app.kubernetes.io/component: fabric-identity-controller
+ spec:
+ serviceAccountName: vpc-controller
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: manager
+ image: ghcr.io/datum-cloud/vpc-controller
+ command:
+ - /fabric-identity-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
+ - --identity-class=$(IDENTITY_CLASS)
+ - --identity-namespace=$(IDENTITY_NAMESPACE)
+ - --platform-project=$(PLATFORM_PROJECT)
+ - --ipam-kubeconfig=/etc/ipam-cluster/kubeconfig
+ - --hub-kubeconfig=/etc/kubernetes/federation/auth/kubeconfig
+ env:
+ # The IPClass that hands out identities. Required; a deployment
+ # naming no class refuses to start rather than draw from a default
+ # that would collide with something else's space.
+ - name: IDENTITY_CLASS
+ value: datum-fabric-identity
+ - name: IDENTITY_NAMESPACE
+ value: default
+ # The identity has to be unique across every consumer, so the claim
+ # is written in a project the platform owns rather than in the
+ # consumer's. Required, and deployment-specific.
+ - name: PLATFORM_PROJECT
+ value: ""
+ ports:
+ - name: metrics
+ containerPort: 8080
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 8081
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ resources:
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ limits:
+ memory: 256Mi
+ volumeMounts:
+ - name: ipam-cluster-kubeconfig
+ mountPath: /etc/ipam-cluster
+ readOnly: true
+ - name: federation-kubeconfig
+ mountPath: /etc/kubernetes/federation/auth
+ readOnly: true
+ volumes:
+ # Neither mount is optional. This component reads networks on the hub
+ # and draws identities from IPAM, so it can do nothing at all without
+ # both. A pod waiting in ContainerCreating for a credential that has not
+ # landed yet says so plainly; one started against an empty dir
+ # crashloops until the kubelet's next volume resync swaps the real
+ # secret in, which reads as a broken image rather than a missing secret.
+ #
+ # The hub credential is not what this pod authenticates to its own
+ # cluster with. Leader election runs locally under the ServiceAccount.
+ - name: ipam-cluster-kubeconfig
+ secret:
+ secretName: ipam-cluster-kubeconfig
+ # The hub credential is a client certificate paired with a kubeconfig
+ # naming the Karmada endpoint. They are separate objects because the
+ # certificate is issued per deployment and the endpoint is not, so they
+ # are joined at the mount rather than duplicated into one secret.
+ - name: federation-kubeconfig
+ projected:
+ sources:
+ - secret:
+ name: fabric-identity-federation-client-cert
+ - configMap:
+ name: fabric-identity-federation-kubeconfig
+ terminationGracePeriodSeconds: 10
diff --git a/config/components/fabric-identity/kustomization.yaml b/config/components/fabric-identity/kustomization.yaml
new file mode 100644
index 0000000..82fa7cb
--- /dev/null
+++ b/config/components/fabric-identity/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/fabric-identity/metrics_service.yaml b/config/components/fabric-identity/metrics_service.yaml
new file mode 100644
index 0000000..fd7cfbf
--- /dev/null
+++ b/config/components/fabric-identity/metrics_service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: fabric-identity-metrics
+ namespace: system
+ labels:
+ app.kubernetes.io/name: fabric-identity-controller
+ app.kubernetes.io/component: fabric-identity-controller
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ ports:
+ - name: metrics
+ port: 8080
+ protocol: TCP
+ targetPort: metrics
+ selector:
+ app.kubernetes.io/name: fabric-identity-controller
diff --git a/config/crd/cloud.datumapis.com_networkfabricidentities.yaml b/config/crd/cloud.datumapis.com_networkfabricidentities.yaml
new file mode 100644
index 0000000..f372803
--- /dev/null
+++ b/config/crd/cloud.datumapis.com_networkfabricidentities.yaml
@@ -0,0 +1,116 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.18.0
+ name: networkfabricidentities.cloud.datumapis.com
+spec:
+ group: cloud.datumapis.com
+ names:
+ kind: NetworkFabricIdentity
+ listKind: NetworkFabricIdentityList
+ plural: networkfabricidentities
+ singular: networkfabricidentity
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.identity
+ name: Identity
+ type: integer
+ - jsonPath: .spec.networkRef.name
+ name: Network
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ NetworkFabricIdentity tells a location what identity the fabric knows a
+ network by.
+
+ There is one per network, not one per location. A VPC is the network's
+ realization at a single location and takes its identity from here, which is
+ what makes the locations of one network the same network on the fabric
+ instead of unrelated ones that happen to share a name.
+
+ This is platform-internal. It is written centrally and carried to the cells
+ where the network is required; it never appears in a project control plane
+ and no consumer reads or writes one. The identity is a value the fabric acts
+ on directly, so it is kept to the platform rather than published beside the
+ network it belongs to.
+
+ This object is managed for you. It follows the Network it was allocated for.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ Spec is the whole of this object. There is no status: federation carries
+ configuration to a cell and deliberately does not carry status, so
+ anything a cell has to read has to be here.
+ properties:
+ identity:
+ description: |-
+ Identity is what the fabric knows the network by, the same in every
+ location the network reaches. The Route Target is derived from it, which
+ is what makes two locations of one network import each other's routes
+ rather than behave as two networks that share a name. The VRF device is
+ named from it for the same reason.
+
+ It is an integer rather than an encoded string because a consumer builds
+ `ASN:` from it and encodes it for its own use. It is 32 bits
+ wide because that is what survives into the Route Target: the fabric
+ truncates, so a wider value would be uniqueness the platform believes it
+ has and the fabric does not.
+
+ It is never zero and never changes. The fabric embeds it in import policy
+ in every location the network reaches, so a network that changed identity
+ would be a different network to everything already carrying its traffic.
+ format: int64
+ maximum: 4294967295
+ minimum: 1
+ type: integer
+ x-kubernetes-validations:
+ - message: identity is immutable
+ rule: self == oldSelf
+ networkRef:
+ description: |-
+ NetworkRef names the network this identity belongs to.
+
+ It carries a name and no UID, deliberately. The identity is a permanent
+ property of a name in a namespace, not of one object's lifetime: a
+ network deleted and recreated under the same name inherits it. A UID here
+ would document the opposite of the rule.
+ properties:
+ name:
+ description: Name is the network's name.
+ type: string
+ required:
+ - name
+ type: object
+ required:
+ - identity
+ - networkRef
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 99a5913..bbfc2ec 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -3,3 +3,6 @@ kind: Kustomization
resources:
- cloud.datumapis.com_vpcs.yaml
- cloud.datumapis.com_vpcattachments.yaml
+ # Written centrally, federated to the cells that need it, so it is installed
+ # both places.
+ - cloud.datumapis.com_networkfabricidentities.yaml
diff --git a/config/fabric-identity/kustomization.yaml b/config/fabric-identity/kustomization.yaml
new file mode 100644
index 0000000..a69dd5a
--- /dev/null
+++ b/config/fabric-identity/kustomization.yaml
@@ -0,0 +1,30 @@
+# The central allocator of a network's fabric identity. It is its own overlay
+# because it does not run where the cell manager runs: a network spans
+# locations, so the one value that has to be the same in all of them cannot be
+# decided in any one of them.
+#
+# The manager runs against the cluster it is scheduled on and reaches the
+# federation hub as a second cluster. Everything it reads and writes is on the
+# hub: the Network and its NetworkContexts, both mirrored there by the network
+# operator, and the identity and its placement. Only the leader election lease
+# is local, so that an unreachable hub costs this component its work rather than
+# its identity.
+#
+# RBAC comes from ../rbac unchanged. The repo generates one ClusterRole from
+# every marker under ./internal/..., so both roles this image runs share a role
+# and a ServiceAccount name; splitting them means splitting the generator, and
+# the role is only ever bound in one namespace per deployment.
+#
+# The binding here is what covers the lease and the events, which is everything
+# this component does on the cluster it runs on. The same ClusterRole names what
+# it needs on the hub, and an operator binds it there to the federation
+# credential's identity.
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: fabric-identity-system
+resources:
+ - namespace.yaml
+ - ../crd
+ - ../rbac
+components:
+ - ../components/fabric-identity
diff --git a/config/fabric-identity/namespace.yaml b/config/fabric-identity/namespace.yaml
new file mode 100644
index 0000000..6e9284a
--- /dev/null
+++ b/config/fabric-identity/namespace.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: system
+ labels:
+ app.kubernetes.io/name: fabric-identity-controller
+ app.kubernetes.io/managed-by: kustomize
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index f50e03f..a1a49b5 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -14,6 +14,7 @@ rules:
- apiGroups:
- cloud.datumapis.com
resources:
+ - networkfabricidentities
- vpcattachments
- vpcs
verbs:
@@ -80,6 +81,7 @@ rules:
- networkcontexts
- networkinterfaceclaims
- networkinterfaces
+ - networks
- subnets
verbs:
- get
@@ -94,3 +96,14 @@ rules:
- get
- patch
- update
+- apiGroups:
+ - policy.karmada.io
+ resources:
+ - clusterpropagationpolicies
+ verbs:
+ - create
+ - get
+ - list
+ - patch
+ - update
+ - watch
diff --git a/docs/api/vpc.md b/docs/api/vpc.md
index 6d0764a..d5a70bd 100644
--- a/docs/api/vpc.md
+++ b/docs/api/vpc.md
@@ -9,6 +9,7 @@
Package v1alpha1 contains API Schema definitions for the cloud.datumapis.com/v1alpha1 API group.
### Resource Types
+- [NetworkFabricIdentity](#networkfabricidentity)
- [VPC](#vpc)
- [VPCAttachment](#vpcattachment)
@@ -42,6 +43,75 @@ _Appears in:_
+#### NetworkFabricIdentity
+
+
+
+NetworkFabricIdentity tells a location what identity the fabric knows a
+network by.
+
+There is one per network, not one per location. A VPC is the network's
+realization at a single location and takes its identity from here, which is
+what makes the locations of one network the same network on the fabric
+instead of unrelated ones that happen to share a name.
+
+This is platform-internal. It is written centrally and carried to the cells
+where the network is required; it never appears in a project control plane
+and no consumer reads or writes one. The identity is a value the fabric acts
+on directly, so it is kept to the platform rather than published beside the
+network it belongs to.
+
+This object is managed for you. It follows the Network it was allocated for.
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `cloud.datumapis.com/v1alpha1` | | |
+| `kind` _string_ | `NetworkFabricIdentity` | | |
+| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | |
+| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[NetworkFabricIdentitySpec](#networkfabricidentityspec)_ | Spec is the whole of this object. There is no status: federation carries
configuration to a cell and deliberately does not carry status, so
anything a cell has to read has to be here. | | |
+
+
+#### NetworkFabricIdentityNetworkRef
+
+
+
+NetworkFabricIdentityNetworkRef identifies the network an identity was
+allocated for.
+
+
+
+_Appears in:_
+- [NetworkFabricIdentitySpec](#networkfabricidentityspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name is the network's name. | | Required: \{\}
|
+
+
+#### NetworkFabricIdentitySpec
+
+
+
+NetworkFabricIdentitySpec carries the identity the fabric knows one network
+by.
+
+
+
+_Appears in:_
+- [NetworkFabricIdentity](#networkfabricidentity)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `identity` _integer_ | Identity is what the fabric knows the network by, the same in every
location the network reaches. The Route Target is derived from it, which
is what makes two locations of one network import each other's routes
rather than behave as two networks that share a name. The VRF device is
named from it for the same reason.
It is an integer rather than an encoded string because a consumer builds
`ASN:` from it and encodes it for its own use. It is 32 bits
wide because that is what survives into the Route Target: the fabric
truncates, so a wider value would be uniqueness the platform believes it
has and the fabric does not.
It is never zero and never changes. The fabric embeds it in import policy
in every location the network reaches, so a network that changed identity
would be a different network to everything already carrying its traffic. | | Maximum: 4.294967295e+09
Minimum: 1
Required: \{\}
|
+| `networkRef` _[NetworkFabricIdentityNetworkRef](#networkfabricidentitynetworkref)_ | NetworkRef names the network this identity belongs to.
It carries a name and no UID, deliberately. The identity is a permanent
property of a name in a namespace, not of one object's lifetime: a
network deleted and recreated under the same name inherits it. A UID here
would document the opposite of the rule. | | Required: \{\}
|
+
+
#### NetworkInterfaceRef
diff --git a/go.mod b/go.mod
index 246faa3..c4857a9 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
go.datum.net/compute v0.8.0-dev.7.0.20260821003916-1a0e4d6443f0
go.datum.net/network v0.0.0-20260819160013-45d0ff9deaee
go.datum.net/network-services-operator v0.26.1-0.20260820201844-f366b960529b
+ go.miloapis.com/ipam v0.3.2-0.20260813184449-4fac0aa96194
k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
@@ -76,3 +77,5 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+
+replace go.miloapis.com/ipam => github.com/milo-os/ipam v0.3.2-0.20260819234259-2f31bea79f62
diff --git a/go.sum b/go.sum
index cd1471a..50ca567 100644
--- a/go.sum
+++ b/go.sum
@@ -83,6 +83,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/milo-os/ipam v0.3.2-0.20260819234259-2f31bea79f62 h1:Tm44p0Fq+Ld2CcQvmvhfB6UigvMSUGG8DRXegF5W43Q=
+github.com/milo-os/ipam v0.3.2-0.20260819234259-2f31bea79f62/go.mod h1:Jj7xg4lJi9psE0+4PuOg/GQOG8rG13h112xYoM994rc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
diff --git a/internal/controller/networkfabricidentity_controller.go b/internal/controller/networkfabricidentity_controller.go
new file mode 100644
index 0000000..9d7897a
--- /dev/null
+++ b/internal/controller/networkfabricidentity_controller.go
@@ -0,0 +1,452 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package controller
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/cluster"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+ "sigs.k8s.io/controller-runtime/pkg/source"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/fabricidentity"
+ "go.datum.net/cloud/internal/ipam"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+const (
+ // fabricIdentityFieldManager owns every placement policy this writes.
+ fabricIdentityFieldManager = "cloud-fabric-identity"
+
+ // FabricIdentityPolicyLabel marks the placement policies this controller
+ // owns, so nothing else is ever rewritten or removed by it.
+ FabricIdentityPolicyLabel = "cloud.datumapis.com/fabric-identity-policy"
+
+ // servingLocationTopologyLabel is the cluster label a cell carries to claim
+ // the location it serves. Placement selects on it.
+ servingLocationTopologyLabel = "topology.datum.net/location"
+
+ // fabricIdentityLocationLabelPrefix marks an identity as required at one
+ // location. It follows the per-location convention the existing policies
+ // already select on, with the location in the key rather than the value: a
+ // label key holds one value, and one network is required in several
+ // locations at once.
+ fabricIdentityLocationLabelPrefix = "cloud.datumapis.com/location-"
+)
+
+// LocationLabel is the label marking an identity as required at one location.
+func LocationLabel(location string) string {
+ return fabricIdentityLocationLabelPrefix + location
+}
+
+var clusterPropagationPolicyGVK = schema.GroupVersionKind{
+ Group: "policy.karmada.io",
+ Version: "v1alpha1",
+ Kind: "ClusterPropagationPolicy",
+}
+
+// NetworkFabricIdentityReconciler gives each network the identity the fabric
+// knows it by, once, and carries it to the locations that need it.
+//
+// It runs centrally, not in a cell. A network spans locations, so the one thing
+// that must be the same in all of them cannot be decided in any one of them —
+// which is exactly the defect this replaces, where each location drew its own
+// value and two locations of one network were two networks on the fabric.
+type NetworkFabricIdentityReconciler struct {
+ // Networks reads the Networks identities are allocated for, and the
+ // NetworkContexts saying where each one is required.
+ //
+ // Both arrive on the hub as copies published by the operator that owns
+ // them, because reaching every consumer's project control plane needs a
+ // multi-cluster provider this component does not have. A copy is a
+ // different object from its source and does not carry the source's UID, so
+ // nothing here reads one.
+ //
+ // Kept separate from Hub even though a deployment points both at the hub:
+ // it is what lets a failed read be exercised on its own, and every
+ // withdrawal in this controller is conditioned on a read having succeeded.
+ Networks client.Client
+
+ // Hub is where the identity and its placement are written, and from where
+ // federation carries them to the cells.
+ Hub client.Client
+
+ // HubCluster is the hub as a cluster rather than a client, and is what the
+ // watches are sourced from.
+ //
+ // The manager itself runs against the cluster this is scheduled on, not
+ // against the hub. Leases on the hub would put a constant write load on a
+ // control plane everything else federates through, and would tie this
+ // component's leadership to that plane being reachable: a hub hiccup would
+ // churn leadership and restart the controller. Keeping the lease local
+ // means an unreachable hub costs this component its work, which cannot be
+ // avoided, and not its identity, which can.
+ HubCluster cluster.Cluster
+
+ // IPAM reaches the identifier space.
+ IPAM ipam.ClientFactory
+
+ // IdentityClass is the IPClass that hands out identities.
+ IdentityClass string
+
+ // IdentityNamespace is the namespace in the platform's own tenancy that
+ // identity claims are written to.
+ IdentityNamespace string
+}
+
+// Reconcile is keyed by a network. The network decides whether the identity
+// exists at all; its NetworkContexts decide only where that identity is
+// carried.
+//
+// Reading the network is what separates "deleted" from "required nowhere". A
+// network that exists but has no context anywhere keeps its identity: it is a
+// network the consumer still has, and the next context to appear must find the
+// value the fabric already knows it by rather than wait for one to be drawn
+// again.
+func (r *NetworkFabricIdentityReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ network := &networkingv1alpha.Network{}
+ err := r.Networks.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, network)
+ switch {
+ case apierrors.IsNotFound(err):
+ // The one thing that collects an identity.
+ return ctrl.Result{}, r.collect(ctx, req.Namespace, req.Name)
+ case err != nil:
+ // A read that failed says nothing about whether the network is still
+ // there. Changing nothing is the only safe answer.
+ return ctrl.Result{}, err
+ }
+
+ // A network on its way out still has an identity, and deliberately keeps it
+ // until it is actually gone. The VRF device is named from the identity and
+ // the Route Target derives from it, so tearing a network down needs it as
+ // much as standing it up did; collecting on the deletion timestamp would
+ // pull it out from under a teardown still in flight. The cost is that a
+ // network wedged on a stuck finalizer holds its object indefinitely, which
+ // is a leak, and a leak is recoverable where a torn-down data plane is not.
+
+ presences, err := r.presences(ctx, req.Namespace, req.Name)
+ if err != nil {
+ // A list that failed says nothing about where the network is required.
+ return ctrl.Result{}, err
+ }
+
+ locations := locationsFrom(presences)
+
+ if err := r.publish(ctx, req.Namespace, req.Name, locations); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ return ctrl.Result{}, r.placeLocations(ctx, locations)
+}
+
+// presences reads the contexts declaring the network is required somewhere.
+func (r *NetworkFabricIdentityReconciler) presences(
+ ctx context.Context,
+ namespace string,
+ networkName string,
+) ([]networkingv1alpha.NetworkContext, error) {
+ var contexts networkingv1alpha.NetworkContextList
+ if err := r.Networks.List(ctx, &contexts, client.InNamespace(namespace)); err != nil {
+ return nil, fmt.Errorf("read the presences of network %q: %w", networkName, err)
+ }
+
+ matching := make([]networkingv1alpha.NetworkContext, 0, len(contexts.Items))
+ for i := range contexts.Items {
+ presence := &contexts.Items[i]
+ if presence.Spec.Network.Name != networkName {
+ continue
+ }
+ // A presence on its way out is still a presence. The location keeps the
+ // identity until the context is actually gone, because the traffic it
+ // carries is still there while it drains.
+ matching = append(matching, *presence)
+ }
+ return matching, nil
+}
+
+func locationsFrom(presences []networkingv1alpha.NetworkContext) []string {
+ seen := map[string]struct{}{}
+ locations := make([]string, 0, len(presences))
+ for i := range presences {
+ location := presences[i].Spec.Location.Name
+ if location == "" {
+ continue
+ }
+ if _, ok := seen[location]; ok {
+ continue
+ }
+ seen[location] = struct{}{}
+ locations = append(locations, location)
+ }
+ sort.Strings(locations)
+ return locations
+}
+
+// publish allocates the identity if it does not have one yet, and marks it as
+// required at each location the network reaches.
+func (r *NetworkFabricIdentityReconciler) publish(
+ ctx context.Context,
+ namespace string,
+ networkName string,
+ locations []string,
+) error {
+ key := client.ObjectKey{Namespace: namespace, Name: networkName}
+
+ published := &cloudv1alpha1.NetworkFabricIdentity{}
+ err := r.Hub.Get(ctx, key, published)
+ if err != nil && !apierrors.IsNotFound(err) {
+ return fmt.Errorf("read the published identity for network %q: %w", networkName, err)
+ }
+
+ identity := published.Spec.Identity
+ if identity == 0 {
+ identity, err = r.claim(ctx, namespace, networkName)
+ if err != nil {
+ return err
+ }
+ }
+
+ object := &cloudv1alpha1.NetworkFabricIdentity{}
+ object.Namespace = key.Namespace
+ object.Name = key.Name
+
+ if _, err := controllerutil.CreateOrUpdate(ctx, r.Hub, object, func() error {
+ // Never moved once set. A network that changed identity would be a
+ // different network to every location already carrying its traffic, and
+ // the API refuses the write in any case.
+ if object.Spec.Identity == 0 {
+ object.Spec.Identity = identity
+ }
+ object.Spec.NetworkRef = cloudv1alpha1.NetworkFabricIdentityNetworkRef{Name: networkName}
+ setLocationLabels(object, locations)
+ return nil
+ }); err != nil {
+ return fmt.Errorf("publish the fabric identity for network %q: %w", networkName, err)
+ }
+ return nil
+}
+
+// setLocationLabels marks the identity as required at exactly these locations.
+//
+// A label is dropped only because a location is absent from a set built from a
+// successful read of the presences. Every failure returns before reaching here,
+// so a context that was briefly unreadable, or a cell that went quiet, never
+// withdraws an identity. That matters because the fabric keys a VRF on it:
+// withdrawing it under live traffic tears the data plane down at that location.
+func setLocationLabels(object *cloudv1alpha1.NetworkFabricIdentity, locations []string) {
+ if object.Labels == nil {
+ object.Labels = map[string]string{}
+ }
+ required := make(map[string]struct{}, len(locations))
+ for _, location := range locations {
+ key := LocationLabel(location)
+ required[key] = struct{}{}
+ object.Labels[key] = "true"
+ }
+ for key := range object.Labels {
+ if !strings.HasPrefix(key, fabricIdentityLocationLabelPrefix) {
+ continue
+ }
+ if _, ok := required[key]; !ok {
+ delete(object.Labels, key)
+ }
+ }
+}
+
+func (r *NetworkFabricIdentityReconciler) claim(
+ ctx context.Context,
+ networkNamespace string,
+ networkName string,
+) (int64, error) {
+ ipamClient, err := r.IPAM.ClientForPlatform()
+ if err != nil {
+ return 0, fmt.Errorf("reach the platform identifier space: %w", err)
+ }
+
+ identity, err := fabricidentity.Claim(ctx, ipamClient, fabricidentity.Request{
+ ClassName: r.IdentityClass,
+ Namespace: r.IdentityNamespace,
+ NetworkNamespace: networkNamespace,
+ NetworkName: networkName,
+ })
+ if err != nil {
+ // An unusable answer is a wait on an operator, not on the service:
+ // retrying reaches the same block. Fail closed either way — a network
+ // given an ambiguous identity is worse than one given none.
+ var unusable *fabricidentity.UnusableError
+ if errors.As(err, &unusable) {
+ log.FromContext(ctx).Error(err, "the identifier space handed out a block no identity can be read from",
+ "network", networkName)
+ }
+ return 0, fmt.Errorf("allocate a fabric identity for network %q: %w", networkName, err)
+ }
+ return identity, nil
+}
+
+// collect removes the published identity once the network it belongs to is
+// gone.
+//
+// The IPAM claim is deliberately not released. A Route Target still installed
+// in a remote location's import policy would silently merge a new network into
+// a dead one's routes, so the allocation is retained forever and the identity
+// is never reissued. Only the object goes; the per-location policies are shared
+// and stay.
+func (r *NetworkFabricIdentityReconciler) collect(
+ ctx context.Context,
+ namespace string,
+ networkName string,
+) error {
+ object := &cloudv1alpha1.NetworkFabricIdentity{}
+ object.Namespace = namespace
+ object.Name = networkName
+
+ if err := r.Hub.Delete(ctx, object); err != nil && !apierrors.IsNotFound(err) {
+ return fmt.Errorf("collect the fabric identity for network %q: %w", networkName, err)
+ }
+ return nil
+}
+
+// placeLocations keeps one policy per location, not one per network.
+//
+// A policy per network would put the policy count on the order of the number of
+// networks, and federation evaluates its policy set against candidate
+// resources, so that cost is paid by everything else propagating through the
+// same hub rather than by this alone. One policy per location selects every
+// identity required there by label, so the object count stays per network while
+// the policy count falls to the number of locations.
+//
+// Placing it fleet-wide is not an option: the identity is capability-like, and
+// what holds it can name a network's forwarding state.
+func (r *NetworkFabricIdentityReconciler) placeLocations(ctx context.Context, locations []string) error {
+ for _, location := range locations {
+ if err := r.placeLocation(ctx, location); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *NetworkFabricIdentityReconciler) placeLocation(ctx context.Context, location string) error {
+ policy := &unstructured.Unstructured{Object: map[string]any{
+ "spec": map[string]any{
+ "conflictResolution": "Overwrite",
+ "resourceSelectors": []any{
+ map[string]any{
+ "apiVersion": cloudv1alpha1.GroupVersion.String(),
+ "kind": "NetworkFabricIdentity",
+ "labelSelector": map[string]any{
+ "matchLabels": map[string]any{
+ LocationLabel(location): "true",
+ },
+ },
+ },
+ },
+ "placement": map[string]any{
+ "clusterAffinity": map[string]any{
+ "labelSelector": map[string]any{
+ "matchLabels": map[string]any{
+ servingLocationTopologyLabel: location,
+ },
+ },
+ },
+ },
+ },
+ }}
+ policy.SetGroupVersionKind(clusterPropagationPolicyGVK)
+ policy.SetName(FabricIdentityPolicyName(location))
+ policy.SetLabels(map[string]string{FabricIdentityPolicyLabel: "true"})
+
+ if err := r.Hub.Patch(ctx, policy, client.Apply, //nolint:staticcheck // SA1019: the typed Apply API needs a generated ApplyConfiguration this unstructured policy has none of
+ client.FieldOwner(fabricIdentityFieldManager), client.ForceOwnership); err != nil {
+ return fmt.Errorf("place fabric identities for location %q: %w", location, err)
+ }
+ return nil
+}
+
+// FabricIdentityPolicyName names the placement for one location.
+func FabricIdentityPolicyName(location string) string {
+ return "cloud-fabric-identity-" + location
+}
+
+// The manager runs locally, so what this ServiceAccount has to be able to do is
+// hold a leader election lease and record events. Everything else is read and
+// written on the hub under the federation credential, and is declared here
+// because this role is also what an operator binds there.
+//
+// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=create;delete;get;list;patch;update;watch
+// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networks;networkcontexts,verbs=get;list;watch
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=networkfabricidentities,verbs=create;delete;get;list;patch;update;watch
+// +kubebuilder:rbac:groups=policy.karmada.io,resources=clusterpropagationpolicies,verbs=create;get;list;patch;update;watch
+
+// SetupWithManager registers the reconciler.
+//
+// The network is the primary object: it decides whether an identity exists.
+// Contexts still have to wake it, because placement follows them and nothing
+// else would carry the identity to a location the network has just reached.
+//
+// Every request is keyed by a network, never by a context, because one
+// network's identity is decided from all of its contexts at once.
+//
+// Both watches are sourced from the hub's cache rather than the manager's own,
+// because that is where the copies live. The manager's cluster holds only the
+// lease.
+func (r *NetworkFabricIdentityReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ if r.IdentityClass == "" {
+ return errors.New("an identifier class is required")
+ }
+ if r.IPAM == nil {
+ return errors.New("an identifier space is required")
+ }
+ if r.HubCluster == nil {
+ return errors.New("a hub to watch is required")
+ }
+
+ hubCache := r.HubCluster.GetCache()
+
+ return ctrl.NewControllerManagedBy(mgr).
+ Named("networkfabricidentity").
+ WatchesRawSource(source.Kind(hubCache, &networkingv1alpha.Network{},
+ &handler.TypedEnqueueRequestForObject[*networkingv1alpha.Network]{})).
+ WatchesRawSource(source.Kind(hubCache, &networkingv1alpha.NetworkContext{},
+ handler.TypedEnqueueRequestsFromMapFunc(
+ func(_ context.Context, presence *networkingv1alpha.NetworkContext) []reconcile.Request {
+ if presence == nil || presence.Spec.Network.Name == "" {
+ return nil
+ }
+ return []reconcile.Request{{NamespacedName: types.NamespacedName{
+ Namespace: presence.Namespace,
+ Name: presence.Spec.Network.Name,
+ }}}
+ }))).
+ Complete(r)
+}
diff --git a/internal/controller/networkfabricidentity_controller_test.go b/internal/controller/networkfabricidentity_controller_test.go
new file mode 100644
index 0000000..bf9df30
--- /dev/null
+++ b/internal/controller/networkfabricidentity_controller_test.go
@@ -0,0 +1,736 @@
+/*
+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"
+ "sort"
+ "strings"
+ "testing"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ "go.miloapis.com/ipam/pkg/ipamerrors"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/fabricidentity"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+const (
+ testNetworkName = "prod"
+ testNamespace = "ns-project"
+ testNetworkUID = "11111111-1111-1111-1111-111111111111"
+ testClass = "datum-fabric-identity"
+)
+
+// fakeIdentityIPAM stands in for the address service. Allocation is synchronous
+// there, so the create response already carries the block.
+type fakeIdentityIPAM struct {
+ client client.Client
+ // next is the index the pool hands out. A correctly provisioned identifier
+ // pool holds its own first block back, because a network handed index zero
+ // reads as one holding no identity at all.
+ next int
+ // created records every claim name the factory was asked to bind.
+ created []string
+ // retained maps an allocation name to the block it still holds after its
+ // claim was deleted under Retain. It is what makes a second claim of the
+ // same name a conflict rather than a fresh allocation.
+ retained map[string]string
+}
+
+func allocationNameFor(claimName string) string { return "alloc-" + claimName }
+
+func newFakeIdentityIPAM(t *testing.T) *fakeIdentityIPAM {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := ipamv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the IPAM scheme: %v", err)
+ }
+
+ f := &fakeIdentityIPAM{next: 1, retained: map[string]string{}}
+ f.client = fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{
+ Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
+ ipClaim, ok := obj.(*ipamv1alpha1.IPClaim)
+ if !ok {
+ return c.Create(ctx, obj, opts...)
+ }
+ f.created = append(f.created, ipClaim.Name)
+
+ // An allocation left behind by a deleted claim blocks the name it
+ // used, which is exactly what retention is for.
+ allocationName := allocationNameFor(ipClaim.Name)
+ if _, held := f.retained[allocationName]; held {
+ return ipamerrors.NewRetainedAllocation(
+ ipamv1alpha1.SchemeGroupVersion.WithResource("ipclaims").GroupResource(),
+ ipClaim.Name, allocationName,
+ "an allocation under this identity already exists, retained by an earlier claim of the same name")
+ }
+
+ index := f.next
+ f.next++
+ ipClaim.Status.Phase = ipamv1alpha1.ClaimBound
+ ipClaim.Status.AllocatedCIDR = blockForIndex(index)
+ return c.Create(ctx, obj, opts...)
+ },
+ }).Build()
+ return f
+}
+
+// blockForIndex renders the block a pool rooted at fc00::/32 hands out for an
+// index, which is what the identity is read back out of.
+func blockForIndex(index int) string {
+ return "fc00:0:" + hex16(index>>16) + ":" + hex16(index&0xffff) + "::/64"
+}
+
+func hex16(v int) string {
+ const digits = "0123456789abcdef"
+ if v == 0 {
+ return "0"
+ }
+ out := ""
+ for v > 0 {
+ out = string(digits[v&0xf]) + out
+ v >>= 4
+ }
+ return out
+}
+
+// release models a claim deleted under Retain: the claim goes, the allocation
+// stays, and the block it holds becomes readable through the allocation.
+func (f *fakeIdentityIPAM) release(t *testing.T, ctx context.Context, claimName string) {
+ t.Helper()
+ var ipClaim ipamv1alpha1.IPClaim
+ if err := f.client.Get(ctx, client.ObjectKey{Namespace: "default", Name: claimName}, &ipClaim); err != nil {
+ t.Fatalf("read the claim being released: %v", err)
+ }
+
+ allocation := &ipamv1alpha1.IPAllocation{}
+ allocation.Namespace = "default"
+ allocation.Name = allocationNameFor(claimName)
+ allocation.Status.AllocatedCIDR = ipClaim.Status.AllocatedCIDR
+ if err := f.client.Create(ctx, allocation); err != nil {
+ t.Fatalf("retain the allocation: %v", err)
+ }
+ if err := f.client.Delete(ctx, &ipClaim); err != nil {
+ t.Fatalf("delete the claim: %v", err)
+ }
+ f.retained[allocation.Name] = ipClaim.Status.AllocatedCIDR
+}
+
+func (f *fakeIdentityIPAM) ClientForPlatform() (client.Client, error) { return f.client, nil }
+func (f *fakeIdentityIPAM) ClientForProject(string) (client.Client, error) {
+ return nil, errors.New("a fabric identity is never drawn from a consumer's project")
+}
+
+type identityFixture struct {
+ t *testing.T
+ ctx context.Context
+ networks client.Client
+ hub client.Client
+ ipam *fakeIdentityIPAM
+ reconciler *NetworkFabricIdentityReconciler
+ network *networkingv1alpha.Network
+}
+
+func newIdentityFixture(t *testing.T, presences ...string) *identityFixture {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ if err := networkingv1alpha.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the networking scheme: %v", err)
+ }
+ if err := cloudv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("build the cloud scheme: %v", err)
+ }
+ scheme.AddKnownTypeWithName(clusterPropagationPolicyGVK, &unstructured.Unstructured{})
+ scheme.AddKnownTypeWithName(clusterPropagationPolicyGVK.GroupVersion().WithKind("ClusterPropagationPolicyList"),
+ &unstructured.UnstructuredList{})
+
+ network := &networkingv1alpha.Network{}
+ network.Namespace = testNamespace
+ network.Name = testNetworkName
+ network.UID = types.UID(testNetworkUID)
+
+ objects := []client.Object{network}
+ for _, location := range presences {
+ presence := &networkingv1alpha.NetworkContext{}
+ presence.Namespace = testNamespace
+ presence.Name = testNetworkName + "-" + location
+ presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: testNetworkName}
+ presence.Spec.Location = networkingv1alpha.LocationReference{Name: location}
+ objects = append(objects, presence)
+ }
+
+ networks := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
+ hub := fake.NewClientBuilder().WithScheme(scheme).Build()
+ ipam := newFakeIdentityIPAM(t)
+
+ return &identityFixture{
+ t: t,
+ ctx: context.Background(),
+ networks: networks,
+ hub: hub,
+ ipam: ipam,
+ reconciler: &NetworkFabricIdentityReconciler{
+ Networks: networks,
+ Hub: hub,
+ IPAM: ipam,
+ IdentityClass: testClass,
+ IdentityNamespace: "default",
+ },
+ network: network,
+ }
+}
+
+func (f *identityFixture) deletePresence(location string) {
+ f.t.Helper()
+ if err := f.networks.Delete(f.ctx, newPresence(location)); err != nil {
+ f.t.Fatalf("delete the presence: %v", err)
+ }
+}
+
+func (f *identityFixture) deleteNetwork() {
+ f.t.Helper()
+ network := &networkingv1alpha.Network{}
+ network.Namespace = testNamespace
+ network.Name = testNetworkName
+ if err := f.networks.Delete(f.ctx, network); err != nil {
+ f.t.Fatalf("delete the network: %v", err)
+ }
+}
+
+// addNetwork declares a second network alongside the fixture's own, with a
+// presence in each location named.
+func (f *identityFixture) addNetwork(name string, locations ...string) {
+ f.t.Helper()
+ network := &networkingv1alpha.Network{}
+ network.Namespace = testNamespace
+ network.Name = name
+ if err := f.networks.Create(f.ctx, network); err != nil {
+ f.t.Fatalf("create network %q: %v", name, err)
+ }
+ for _, location := range locations {
+ presence := &networkingv1alpha.NetworkContext{}
+ presence.Namespace = testNamespace
+ presence.Name = name + "-" + location
+ presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: name}
+ presence.Spec.Location = networkingv1alpha.LocationReference{Name: location}
+ if err := f.networks.Create(f.ctx, presence); err != nil {
+ f.t.Fatalf("declare the presence of %q in %q: %v", name, location, err)
+ }
+ }
+}
+
+func (f *identityFixture) createNetwork() {
+ f.t.Helper()
+ network := &networkingv1alpha.Network{}
+ network.Namespace = testNamespace
+ network.Name = testNetworkName
+ if err := f.networks.Create(f.ctx, network); err != nil {
+ f.t.Fatalf("create the network: %v", err)
+ }
+}
+
+func (f *identityFixture) reconcile() {
+ f.t.Helper()
+ _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{
+ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName},
+ })
+ if err != nil {
+ f.t.Fatalf("reconcile: %v", err)
+ }
+}
+
+func (f *identityFixture) published() (*cloudv1alpha1.NetworkFabricIdentity, bool) {
+ f.t.Helper()
+ var identity cloudv1alpha1.NetworkFabricIdentity
+ err := f.hub.Get(f.ctx, client.ObjectKey{Namespace: testNamespace, Name: testNetworkName}, &identity)
+ if err != nil {
+ return nil, false
+ }
+ return &identity, true
+}
+
+// placement reads the locations off the identity itself, which is what the
+// per-location policies select on.
+func (f *identityFixture) placement() ([]string, bool) {
+ f.t.Helper()
+ identity, ok := f.published()
+ if !ok {
+ return nil, false
+ }
+ locations := make([]string, 0, len(identity.Labels))
+ for key, value := range identity.Labels {
+ if strings.HasPrefix(key, fabricIdentityLocationLabelPrefix) && value == "true" {
+ locations = append(locations, strings.TrimPrefix(key, fabricIdentityLocationLabelPrefix))
+ }
+ }
+ sort.Strings(locations)
+ return locations, len(locations) > 0
+}
+
+// policyFor reads the one policy that carries every identity required at a
+// location. There is one of these per location, not per network.
+func (f *identityFixture) policyFor(location string) (*unstructured.Unstructured, bool) {
+ f.t.Helper()
+ policy := &unstructured.Unstructured{}
+ policy.SetGroupVersionKind(clusterPropagationPolicyGVK)
+ if err := f.hub.Get(f.ctx, client.ObjectKey{Name: FabricIdentityPolicyName(location)}, policy); err != nil {
+ return nil, false
+ }
+ return policy, true
+}
+
+// The identity is published on a cloud object, not on the Network. Nothing a
+// consumer reads carries it.
+func TestIdentityIsPublishedOnItsOwnObject(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ identity, ok := f.published()
+ if !ok {
+ t.Fatal("the identity must be published")
+ }
+ if identity.Spec.Identity == 0 {
+ t.Fatal("a published identity is never zero")
+ }
+ if identity.Spec.NetworkRef.Name != testNetworkName {
+ t.Fatalf("the identity must name the network it belongs to, got %+v", identity.Spec.NetworkRef)
+ }
+ if len(f.ipam.created) != 1 || f.ipam.created[0] != fabricidentity.ClaimName(testNamespace, testNetworkName) {
+ t.Fatalf("the identity must be claimed from the platform tenancy under the network's UID, got %v", f.ipam.created)
+ }
+}
+
+// One network, one identity, however many times it is reconciled.
+func TestIdentityIsAllocatedOnlyOnce(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ first, _ := f.published()
+ for range 3 {
+ f.reconcile()
+ }
+ again, _ := f.published()
+
+ if first.Spec.Identity != again.Spec.Identity {
+ t.Fatalf("the identity moved from %d to %d", first.Spec.Identity, again.Spec.Identity)
+ }
+ if len(f.ipam.created) != 1 {
+ t.Fatalf("a network already holding an identity must not ask for another, got %v", f.ipam.created)
+ }
+}
+
+// Two networks are two identities. One shared between them would make them one
+// network on the fabric: each would import the other's routes.
+func TestEachNetworkGetsItsOwnIdentity(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+ first, _ := f.published()
+
+ f.addNetwork("staging", "us-central-1")
+ if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{
+ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "staging"},
+ }); err != nil {
+ t.Fatalf("reconcile the second network: %v", err)
+ }
+
+ var second cloudv1alpha1.NetworkFabricIdentity
+ if err := f.hub.Get(f.ctx, client.ObjectKey{Namespace: testNamespace, Name: "staging"}, &second); err != nil {
+ t.Fatalf("the second network must be published too: %v", err)
+ }
+ if first.Spec.Identity == second.Spec.Identity {
+ t.Fatal("two networks must not share an identity")
+ }
+}
+
+// Placement follows the presences: the identity reaches exactly the cells
+// backing the network's NetworkContexts, and no others.
+func TestIdentityIsPlacedOnTheCellsTheNetworkIsRequiredIn(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1", "us-east-1")
+ f.reconcile()
+
+ locations, placed := f.placement()
+ if !placed {
+ t.Fatal("a network required somewhere must be placed there")
+ }
+ if len(locations) != 2 || locations[0] != "us-central-1" || locations[1] != "us-east-1" {
+ t.Fatalf("expected both locations, got %v", locations)
+ }
+}
+
+// A network that exists is a network with an identity, whether or not anything
+// has asked for it in a location yet. It is placed nowhere until something
+// does, so no cell carries a value it has no use for, but the value is settled
+// before the first context appears rather than while one is waiting on it.
+func TestANetworkRequiredNowhereStillHasAnIdentity(t *testing.T) {
+ f := newIdentityFixture(t)
+ f.reconcile()
+
+ published, ok := f.published()
+ if !ok {
+ t.Fatal("a network that exists must have an identity")
+ }
+ if published.Spec.Identity == 0 {
+ t.Fatal("an identity of zero reads as a network holding none")
+ }
+ if _, placed := f.placement(); placed {
+ t.Fatal("nothing requires it anywhere, so nothing should carry it")
+ }
+
+ // Again, to prove it settles rather than drawing a second value.
+ f.reconcile()
+ again, ok := f.published()
+ if !ok {
+ t.Fatal("a second pass must not collect it")
+ }
+ if again.Spec.Identity != published.Spec.Identity {
+ t.Fatalf("the identity must not move, got %d then %d", published.Spec.Identity, again.Spec.Identity)
+ }
+ if len(f.ipam.created) != 1 {
+ t.Fatalf("exactly one claim for one network, got %v", f.ipam.created)
+ }
+}
+
+// The last presence going withdraws the placement and nothing else. The
+// network still exists, so it keeps the identity the fabric knows it by, and
+// the next location it reaches is given that value straight away instead of
+// waiting on an allocation.
+func TestLastPresenceWithdrawsThePlacementButKeepsTheIdentity(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ before, ok := f.published()
+ if !ok {
+ t.Fatal("expected an identity to start from")
+ }
+
+ f.deletePresence("us-central-1")
+ f.reconcile()
+
+ kept, ok := f.published()
+ if !ok {
+ t.Fatal("the network still exists, so it must keep its identity")
+ }
+ if _, placed := f.placement(); placed {
+ t.Fatal("nothing requires it anywhere, so nothing should carry it")
+ }
+ if kept.Spec.Identity != before.Spec.Identity {
+ t.Fatalf("the identity must not move, got %d then %d", before.Spec.Identity, kept.Spec.Identity)
+ }
+
+ if err := f.networks.Create(f.ctx, newPresence("us-central-1")); err != nil {
+ t.Fatalf("declare the presence again: %v", err)
+ }
+ f.reconcile()
+
+ after, ok := f.published()
+ if !ok {
+ t.Fatal("the identity must still be there when the network is required again")
+ }
+ if after.Spec.Identity != before.Spec.Identity {
+ t.Fatalf("the same network must keep the same identity, got %d then %d",
+ before.Spec.Identity, after.Spec.Identity)
+ }
+ if len(f.ipam.created) != 1 {
+ t.Fatalf("a presence coming and going must not draw again, got %v", f.ipam.created)
+ }
+}
+
+// The network going is what collects the object. It is the one signal that
+// separates a network that is gone from one that is required nowhere right
+// now, and it is the only thing this acts on.
+//
+// The allocation itself is retained: a Route Target still installed in a remote
+// location would merge a new network into a dead one's routes.
+func TestTheNetworkGoingCollectsTheObjectButNotTheAllocation(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ before, ok := f.published()
+ if !ok {
+ t.Fatal("expected an identity to start from")
+ }
+
+ f.deleteNetwork()
+ f.reconcile()
+
+ if _, ok := f.published(); ok {
+ t.Fatal("the network is gone, so the object should be collected")
+ }
+
+ // A network of the same name comes back. It must come back with the
+ // identity the name always had, because the claim was retained and is named
+ // from the network's namespace and name.
+ f.createNetwork()
+ f.reconcile()
+
+ after, ok := f.published()
+ if !ok {
+ t.Fatal("the identity must be republished when the network comes back")
+ }
+ if after.Spec.Identity != before.Spec.Identity {
+ t.Fatalf("a retained allocation must give back the same identity, got %d then %d",
+ before.Spec.Identity, after.Spec.Identity)
+ }
+}
+
+// A network that cannot be read is not a network that is gone. Nothing may be
+// collected on the strength of a failed read, because collecting takes the
+// value a live VRF is named from with it.
+func TestAnUnreadableNetworkCollectsNothing(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ before, ok := f.published()
+ if !ok {
+ t.Fatal("expected an identity to start from")
+ }
+
+ f.reconciler.Networks = failingGetter{Client: f.networks}
+ if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{
+ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName},
+ }); err == nil {
+ t.Fatal("an unreadable network must be an error, never a collection")
+ }
+
+ f.reconciler.Networks = f.networks
+ after, ok := f.published()
+ if !ok {
+ t.Fatal("a failed read must not collect the identity")
+ }
+ if after.Spec.Identity != before.Spec.Identity {
+ t.Fatalf("the identity must be left exactly as it was, got %d then %d",
+ before.Spec.Identity, after.Spec.Identity)
+ }
+ if _, placed := f.placement(); !placed {
+ t.Fatal("a failed read must not withdraw the placement either")
+ }
+}
+
+// failingGetter stands in for a control plane that cannot answer a read of the
+// network itself. Every get is an error, which is the case a collection must
+// never mistake for "the network is gone".
+type failingGetter struct {
+ client.Client
+}
+
+func (failingGetter) Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error {
+ return errors.New("the control plane is unreachable")
+}
+
+func newPresence(location string) *networkingv1alpha.NetworkContext {
+ presence := &networkingv1alpha.NetworkContext{}
+ presence.Namespace = testNamespace
+ presence.Name = testNetworkName + "-" + location
+ presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: testNetworkName}
+ presence.Spec.Location = networkingv1alpha.LocationReference{Name: location}
+ return presence
+}
+
+// One policy per location, selecting every identity required there. The policy
+// count is the number of locations, not the number of networks.
+func TestOnePolicyPerLocationCarriesEveryIdentityRequiredThere(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1", "us-east-1")
+ f.reconcile()
+
+ for _, location := range []string{"us-central-1", "us-east-1"} {
+ policy, ok := f.policyFor(location)
+ if !ok {
+ t.Fatalf("expected a policy for %q", location)
+ }
+
+ selectors, _, err := unstructured.NestedSlice(policy.Object, "spec", "resourceSelectors")
+ if err != nil || len(selectors) != 1 {
+ t.Fatalf("expected one resource selector, got %v (%v)", selectors, err)
+ }
+ entry, _ := selectors[0].(map[string]any)
+ labels, _, _ := unstructured.NestedStringMap(entry, "labelSelector", "matchLabels")
+ if labels[LocationLabel(location)] != "true" {
+ t.Fatalf("the policy for %q must select identities required there, got %v", location, labels)
+ }
+
+ placement, _, _ := unstructured.NestedStringMap(policy.Object,
+ "spec", "placement", "clusterAffinity", "labelSelector", "matchLabels")
+ if placement[servingLocationTopologyLabel] != location {
+ t.Fatalf("the policy for %q must place on the cell serving it, got %v", location, placement)
+ }
+ }
+
+ // A second network in the same location reuses the same policy rather than
+ // adding one.
+ f.addNetwork("staging", "us-central-1")
+ if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{
+ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "staging"},
+ }); err != nil {
+ t.Fatalf("reconcile the second network: %v", err)
+ }
+
+ var policies unstructured.UnstructuredList
+ policies.SetGroupVersionKind(clusterPropagationPolicyGVK.GroupVersion().WithKind("ClusterPropagationPolicyList"))
+ if err := f.hub.List(f.ctx, &policies); err != nil {
+ t.Fatalf("list policies: %v", err)
+ }
+ if len(policies.Items) != 2 {
+ t.Fatalf("two locations must need two policies however many networks there are, got %d", len(policies.Items))
+ }
+}
+
+// Withdrawal is what tears a data plane down, so it happens only when a
+// presence is positively observed to be gone.
+func TestPlacementShrinksOnlyOnAnObservedDeletion(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1", "us-east-1")
+ f.reconcile()
+
+ f.deletePresence("us-east-1")
+ f.reconcile()
+
+ locations, placed := f.placement()
+ if !placed {
+ t.Fatal("the placement must survive one presence going")
+ }
+ if len(locations) != 1 || locations[0] != "us-central-1" {
+ t.Fatalf("a presence that is actually gone withdraws only that cell, got %v", locations)
+ }
+}
+
+// A read that failed says nothing about where the network is required. The
+// placement already in force must survive it untouched.
+func TestPlacementSurvivesAnUnreadablePresence(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1", "us-east-1")
+ f.reconcile()
+
+ before, placed := f.placement()
+ if !placed {
+ t.Fatal("expected a placement to start from")
+ }
+
+ f.reconciler.Networks = failingLister{Client: f.networks}
+ if _, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{
+ NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testNetworkName},
+ }); err == nil {
+ t.Fatal("an unreadable presence must be an error, never a withdrawal")
+ }
+
+ f.reconciler.Networks = f.networks
+ after, placed := f.placement()
+ if !placed {
+ t.Fatal("the placement must not be withdrawn by a failed read")
+ }
+ if len(after) != len(before) || after[0] != before[0] || after[1] != before[1] {
+ t.Fatalf("the placement must be left exactly as it was: %v became %v", before, after)
+ }
+}
+
+// failingLister stands in for a control plane that cannot answer. Every list is
+// an error, which is the case a placement must never mistake for "the network
+// is required nowhere".
+type failingLister struct {
+ client.Client
+}
+
+func (failingLister) List(context.Context, client.ObjectList, ...client.ListOption) error {
+ return errors.New("the control plane is unreachable")
+}
+
+// A claim whose allocation was retained comes back through the conflict, not
+// through a fresh allocation. This is a different path from the collection
+// round trip: there the claim still existed and was read back, here the claim
+// is gone and only the retained allocation remains.
+//
+// Retention is what stops a released identifier returning to the pool, where
+// the allocator could hand it to any network at all. The cost is that a network
+// recreated under the same name in the same namespace inherits its predecessor's
+// identity, which is confined to one name in one namespace.
+func TestARetainedAllocationIsAdoptedRatherThanReallocated(t *testing.T) {
+ f := newIdentityFixture(t, "us-central-1")
+ f.reconcile()
+
+ before, ok := f.published()
+ if !ok {
+ t.Fatal("expected an identity to start from")
+ }
+
+ // The network goes: its object is collected, and its claim is released
+ // under Retain, which leaves the allocation behind.
+ claimName := fabricidentity.ClaimName(testNamespace, testNetworkName)
+ f.ipam.release(t, f.ctx, claimName)
+
+ f.deleteNetwork()
+ f.reconcile()
+ if _, ok := f.published(); ok {
+ t.Fatal("the network is gone, so the object should be collected")
+ }
+
+ // A network of the same name comes back. The claim no longer exists, so the
+ // allocate path runs and must hit the retained allocation.
+ claimsBefore := len(f.ipam.created)
+ f.createNetwork()
+ f.reconcile()
+
+ after, ok := f.published()
+ if !ok {
+ t.Fatal("the identity must be republished")
+ }
+ if after.Spec.Identity != before.Spec.Identity {
+ t.Fatalf("a retained allocation must be adopted, got %d where %d was held",
+ after.Spec.Identity, before.Spec.Identity)
+ }
+ if len(f.ipam.created) != claimsBefore+1 {
+ t.Fatalf("the adopt path runs through a refused create, got %d creates then %d",
+ claimsBefore, len(f.ipam.created))
+ }
+
+ // And the identity that came back is not merely the next one the pool would
+ // have handed out.
+ if next := blockForIndex(f.ipam.next); next == "" {
+ t.Fatal("unreachable")
+ }
+ fresh, err := fabricidentity.FromBlock(blockForIndex(f.ipam.next))
+ if err != nil {
+ t.Fatalf("read the next free block: %v", err)
+ }
+ if after.Spec.Identity == fresh {
+ t.Fatal("the identity must come from the retained allocation, not from a fresh one")
+ }
+}
+
+// The watches are sourced from the hub's cache, not the manager's own, because
+// the manager runs against the cluster this is scheduled on and only the leader
+// election lease lives there. Wired without a hub, the controller would come up
+// watching a plane that holds none of the objects it exists for and would sit
+// idle rather than fail, so setup refuses instead.
+func TestSetupRefusesWithoutAHubToWatch(t *testing.T) {
+ reconciler := &NetworkFabricIdentityReconciler{
+ IPAM: newFakeIdentityIPAM(t),
+ IdentityClass: testClass,
+ }
+
+ if err := reconciler.SetupWithManager(nil); err == nil {
+ t.Fatal("a reconciler with no hub to watch must refuse to start")
+ }
+}
diff --git a/internal/fabricidentity/identity.go b/internal/fabricidentity/identity.go
new file mode 100644
index 0000000..506a030
--- /dev/null
+++ b/internal/fabricidentity/identity.go
@@ -0,0 +1,219 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package fabricidentity allocates the identity the fabric knows a network by.
+//
+// The platform's address service allocates prefixes, not integers. Rather than
+// build a second allocator with the same uniqueness and concurrency problems
+// already solved there, an identity is allocated as a block from a pool that is
+// never routed, and the integer is the block's index within that pool. A /32
+// root handing out /64s yields exactly 2^32 allocations whose distinguishing
+// bits are exactly the 32 the fabric uses.
+//
+// That buys uniqueness, exhaustion accounting, quota and an audit trail, and
+// costs address space that is never routed and never reachable.
+package fabricidentity
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "net/netip"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ "go.miloapis.com/ipam/pkg/ipamerrors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/utils/ptr"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const (
+ // BlockBits is the size of the block an identity is read out of. The pool
+ // roots a /32 and hands out /64s, so the bits between them are the block's
+ // index within the pool, and there are exactly 2^32 of them.
+ BlockBits = 64
+
+ // RootBits is where the pool's own prefix stops and the index begins.
+ // Reading the index from a fixed offset rather than from the pool's CIDR
+ // means the identity does not depend on an object this would otherwise have
+ // to read on every allocation. A pool rooted longer than a /32 leaves the
+ // leading bits of every index at zero, which is still unique within the one
+ // pool the platform allocates from.
+ RootBits = 32
+)
+
+// Request names one network's claim on the identifier space.
+type Request struct {
+ // ClassName is the IPClass that hands out identities.
+ ClassName string
+
+ // Namespace is the namespace in the platform's own tenancy the claim is
+ // written to.
+ Namespace string
+
+ // NetworkNamespace and NetworkName identify the network the identity is
+ // for. The claim is named from the pair, so an identity is a permanent
+ // property of that name in that namespace.
+ //
+ // A network deleted and recreated under the same name in the same namespace
+ // therefore inherits the identity it had before. That is deliberate: the
+ // alternative is releasing identifiers back to the pool, where the
+ // allocator could hand one to any network at all. A Route Target still
+ // installed in a remote location's import policy would then merge an
+ // unrelated network into a dead one's routes. Inheritance is confined to
+ // one namespace and one name; reissue is not confined to anything.
+ NetworkNamespace string
+ NetworkName string
+}
+
+// maxClaimNameLength is the ceiling on an object name in Kubernetes, which is
+// all an IPClaim name is.
+const maxClaimNameLength = 253
+
+// ClaimName is the name the request's claim is held under.
+//
+// The delimiter is a dot rather than a dash because a namespace is a DNS label
+// and cannot contain one, so the first dot after the prefix always ends the
+// namespace. A dash would be ambiguous: namespace "a-b" with name "c" and
+// namespace "a" with name "b-c" would collide.
+//
+// Uniqueness rests on the API server refusing a namespace containing a dot. A
+// network name may contain one, and that is fine: the split is taken at the
+// first dot, which is always the namespace boundary.
+func ClaimName(networkNamespace, networkName string) string {
+ name := fmt.Sprintf("fabric-identity.%s.%s", networkNamespace, networkName)
+ if len(name) <= maxClaimNameLength {
+ return name
+ }
+
+ sum := sha256.Sum256([]byte(networkNamespace + "/" + networkName))
+ suffix := "." + hex.EncodeToString(sum[:])[:16]
+ return name[:maxClaimNameLength-len(suffix)] + suffix
+}
+
+// Claim holds one block of the identifier space and reads the identity out of
+// it.
+//
+// IPAM binds on create and refuses a duplicate name, so the read comes first.
+// That is what makes the allocation idempotent without this recording anything
+// of its own: the claim is the record.
+func Claim(ctx context.Context, ipamClient client.Client, request Request) (int64, error) {
+ ipClaim := &ipamv1alpha1.IPClaim{}
+ ipClaim.Namespace = request.Namespace
+ ipClaim.Name = ClaimName(request.NetworkNamespace, request.NetworkName)
+ ipClaim.Spec = ipamv1alpha1.IPClaimSpec{
+ ClassName: request.ClassName,
+ Target: ipamv1alpha1.TargetBlock,
+ PrefixLength: ptr.To(int32(BlockBits)),
+
+ // An identity is never given back. A Route Target still installed in a
+ // remote location's import policy would silently merge a new network
+ // into a dead one's routes, so holding the block forever is the safe
+ // failure and reissuing it is not a failure anything can see.
+ ReclaimPolicy: ipamv1alpha1.ReclaimRetain,
+ }
+
+ existing := &ipamv1alpha1.IPClaim{}
+ getErr := ipamClient.Get(ctx, client.ObjectKeyFromObject(ipClaim), existing)
+ if getErr != nil && !apierrors.IsNotFound(getErr) {
+ return 0, fmt.Errorf("read the identity claim %q: %w", ipClaim.Name, getErr)
+ }
+
+ if getErr == nil {
+ ipClaim = existing
+ } else if createErr := ipamClient.Create(ctx, ipClaim); createErr != nil {
+ // An allocation retained by an earlier claim of this name is this
+ // network's own identity, kept precisely so it could not be handed to
+ // another network. Adopt it rather than treating it as a failure.
+ if allocationName, retained := ipamerrors.RetainedAllocation(createErr); retained {
+ return adopt(ctx, ipamClient, request.Namespace, allocationName)
+ }
+
+ // The create can still lose a race with another writer, so ask again
+ // before calling this a failure to allocate.
+ raced := &ipamv1alpha1.IPClaim{}
+ if err := ipamClient.Get(ctx, client.ObjectKeyFromObject(ipClaim), raced); err != nil {
+ return 0, fmt.Errorf("claim a fabric identity: %w", createErr)
+ }
+ ipClaim = raced
+ }
+
+ if ipClaim.Status.AllocatedCIDR == "" {
+ return 0, fmt.Errorf("the identity space allocated nothing for this network (phase %q)", ipClaim.Status.Phase)
+ }
+
+ return FromBlock(ipClaim.Status.AllocatedCIDR)
+}
+
+// adopt reads the identity out of an allocation this network already holds.
+// The allocation outlives the claim that made it, which is what retention is
+// for, so the block it names is the same one this network has always had.
+func adopt(ctx context.Context, ipamClient client.Client, namespace, allocationName string) (int64, error) {
+ allocation := &ipamv1alpha1.IPAllocation{}
+ if err := ipamClient.Get(ctx,
+ client.ObjectKey{Namespace: namespace, Name: allocationName}, allocation); err != nil {
+ return 0, fmt.Errorf("read the retained allocation %q: %w", allocationName, err)
+ }
+ if allocation.Status.AllocatedCIDR == "" {
+ return 0, fmt.Errorf("the retained allocation %q holds no block", allocationName)
+ }
+ return FromBlock(allocation.Status.AllocatedCIDR)
+}
+
+// FromBlock reads the identity out of the block the identifier space handed
+// out. The block's index within the pool is the identity, and the index is the
+// 32 bits between the pool's root and the block, which is exactly the width
+// that survives into the Route Target.
+func FromBlock(cidr string) (int64, error) {
+ prefix, err := netip.ParsePrefix(cidr)
+ if err != nil {
+ return 0, &UnusableError{message: fmt.Sprintf(
+ "the identity space answered with %q, which is not a prefix", cidr)}
+ }
+
+ address := prefix.Addr()
+ if !address.Is6() || address.Is4In6() {
+ return 0, &UnusableError{message: fmt.Sprintf(
+ "the identity space answered with %q; identifiers are read out of an IPv6 space", cidr)}
+ }
+
+ // Shorter than a /64 and two networks could be handed blocks that share an
+ // index; longer and one block's index is not the whole of it.
+ if prefix.Bits() != BlockBits {
+ return 0, &UnusableError{message: fmt.Sprintf(
+ "the identity space answered with %q; identifiers are read out of a /%d", cidr, BlockBits)}
+ }
+
+ octets := address.As16()
+ identity := int64(binary.BigEndian.Uint32(octets[RootBits/8 : BlockBits/8]))
+ if identity == 0 {
+ return 0, &UnusableError{message: fmt.Sprintf(
+ "the identity space answered with %q, whose index is zero; zero is what a network holding no identity reads as, so the pool must not hand out its first block", cidr)}
+ }
+ return identity, nil
+}
+
+// UnusableError says the identity space answered, and its answer cannot be
+// turned into an identity. Retrying reaches the same block, so this is a wait
+// on an operator rather than on the service.
+type UnusableError struct {
+ message string
+}
+
+func (e *UnusableError) Error() string { return e.message }
diff --git a/internal/fabricidentity/identity_test.go b/internal/fabricidentity/identity_test.go
new file mode 100644
index 0000000..0e45974
--- /dev/null
+++ b/internal/fabricidentity/identity_test.go
@@ -0,0 +1,134 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package fabricidentity
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+// The identity is the block's index within the pool, which is the 32 bits
+// between the pool's root and the block, which is exactly the width that
+// survives into the Route Target.
+func TestFromBlockReadsTheIndexOutOfTheBlock(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cidr string
+ want int64
+ wants string
+ }{
+ {name: "the first usable block", cidr: "fc00:0:0:1::/64", want: 1},
+ {name: "an index spanning both halves", cidr: "fc00:0:1234:5678::/64", want: 0x12345678},
+ {name: "the last block in the pool", cidr: "fc00:0:ffff:ffff::/64", want: 0xffffffff},
+ {name: "a pool rooted longer than a /32", cidr: "fc00:0:0:beef::/64", want: 0xbeef},
+
+ {name: "the pool's own zero block", cidr: "fc00::/64", wants: "index is zero"},
+ {name: "a block wider than a /64", cidr: "fc00:0:0:1::/48", wants: "read out of a /64"},
+ {name: "a block narrower than a /64", cidr: "fc00:0:0:1::/96", wants: "read out of a /64"},
+ {name: "an IPv4 block", cidr: "10.0.0.0/24", wants: "read out of an IPv6 space"},
+ {name: "not a prefix at all", cidr: "fc00::", wants: "not a prefix"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ identity, err := FromBlock(tc.cidr)
+ if tc.wants != "" {
+ if err == nil {
+ t.Fatalf("expected a refusal, got identity %d", identity)
+ }
+ if !contains(err.Error(), tc.wants) {
+ t.Fatalf("expected the refusal to mention %q, got %q", tc.wants, err.Error())
+ }
+ var unusable *UnusableError
+ if !asUnusable(err, &unusable) {
+ t.Fatalf("a bad block must be an UnusableError so the caller can tell it from an outage")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity != tc.want {
+ t.Fatalf("expected identity %d, got %d", tc.want, identity)
+ }
+ // A value needing more than 32 bits would be uniqueness the platform
+ // believes it has and the Route Target does not.
+ if identity > 0xffffffff {
+ t.Fatalf("identity %d does not fit the 32 bits the fabric carries", identity)
+ }
+ })
+ }
+}
+
+// The claim is named from the network's namespace and name, which is what makes
+// the identity a permanent property of that pair and makes allocation idempotent
+// without recording anything of its own.
+func TestClaimNameIsStableAndCollisionFree(t *testing.T) {
+ namespace, name := "ns", "prod"
+ if ClaimName(namespace, name) != ClaimName("ns", "prod") {
+ t.Fatal("the same network must reach the same claim")
+ }
+ if ClaimName("ns", "prod") == ClaimName("ns", "staging") {
+ t.Fatal("two networks in a namespace must reach different claims")
+ }
+ if ClaimName("a", "b") == ClaimName("b", "a") {
+ t.Fatal("namespace and name must not be interchangeable")
+ }
+
+ // A dash delimiter would collide here: both would render "...a-b-c". A
+ // namespace is a DNS label and cannot contain a dot, so the first dot after
+ // the prefix always ends it.
+ if ClaimName("a-b", "c") == ClaimName("a", "b-c") {
+ t.Fatal("the delimiter must not be ambiguous")
+ }
+
+ // A network name may contain dots; a namespace may not. Uniqueness rests on
+ // that: "ns.a" is not a namespace the API server will accept, so the pair
+ // that would collide with ("ns", "a.b") cannot exist. Asserted here so the
+ // dependency is recorded rather than assumed.
+ if strings.Contains("ns", ".") {
+ t.Fatal("a namespace is a DNS label and cannot contain a dot")
+ }
+ if ClaimName("ns", "a.b") == ClaimName("ns", "a-b") {
+ t.Fatal("two names in one namespace must reach different claims")
+ }
+
+ long := ClaimName(strings.Repeat("n", 200), strings.Repeat("p", 200))
+ if len(long) > maxClaimNameLength {
+ t.Fatalf("an over-long pair must still yield a usable name, got %d characters", len(long))
+ }
+ if long == ClaimName(strings.Repeat("n", 200), strings.Repeat("q", 200)) {
+ t.Fatal("two truncated names must still differ")
+ }
+}
+
+func contains(haystack, needle string) bool {
+ return len(needle) == 0 || (len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0)
+}
+
+func indexOf(haystack, needle string) int {
+ for i := 0; i+len(needle) <= len(haystack); i++ {
+ if haystack[i:i+len(needle)] == needle {
+ return i
+ }
+ }
+ return -1
+}
+
+func asUnusable(err error, target **UnusableError) bool {
+ return errors.As(err, target)
+}
diff --git a/internal/ipam/client.go b/internal/ipam/client.go
new file mode 100644
index 0000000..c7ed84b
--- /dev/null
+++ b/internal/ipam/client.go
@@ -0,0 +1,159 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package ipam reaches the platform's address management service.
+//
+// Every request names the tenancy it is made for, so nothing can allocate
+// without saying on whose behalf. Two tenancies exist: a consumer's own
+// project, and the platform itself.
+package ipam
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "sync"
+
+ ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/rest"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// resourceManagerGroup is the API group whose project path addresses one
+// project's control plane. It is a constant rather than an import so that
+// reaching IPAM does not pull the whole resource manager API surface in for the
+// sake of one string.
+const resourceManagerGroup = "resourcemanager.miloapis.com"
+
+// ClientFactory returns a client bound to one tenancy.
+type ClientFactory interface {
+ // ClientForProject reaches IPAM on a consumer's behalf, inside their own
+ // project. What it allocates is theirs, counts against their quota, and is
+ // unique only among their own allocations.
+ ClientForProject(project string) (client.Client, error)
+
+ // ClientForPlatform reaches IPAM on the platform's own behalf, for values
+ // that must be unique across every consumer and must not be gated on one
+ // enabling the address service or draw on their quota.
+ //
+ // A network's fabric identity is the case this exists for: uniqueness per
+ // project is not uniqueness, and a consumer never asked for the value and
+ // cannot see it.
+ //
+ // IPAM has no platform tenancy of its own, so today this is one project
+ // control plane the platform owns. The seam is here so that when it gains
+ // one, nothing above this line changes.
+ ClientForPlatform() (client.Client, error)
+}
+
+// NewClientFactory builds tenancy-scoped clients from one connection. The
+// clients are uncached, because a cache would watch every project served.
+//
+// platformProject names the control plane platform-owned allocations are made
+// in. Empty means the deployment allocates nothing platform-scoped.
+func NewClientFactory(base *rest.Config, scheme *runtime.Scheme, platformProject string) (ClientFactory, error) {
+ if base == nil {
+ return nil, errors.New("a rest config is required")
+ }
+ return &projectPathClientFactory{
+ base: base,
+ scheme: scheme,
+ platformProject: platformProject,
+ clients: map[string]client.Client{},
+ }, nil
+}
+
+type projectPathClientFactory struct {
+ base *rest.Config
+ scheme *runtime.Scheme
+ platformProject string
+
+ mu sync.Mutex
+ clients map[string]client.Client
+}
+
+func (f *projectPathClientFactory) ClientForPlatform() (client.Client, error) {
+ if f.platformProject == "" {
+ return nil, ErrNoPlatformTenancy
+ }
+ return f.ClientForProject(f.platformProject)
+}
+
+func (f *projectPathClientFactory) ClientForProject(project string) (client.Client, error) {
+ if project == "" {
+ return nil, ErrNoProject
+ }
+
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if existing, ok := f.clients[project]; ok {
+ return existing, nil
+ }
+
+ cfg, err := f.configForProject(project)
+ if err != nil {
+ return nil, err
+ }
+
+ cl, err := client.New(cfg, client.Options{Scheme: f.scheme})
+ if err != nil {
+ return nil, fmt.Errorf("build IPAM client for project %q: %w", project, err)
+ }
+
+ f.clients[project] = cl
+ return cl, nil
+}
+
+// configForProject addresses the base connection at one project's control
+// plane. The path names the project, so the platform authorizes this
+// operator's own identity against that project rather than trusting a
+// caller-supplied parent. Any path the base host already carries is replaced,
+// not extended.
+func (f *projectPathClientFactory) configForProject(project string) (*rest.Config, error) {
+ cfg := rest.CopyConfig(f.base)
+
+ host, err := url.Parse(cfg.Host)
+ if err != nil {
+ return nil, fmt.Errorf("parse IPAM host %q: %w", cfg.Host, err)
+ }
+ host.Path = fmt.Sprintf("/apis/%s/v1alpha1/projects/%s/control-plane", resourceManagerGroup, project)
+ cfg.Host = host.String()
+
+ return cfg, nil
+}
+
+// Scheme is the scheme a tenancy-scoped IPAM client is built with.
+func Scheme() (*runtime.Scheme, error) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ return nil, err
+ }
+ if err := ipamv1alpha1.AddToScheme(scheme); err != nil {
+ return nil, err
+ }
+ return scheme, nil
+}
+
+// ErrNoProject says a request named no project, which is never a default.
+var ErrNoProject = errors.New("no project")
+
+// ErrNoPlatformTenancy says the deployment named no place for the platform to
+// allocate what it owns, so it allocates none of it.
+var ErrNoPlatformTenancy = errors.New("no platform tenancy is configured")