diff --git a/AGENTS.md b/AGENTS.md
index ef31713..8f2043f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@ This file provides guidance to AI assistants when working with code in this repo
## What this project is
-Cloud defines Kubernetes CRDs and API types for virtual networking. It is **API-only** — no controllers, no binaries, no runtime. Implementations consume these APIs; this repo just defines the contract.
+Cloud defines Kubernetes CRDs and API types for virtual networking, and ships `vpc-controller` (`cmd/main.go`), which reconciles them in a POP cell against the galactic data plane. API types live in `api/v1alpha1/`; controller code lives in `internal/`.
Module: `go.datum.net/cloud`
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3144d46
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+# Build the manager binary
+FROM --platform=$BUILDPLATFORM golang:1.26 AS builder
+ARG TARGETOS
+ARG TARGETARCH
+
+WORKDIR /workspace
+COPY go.mod go.mod
+COPY go.sum go.sum
+RUN go mod download
+
+COPY cmd/ cmd/
+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
+
+FROM gcr.io/distroless/static-debian12:nonroot
+WORKDIR /
+COPY --from=builder /workspace/vpc-controller .
+USER 65532:65532
+
+ENTRYPOINT ["/vpc-controller"]
diff --git a/README.md b/README.md
index 415bba2..639fec0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Services
-Kubernetes CRDs for virtual networking — API-only, no controller.
+Kubernetes CRDs for virtual networking, and the controller that reconciles them in a POP cell.
**API group:** `cloud.datumapis.com/v1alpha1`
**Stability:** Alpha
@@ -10,7 +10,13 @@ Kubernetes CRDs for virtual networking — API-only, no controller.
## What it is
-Services defines Kubernetes Custom Resource Definitions for virtual tenant networking. It ships type definitions, validation rules, and CRD manifests — no controller, no runtime, no binaries. External implementations import this module to register these types and reconcile the resources.
+Services defines Kubernetes Custom Resource Definitions for virtual tenant networking, plus `vpc-controller`, which realizes them against the galactic data plane.
+
+The controller runs in a POP cell beside network-services-operator, compute and the workload providers. It turns a `NetworkContext` into a `VPC` identity; when a `NetworkInterface` claim is fulfilled it creates the `VPCAttachment` and the `NetworkAttachmentDefinition`, allocates the attachment identifier, and publishes the annotations a workload must carry; and it projects what the data plane reported back onto `VPCAttachment` and `NetworkInterface` status.
+
+It also serves a mutating admission webhook that injects the Multus annotation into Pods labelled `networking.datumapis.com/inject-interfaces: "true"`, so Multus knowledge stays inside the one component that writes NetworkAttachmentDefinitions.
+
+It requires `--attachment-mode` (`Netns` or `Hypervisor`) — how guests in the cell consume an interface. There is no default, because defaulting would hand a microVM an interface it cannot use.
## Resources
@@ -50,7 +56,8 @@ spec:
## Quick start
```bash
-kubectl apply -k config/crd
+kubectl apply -k config/crd # types only
+kubectl apply -k config/default # types, RBAC and the controller
```
## Development
diff --git a/Taskfile.yaml b/Taskfile.yaml
index bf72b96..0877ee1 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -50,7 +50,7 @@ tasks:
desc: Run unit tests
deps: [fmt, vet]
cmds:
- - GOOS=linux go test $(go list ./... | grep -v /e2e) -coverprofile cover.out
+ - go test $(go list ./... | grep -v /e2e) -coverprofile cover.out
lint:
desc: Run golangci-lint, yamlfmt, and yaml extension check
@@ -95,6 +95,7 @@ tasks:
cmds:
- task: generate:methods
- task: generate:manifests
+ - task: generate:rbac
- task: generate:docs
generate:methods:
@@ -109,6 +110,12 @@ tasks:
cmds:
- '{{.CONTROLLER_GEN}} crd paths="./api/..." output:crd:artifacts:config=config/crd'
+ generate:rbac:
+ desc: Generate RBAC manifests from controller markers
+ deps: [install:controller-gen]
+ cmds:
+ - '{{.CONTROLLER_GEN}} rbac:roleName=vpc-controller paths="./internal/..." output:rbac:artifacts:config=config/rbac'
+
generate:docs:
desc: Generate API reference documentation
deps: [install:crd-ref-docs]
diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go
index bcecf0e..e6875db 100644
--- a/api/v1alpha1/groupversion_info.go
+++ b/api/v1alpha1/groupversion_info.go
@@ -30,6 +30,7 @@ var (
GroupVersion = schema.GroupVersion{Group: "cloud.datumapis.com", Version: "v1alpha1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+ //nolint:staticcheck // scheme.Builder is what gives the generated types a Register().
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
diff --git a/api/v1alpha1/vpcattachment_types.go b/api/v1alpha1/vpcattachment_types.go
index 0c417f6..8115c26 100644
--- a/api/v1alpha1/vpcattachment_types.go
+++ b/api/v1alpha1/vpcattachment_types.go
@@ -23,14 +23,25 @@ import (
const VPCAttachmentAnnotation = "k8s.v1alpha1.cloud.datumapis.com/vpc-attachment"
+const (
+ // ConditionTypeReady reports that identifiers are allocated and the
+ // NetworkAttachmentDefinition is written.
+ ConditionTypeReady = "Ready"
+
+ // ConditionTypeProgrammed reports that the data plane realized the attachment.
+ ConditionTypeProgrammed = "Programmed"
+)
+
// VPCAttachmentSpec defines the desired state of VPCAttachment
-//
-// +kubebuilder:validation:XValidation:rule="has(self.vpc) && self.vpc.name != ”",message="vpc reference is required"
type VPCAttachmentSpec struct {
// VPC this attachment belongs to.
// +required
VPC VPCRef `json:"vpc"`
+ // NetworkInterface this attachment realizes.
+ // +optional
+ InterfaceRef *NetworkInterfaceRef `json:"interfaceRef,omitempty"`
+
// Interface defines the network interface configuration.
// +required
Interface VPCAttachmentInterface `json:"interface"`
@@ -43,27 +54,61 @@ type VPCRef struct {
Name string `json:"name"`
}
+// NetworkInterfaceRef references a networking.datumapis.com NetworkInterface in
+// the same namespace.
+type NetworkInterfaceRef struct {
+ // Name of the NetworkInterface.
+ // +kubebuilder:validation:MinLength=1
+ // +required
+ Name string `json:"name"`
+}
+
// IPAddress is an IPv4 or IPv6 address with CIDR notation.
// +kubebuilder:validation:MaxLength=64
type IPAddress string
+// VPCAttachmentInterfaceMode is how the workload consumes the interface. It
+// describes the guest, not the data plane, so a change of implementation on the
+// data plane side does not move this API.
+// +kubebuilder:validation:Enum=Netns;Hypervisor
+type VPCAttachmentInterfaceMode string
+
+const (
+ // VPCAttachmentInterfaceModeNetns moves the interface into the workload's
+ // network namespace, which is what a container consumes.
+ VPCAttachmentInterfaceModeNetns VPCAttachmentInterfaceMode = "Netns"
+
+ // VPCAttachmentInterfaceModeHypervisor hands the interface to a hypervisor as
+ // a device, which is what a virtual machine guest consumes.
+ VPCAttachmentInterfaceModeHypervisor VPCAttachmentInterfaceMode = "Hypervisor"
+)
+
// VPCAttachmentInterface defines the network interface details.
//
-// +kubebuilder:validation:XValidation:rule="self.addresses.all(a, isCIDR(a))",message="each address must be a valid IPv4 or IPv6 CIDR"
+// +kubebuilder:validation:XValidation:rule="!has(self.addresses) || self.addresses.all(a, isCIDR(a))",message="each address must be a valid IPv4 or IPv6 CIDR"
type VPCAttachmentInterface struct {
// Name of the interface (e.g., eth0).
// +required
// +default:value="eth0"
Name string `json:"name"`
- // A list of IPv4 or IPv6 addresses associated with the interface.
- // +kubebuilder:validation:MinItems=1
+ // Mode is how the workload consumes the interface, resolved and written by
+ // the attachment controller rather than by whoever runs the workload.
+ // +kubebuilder:default=Netns
+ // +optional
+ Mode VPCAttachmentInterfaceMode `json:"mode,omitempty"`
+
+ // A list of IPv4 or IPv6 addresses associated with the interface. Empty when
+ // the guest manages its own addressing.
// +kubebuilder:validation:MaxItems=16
- // +required
- Addresses []IPAddress `json:"addresses"`
+ // +optional
+ Addresses []IPAddress `json:"addresses,omitempty"`
}
// VPCAttachmentStatus defines the observed state of VPCAttachment.
+//
+// Every field but Conditions is optional: an identifier is recorded before a pod
+// attaches, and a guest managing its own addressing never reports a subnet.
type VPCAttachmentStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
@@ -76,44 +121,57 @@ type VPCAttachmentStatus struct {
// Base62-encoded VPC identifier.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=16
- VPC string `json:"vpc"`
+ // +optional
+ VPC string `json:"vpc,omitempty"`
// Base62-encoded VPCAttachment identifier.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=16
- VPCAttachment string `json:"vpcAttachment"`
+ // +optional
+ VPCAttachment string `json:"vpcAttachment,omitempty"`
// Kubernetes node name where the attachment lives.
// +kubebuilder:validation:MinLength=1
- Node string `json:"node"`
+ // +optional
+ Node string `json:"node,omitempty"`
// Full container ID (46 hex characters).
// +kubebuilder:validation:MinLength=46
// +kubebuilder:validation:MaxLength=46
- ContainerID string `json:"containerID"`
+ // +optional
+ ContainerID string `json:"containerID,omitempty"`
// Pod name.
// +kubebuilder:validation:MinLength=1
- PodName string `json:"podName"`
+ // +optional
+ PodName string `json:"podName,omitempty"`
- // Host-side veth device name (e.g., "G000000010010H").
+ // Host-side veth or tap device name (e.g., "G000000010013H").
// +kubebuilder:validation:MinLength=1
- HostInterface string `json:"hostInterface"`
+ // +optional
+ HostInterface string `json:"hostInterface,omitempty"`
- // VRF device name (e.g., "G000000010010V").
+ // VRF device name, which is per-VPC (e.g., "G000000010V").
// +kubebuilder:validation:MinLength=1
- VRFInterface string `json:"vrfInterface"`
+ // +optional
+ VRFInterface string `json:"vrfInterface,omitempty"`
- // Guest-side veth device name (e.g., "G000000010010G").
+ // Guest-side veth device name (e.g., "G000000010013G").
// +kubebuilder:validation:MinLength=1
// +optional
GuestInterface string `json:"guestInterface,omitempty"`
- // Allocated /80 subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80").
+ // Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80").
// +kubebuilder:validation:MinLength=1
+ // +optional
//
// +kubebuilder:validation:XValidation:rule="isCIDR(self)",message="podSubnet must be a valid IPv6 CIDR"
- PodSubnet string `json:"podSubnet"`
+ PodSubnet string `json:"podSubnet,omitempty"`
+
+ // NetworkAttachmentDefinition rendered for this attachment.
+ // +kubebuilder:validation:MinLength=1
+ // +optional
+ NetworkAttachmentDefinition string `json:"networkAttachmentDefinition,omitempty"`
}
// +kubebuilder:object:root=true
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 0702675..4bc77cf 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -26,6 +26,21 @@ 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 *NetworkInterfaceRef) DeepCopyInto(out *NetworkInterfaceRef) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkInterfaceRef.
+func (in *NetworkInterfaceRef) DeepCopy() *NetworkInterfaceRef {
+ if in == nil {
+ return nil
+ }
+ out := new(NetworkInterfaceRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VPC) DeepCopyInto(out *VPC) {
*out = *in
@@ -136,6 +151,11 @@ func (in *VPCAttachmentList) DeepCopyObject() runtime.Object {
func (in *VPCAttachmentSpec) DeepCopyInto(out *VPCAttachmentSpec) {
*out = *in
out.VPC = in.VPC
+ if in.InterfaceRef != nil {
+ in, out := &in.InterfaceRef, &out.InterfaceRef
+ *out = new(NetworkInterfaceRef)
+ **out = **in
+ }
in.Interface.DeepCopyInto(&out.Interface)
}
diff --git a/cmd/main.go b/cmd/main.go
new file mode 100644
index 0000000..24882de
--- /dev/null
+++ b/cmd/main.go
@@ -0,0 +1,165 @@
+/*
+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 vpc-controller reconciles VPC and VPCAttachment in a POP cell,
+// alongside network-services-operator, compute and the workload providers.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+
+ nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
+ "sigs.k8s.io/controller-runtime/pkg/webhook"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/controller"
+ "go.datum.net/cloud/internal/galactic"
+ datumwebhook "go.datum.net/cloud/internal/webhook"
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+ bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
+)
+
+var scheme = runtime.NewScheme()
+
+func init() {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(cloudv1alpha1.AddToScheme(scheme))
+ utilruntime.Must(networkingv1alpha.AddToScheme(scheme))
+ utilruntime.Must(bgpv1alpha1.AddToScheme(scheme))
+ utilruntime.Must(nadv1.AddToScheme(scheme))
+ utilruntime.Must(computev1alpha.AddToScheme(scheme))
+}
+
+func main() {
+ var metricsAddr, probeAddr, rawAttachmentMode, webhookCertDir string
+ var webhookPort int
+ 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 makes identifier allocation safe.")
+ flag.StringVar(&rawAttachmentMode, "attachment-mode", "",
+ "Required. How guests in this cell consume an interface: Netns or Hypervisor.")
+ flag.IntVar(&webhookPort, "webhook-port", 9443, "Port the admission webhook server binds to.")
+ flag.StringVar(&webhookCertDir, "webhook-cert-dir", "/tmp/k8s-webhook-server/serving-certs",
+ "Directory holding the webhook server's tls.crt and tls.key.")
+ opts := zap.Options{Development: false}
+ opts.BindFlags(flag.CommandLine)
+ flag.Parse()
+
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+ setupLog := ctrl.Log.WithName("setup")
+ ctx := ctrl.SetupSignalHandler()
+
+ attachmentMode, err := parseAttachmentMode(rawAttachmentMode)
+ if err != nil {
+ setupLog.Error(err, "invalid configuration")
+ os.Exit(1)
+ }
+
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsserver.Options{BindAddress: metricsAddr},
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "vpc-controller.cloud.datumapis.com",
+ WebhookServer: webhook.NewServer(webhook.Options{
+ Port: webhookPort,
+ CertDir: webhookCertDir,
+ }),
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start manager")
+ os.Exit(1)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &cloudv1alpha1.VPCAttachment{},
+ controller.IndexVPCAttachmentIdentity, func(obj client.Object) []string {
+ attachment, ok := obj.(*cloudv1alpha1.VPCAttachment)
+ if !ok || attachment.Status.VPC == "" || attachment.Status.VPCAttachment == "" {
+ return nil
+ }
+ return []string{galactic.AdvertisementName(attachment.Status.VPC, attachment.Status.VPCAttachment)}
+ }); err != nil {
+ setupLog.Error(err, "unable to index VPC attachments by identity")
+ os.Exit(1)
+ }
+
+ if err := (&controller.NetworkContextReconciler{
+ Client: mgr.GetClient(), Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "NetworkContext")
+ os.Exit(1)
+ }
+ if err := (&controller.NetworkInterfaceReconciler{
+ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), APIReader: mgr.GetAPIReader(),
+ AttachmentMode: attachmentMode,
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "NetworkInterface")
+ os.Exit(1)
+ }
+ if err := (&controller.BGPAdvertisementReconciler{
+ Client: mgr.GetClient(), Scheme: mgr.GetScheme(),
+ }).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "BGPAdvertisement")
+ os.Exit(1)
+ }
+
+ (&datumwebhook.PodInterfaceInjector{Client: mgr.GetClient()}).SetupWithManager(mgr)
+
+ 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 manager")
+ if err := mgr.Start(ctx); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+}
+
+// parseAttachmentMode resolves the required attachment mode. There is no
+// default: defaulting to Netns would hand a microVM a veth it cannot use.
+func parseAttachmentMode(value string) (cloudv1alpha1.VPCAttachmentInterfaceMode, error) {
+ switch cloudv1alpha1.VPCAttachmentInterfaceMode(value) {
+ case cloudv1alpha1.VPCAttachmentInterfaceModeNetns:
+ return cloudv1alpha1.VPCAttachmentInterfaceModeNetns, nil
+ case cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor:
+ return cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, nil
+ case "":
+ return "", errors.New("--attachment-mode is required: set Netns for container cells or Hypervisor for microVM cells")
+ default:
+ return "", fmt.Errorf("--attachment-mode %q is not one of Netns, Hypervisor", value)
+ }
+}
diff --git a/cmd/main_test.go b/cmd/main_test.go
new file mode 100644
index 0000000..850be51
--- /dev/null
+++ b/cmd/main_test.go
@@ -0,0 +1,49 @@
+/*
+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 main
+
+import (
+ "testing"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+)
+
+func TestParseAttachmentMode(t *testing.T) {
+ tests := []struct {
+ input string
+ want cloudv1alpha1.VPCAttachmentInterfaceMode
+ wantErr bool
+ }{
+ {"Hypervisor", cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, false},
+ {"Netns", cloudv1alpha1.VPCAttachmentInterfaceModeNetns, false},
+ {"", "", true},
+ {"netns", "", true},
+ {"tap", "", true},
+ }
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ got, err := parseAttachmentMode(test.input)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("error: got %v, wantErr %v", err, test.wantErr)
+ }
+ if got != test.want {
+ t.Errorf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml
new file mode 100644
index 0000000..848c9cc
--- /dev/null
+++ b/config/certmanager/certificate.yaml
@@ -0,0 +1,21 @@
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+ name: selfsigned-issuer
+ namespace: vpc-system
+spec:
+ selfSigned: {}
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: serving-cert
+ namespace: vpc-system
+spec:
+ dnsNames:
+ - vpc-controller-webhook.vpc-system.svc
+ - vpc-controller-webhook.vpc-system.svc.cluster.local
+ issuerRef:
+ kind: Issuer
+ name: selfsigned-issuer
+ secretName: webhook-server-cert
diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml
new file mode 100644
index 0000000..1615af2
--- /dev/null
+++ b/config/certmanager/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - certificate.yaml
diff --git a/config/crd/cloud.datumapis.com_vpcattachments.yaml b/config/crd/cloud.datumapis.com_vpcattachments.yaml
index d451673..b587ed3 100644
--- a/config/crd/cloud.datumapis.com_vpcattachments.yaml
+++ b/config/crd/cloud.datumapis.com_vpcattachments.yaml
@@ -43,27 +43,45 @@ spec:
description: Interface defines the network interface configuration.
properties:
addresses:
- description: A list of IPv4 or IPv6 addresses associated with
- the interface.
+ description: |-
+ A list of IPv4 or IPv6 addresses associated with the interface. Empty when
+ the guest manages its own addressing.
items:
description: IPAddress is an IPv4 or IPv6 address with CIDR
notation.
maxLength: 64
type: string
maxItems: 16
- minItems: 1
type: array
+ mode:
+ default: Netns
+ description: |-
+ Mode is how the workload consumes the interface, resolved and written by
+ the attachment controller rather than by whoever runs the workload.
+ enum:
+ - Netns
+ - Hypervisor
+ type: string
name:
default: eth0
description: Name of the interface (e.g., eth0).
type: string
required:
- - addresses
- name
type: object
x-kubernetes-validations:
- message: each address must be a valid IPv4 or IPv6 CIDR
- rule: self.addresses.all(a, isCIDR(a))
+ rule: '!has(self.addresses) || self.addresses.all(a, isCIDR(a))'
+ interfaceRef:
+ description: NetworkInterface this attachment realizes.
+ properties:
+ name:
+ description: Name of the NetworkInterface.
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
vpc:
description: VPC this attachment belongs to.
properties:
@@ -78,9 +96,6 @@ spec:
- interface
- vpc
type: object
- x-kubernetes-validations:
- - message: vpc reference is required
- rule: has(self.vpc) && self.vpc.name != ''
status:
description: status defines the observed state of VPCAttachment
properties:
@@ -149,11 +164,15 @@ spec:
minLength: 46
type: string
guestInterface:
- description: Guest-side veth device name (e.g., "G000000010010G").
+ description: Guest-side veth device name (e.g., "G000000010013G").
minLength: 1
type: string
hostInterface:
- description: Host-side veth device name (e.g., "G000000010010H").
+ description: Host-side veth or tap device name (e.g., "G000000010013H").
+ minLength: 1
+ type: string
+ networkAttachmentDefinition:
+ description: NetworkAttachmentDefinition rendered for this attachment.
minLength: 1
type: string
node:
@@ -168,7 +187,7 @@ spec:
minLength: 1
type: string
podSubnet:
- description: Allocated /80 subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80").
+ description: Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80").
minLength: 1
type: string
x-kubernetes-validations:
@@ -185,18 +204,9 @@ spec:
minLength: 1
type: string
vrfInterface:
- description: VRF device name (e.g., "G000000010010V").
+ description: VRF device name, which is per-VPC (e.g., "G000000010V").
minLength: 1
type: string
- required:
- - containerID
- - hostInterface
- - node
- - podName
- - podSubnet
- - vpc
- - vpcAttachment
- - vrfInterface
type: object
required:
- spec
diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml
new file mode 100644
index 0000000..58d6242
--- /dev/null
+++ b/config/default/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - ../crd
+ - ../rbac
+ - ../certmanager
+ - ../webhook
+ - ../manager
diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml
new file mode 100644
index 0000000..2926b52
--- /dev/null
+++ b/config/manager/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - manager.yaml
diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml
new file mode 100644
index 0000000..ce62694
--- /dev/null
+++ b/config/manager/manager.yaml
@@ -0,0 +1,73 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: vpc-system
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: vpc-controller
+ namespace: vpc-system
+ labels:
+ app.kubernetes.io/name: vpc-controller
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: vpc-controller
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: vpc-controller
+ spec:
+ serviceAccountName: vpc-controller
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: manager
+ image: ghcr.io/datum-cloud/vpc-controller:latest
+ args:
+ - --leader-elect
+ # Required. Hypervisor for microVM cells, Netns for container cells.
+ - --attachment-mode=Hypervisor
+ - --health-probe-bind-address=:8081
+ - --metrics-bind-address=:8080
+ ports:
+ - name: metrics
+ containerPort: 8080
+ - name: webhook
+ containerPort: 9443
+ volumeMounts:
+ - name: webhook-certs
+ mountPath: /tmp/k8s-webhook-server/serving-certs
+ readOnly: true
+ 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
+ volumes:
+ - name: webhook-certs
+ secret:
+ secretName: webhook-server-cert
+ terminationGracePeriodSeconds: 10
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
new file mode 100644
index 0000000..3db1aeb
--- /dev/null
+++ b/config/rbac/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - service_account.yaml
+ - role.yaml
+ - role_binding.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
new file mode 100644
index 0000000..f50e03f
--- /dev/null
+++ b/config/rbac/role.yaml
@@ -0,0 +1,96 @@
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: vpc-controller
+rules:
+- apiGroups:
+ - ""
+ resources:
+ - events
+ verbs:
+ - create
+ - patch
+- apiGroups:
+ - cloud.datumapis.com
+ resources:
+ - vpcattachments
+ - vpcs
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - cloud.datumapis.com
+ resources:
+ - vpcattachments/status
+ - vpcs/status
+ verbs:
+ - get
+ - patch
+ - update
+- apiGroups:
+ - compute.datumapis.com
+ resources:
+ - instances
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - coordination.k8s.io
+ resources:
+ - leases
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - k8s.cni.cncf.io
+ resources:
+ - network-attachment-definitions
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - network.datumapis.com
+ resources:
+ - bgpadvertisements
+ - bgprouters
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.datumapis.com
+ resources:
+ - networkcontexts
+ - networkinterfaceclaims
+ - networkinterfaces
+ - subnets
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.datumapis.com
+ resources:
+ - networkinterfaceclaims/status
+ - networkinterfaces/status
+ verbs:
+ - get
+ - patch
+ - update
diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml
new file mode 100644
index 0000000..0fbd525
--- /dev/null
+++ b/config/rbac/role_binding.yaml
@@ -0,0 +1,12 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: vpc-controller
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: vpc-controller
+subjects:
+ - kind: ServiceAccount
+ name: vpc-controller
+ namespace: vpc-system
diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml
new file mode 100644
index 0000000..cb957c6
--- /dev/null
+++ b/config/rbac/service_account.yaml
@@ -0,0 +1,5 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: vpc-controller
+ namespace: vpc-system
diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml
new file mode 100644
index 0000000..523dbfd
--- /dev/null
+++ b/config/webhook/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - manifests.yaml
+ - service.yaml
diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml
new file mode 100644
index 0000000..6352c90
--- /dev/null
+++ b/config/webhook/manifests.yaml
@@ -0,0 +1,32 @@
+apiVersion: admissionregistration.k8s.io/v1
+kind: MutatingWebhookConfiguration
+metadata:
+ name: vpc-controller-pod-interfaces
+ annotations:
+ cert-manager.io/inject-ca-from: vpc-system/serving-cert
+webhooks:
+ - name: pod-interfaces.cloud.datumapis.com
+ admissionReviewVersions:
+ - v1
+ sideEffects: None
+ # Fail is safe only because objectSelector narrows this to Pods that opted
+ # in: an outage blocks exactly those, loudly, instead of every Pod in the
+ # cell — and never lets one come up silently unattached.
+ failurePolicy: Fail
+ objectSelector:
+ matchLabels:
+ networking.datumapis.com/inject-interfaces: "true"
+ clientConfig:
+ service:
+ name: vpc-controller-webhook
+ namespace: vpc-system
+ path: /mutate-v1-pod
+ rules:
+ - apiGroups:
+ - ""
+ apiVersions:
+ - v1
+ operations:
+ - CREATE
+ resources:
+ - pods
diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml
new file mode 100644
index 0000000..037d76f
--- /dev/null
+++ b/config/webhook/service.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: vpc-controller-webhook
+ namespace: vpc-system
+spec:
+ selector:
+ app.kubernetes.io/name: vpc-controller
+ ports:
+ - port: 443
+ protocol: TCP
+ targetPort: 9443
diff --git a/docs/api/vpc.md b/docs/api/vpc.md
index d0588b6..6d0764a 100644
--- a/docs/api/vpc.md
+++ b/docs/api/vpc.md
@@ -42,6 +42,23 @@ _Appears in:_
+#### NetworkInterfaceRef
+
+
+
+NetworkInterfaceRef references a networking.datumapis.com NetworkInterface in
+the same namespace.
+
+
+
+_Appears in:_
+- [VPCAttachmentSpec](#vpcattachmentspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name of the NetworkInterface. | | MinLength: 1
|
+
+
#### VPC
@@ -99,7 +116,28 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `name` _string_ | Name of the interface (e.g., eth0). | | |
-| `addresses` _[IPAddress](#ipaddress) array_ | A list of IPv4 or IPv6 addresses associated with the interface. | | MaxItems: 16
MaxLength: 64
MinItems: 1
|
+| `mode` _[VPCAttachmentInterfaceMode](#vpcattachmentinterfacemode)_ | Mode is how the workload consumes the interface, resolved and written by
the attachment controller rather than by whoever runs the workload. | Netns | Enum: [Netns Hypervisor]
|
+| `addresses` _[IPAddress](#ipaddress) array_ | A list of IPv4 or IPv6 addresses associated with the interface. Empty when
the guest manages its own addressing. | | MaxItems: 16
MaxLength: 64
|
+
+
+#### VPCAttachmentInterfaceMode
+
+_Underlying type:_ _string_
+
+VPCAttachmentInterfaceMode is how the workload consumes the interface. It
+describes the guest, not the data plane, so a change of implementation on the
+data plane side does not move this API.
+
+_Validation:_
+- Enum: [Netns Hypervisor]
+
+_Appears in:_
+- [VPCAttachmentInterface](#vpcattachmentinterface)
+
+| Field | Description |
+| --- | --- |
+| `Netns` | VPCAttachmentInterfaceModeNetns moves the interface into the workload's
network namespace, which is what a container consumes.
|
+| `Hypervisor` | VPCAttachmentInterfaceModeHypervisor hands the interface to a hypervisor as
a device, which is what a virtual machine guest consumes.
|
#### VPCAttachmentSpec
@@ -116,6 +154,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `vpc` _[VPCRef](#vpcref)_ | VPC this attachment belongs to. | | |
+| `interfaceRef` _[NetworkInterfaceRef](#networkinterfaceref)_ | NetworkInterface this attachment realizes. | | |
| `interface` _[VPCAttachmentInterface](#vpcattachmentinterface)_ | Interface defines the network interface configuration. | | |
@@ -125,6 +164,9 @@ _Appears in:_
VPCAttachmentStatus defines the observed state of VPCAttachment.
+Every field but Conditions is optional: an identifier is recorded before a pod
+attaches, and a guest managing its own addressing never reports a subnet.
+
_Appears in:_
@@ -139,10 +181,11 @@ _Appears in:_
| `node` _string_ | Kubernetes node name where the attachment lives. | | MinLength: 1
|
| `containerID` _string_ | Full container ID (46 hex characters). | | MaxLength: 46
MinLength: 46
|
| `podName` _string_ | Pod name. | | MinLength: 1
|
-| `hostInterface` _string_ | Host-side veth device name (e.g., "G000000010010H"). | | MinLength: 1
|
-| `vrfInterface` _string_ | VRF device name (e.g., "G000000010010V"). | | MinLength: 1
|
-| `guestInterface` _string_ | Guest-side veth device name (e.g., "G000000010010G"). | | MinLength: 1
|
-| `podSubnet` _string_ | Allocated /80 subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80"). | | MinLength: 1
|
+| `hostInterface` _string_ | Host-side veth or tap device name (e.g., "G000000010013H"). | | MinLength: 1
|
+| `vrfInterface` _string_ | VRF device name, which is per-VPC (e.g., "G000000010V"). | | MinLength: 1
|
+| `guestInterface` _string_ | Guest-side veth device name (e.g., "G000000010013G"). | | MinLength: 1
|
+| `podSubnet` _string_ | Allocated subnet in CIDR notation (e.g., "fd00:10:ff01:0:1::/80"). | | MinLength: 1
|
+| `networkAttachmentDefinition` _string_ | NetworkAttachmentDefinition rendered for this attachment. | | MinLength: 1
|
#### VPCRef
diff --git a/go.mod b/go.mod
index 073a79e..246faa3 100644
--- a/go.mod
+++ b/go.mod
@@ -1,31 +1,78 @@
module go.datum.net/cloud
-go 1.26
+go 1.26.4
require (
- k8s.io/api v0.33.0
- k8s.io/apimachinery v0.33.0
- sigs.k8s.io/controller-runtime v0.21.0
+ github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7
+ github.com/kenshaw/baseconv v0.1.1
+ 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
+ k8s.io/api v0.36.3
+ k8s.io/apimachinery v0.36.3
+ k8s.io/client-go v0.36.3
+ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
+ sigs.k8s.io/controller-runtime v0.24.1
)
require (
- github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.11 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/go-logr/zapr v1.3.0 // indirect
+ github.com/go-openapi/jsonpointer v0.23.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.6 // indirect
+ github.com/go-openapi/swag v0.25.4 // indirect
+ github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
+ github.com/go-openapi/swag/conv v0.26.0 // indirect
+ github.com/go-openapi/swag/fileutils v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonname v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
+ github.com/go-openapi/swag/loading v0.26.0 // indirect
+ github.com/go-openapi/swag/mangling v0.26.0 // indirect
+ github.com/go-openapi/swag/netutils v0.25.4 // indirect
+ github.com/go-openapi/swag/stringutils v0.26.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.26.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/kr/text v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.23.2 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
- golang.org/x/net v0.51.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
- k8s.io/klog/v2 v2.130.1 // indirect
- k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
- sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
+ k8s.io/apiextensions-apiserver v0.36.1 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
+ sigs.k8s.io/gateway-api v1.5.1 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
- sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
- sigs.k8s.io/yaml v1.4.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index 16ad2c1..cd1471a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,108 +1,194 @@
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
-github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
+github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
+github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
+github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
+github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
+github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
+github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
+github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
+github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
+github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
+github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
+github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
+github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
+github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
+github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
+github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
+github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
+github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
+github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
+github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
+github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
+github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
+github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
+github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
+github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
+github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
+github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
+github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
+github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
+github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
+github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
+github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
+github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 h1:z4P744DR+PIpkjwXSEc6TvN3L6LVzmUquFgmNm8wSUc=
+github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7/go.mod h1:CM7HAH5PNuIsqjMN0fGc1ydM74Uj+0VZFhob620nklw=
+github.com/kenshaw/baseconv v0.1.1 h1:oAu/C7ipUT2PqT9DT0mZDGDg4URIglizZMjPv9oCu0E=
+github.com/kenshaw/baseconv v0.1.1/go.mod h1:yy9zGmnnR6vgOxOQb702nVdAG30JhyYZpj/5/m0siRI=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
-github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
-github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
-github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
+github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
+github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
+github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
-golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+go.datum.net/compute v0.8.0-dev.7.0.20260821003916-1a0e4d6443f0 h1:EDzArN7AXyfWzPSAMqUNpC9kmMmT9afO0VTt7JCNacY=
+go.datum.net/compute v0.8.0-dev.7.0.20260821003916-1a0e4d6443f0/go.mod h1:HEyoohOD3mQxkUsnlDIgRrh9L9P8j7nVtiFB1+utIAA=
+go.datum.net/network v0.0.0-20260819160013-45d0ff9deaee h1:7tA+0C1pb/fu/wrgB3Vu+P3nOJK4aNnKaUeB5wzrxVY=
+go.datum.net/network v0.0.0-20260819160013-45d0ff9deaee/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE=
+go.datum.net/network-services-operator v0.26.1-0.20260820201844-f366b960529b h1:Qkh/+0XW+JXVXx89H/vyG1KBl+I0jLnKNiQSB8UgUQA=
+go.datum.net/network-services-operator v0.26.1-0.20260820201844-f366b960529b/go.mod h1:A7JNOuc+e6j/KkUVCcZ7Z2odvf6JFMQlX0Zo1Awj2TY=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
+gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=
-k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=
-k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
-k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
-k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
-k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
-k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
-k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=
-sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=
-sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
-sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
-sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
+k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
+k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks=
+k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8=
+k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
+k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
+k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
+k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg=
+k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
+k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
+sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
+sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
+sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0=
+sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
-sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
-sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
-sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
-sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/internal/controller/bgpadvertisement_controller.go b/internal/controller/bgpadvertisement_controller.go
new file mode 100644
index 0000000..cdd7e23
--- /dev/null
+++ b/internal/controller/bgpadvertisement_controller.go
@@ -0,0 +1,167 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/galactic"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+ bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
+)
+
+// IndexVPCAttachmentIdentity indexes a VPCAttachment by the "-"
+// pair galactic names its BGPAdvertisement after.
+const IndexVPCAttachmentIdentity = "status.identity"
+
+// BGPAdvertisementReconciler projects what the data plane published onto the
+// Datum API. galactic-router sets Advertised from live GoBGP runtime state, so
+// consuming its BGPAdvertisement keeps Datum types off the CNI ADD path and
+// keeps galactic free of Datum APIs.
+type BGPAdvertisementReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=network.datumapis.com,resources=bgpadvertisements;bgprouters,verbs=get;list;watch
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcattachments/status,verbs=get;update;patch
+
+func (r *BGPAdvertisementReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ var advertisement bgpv1alpha1.BGPAdvertisement
+ if err := r.Get(ctx, req.NamespacedName, &advertisement); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ vpc, attachmentID, ok := galactic.SplitAdvertisementName(advertisement.Name)
+ if !ok {
+ return ctrl.Result{}, nil
+ }
+
+ var attachments cloudv1alpha1.VPCAttachmentList
+ if err := r.List(ctx, &attachments,
+ client.MatchingFields{IndexVPCAttachmentIdentity: galactic.AdvertisementName(vpc, attachmentID)}); err != nil {
+ return ctrl.Result{}, fmt.Errorf("list VPC attachments for advertisement %s: %w", advertisement.Name, err)
+ }
+ if len(attachments.Items) == 0 {
+ return ctrl.Result{}, nil
+ }
+
+ node, err := r.nodeForRouter(ctx, advertisement.Namespace, advertisement.Spec.RouterRef.Name)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ programmed := programmedCondition(&advertisement)
+ subnets := galactic.AllocatedSubnets(advertisement.Annotations)
+
+ for i := range attachments.Items {
+ attachment := &attachments.Items[i]
+ attachment.Status.Node = node
+ attachment.Status.HostInterface = galactic.HostInterfaceName(vpc, attachmentID)
+ attachment.Status.VRFInterface = galactic.VRFInterfaceName(vpc)
+ if len(subnets) == 1 {
+ // One live pod per interface at a time, so a single recorded subnet
+ // is this attachment's; several means a container is still being
+ // collected and neither is unambiguously current.
+ attachment.Status.PodSubnet = subnets[0]
+ }
+ meta.SetStatusCondition(&attachment.Status.Conditions, programmed)
+ if err := r.Status().Update(ctx, attachment); err != nil {
+ return ctrl.Result{}, fmt.Errorf("update VPC attachment %s status: %w",
+ client.ObjectKeyFromObject(attachment), err)
+ }
+ if err := r.projectOntoInterface(ctx, attachment, programmed); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// projectOntoInterface closes the Programmed condition NSO deliberately leaves
+// for whoever realizes the interface.
+func (r *BGPAdvertisementReconciler) projectOntoInterface(
+ ctx context.Context, attachment *cloudv1alpha1.VPCAttachment, programmed metav1.Condition,
+) error {
+ if attachment.Spec.InterfaceRef == nil {
+ return nil
+ }
+ var networkInterface networkingv1alpha.NetworkInterface
+ key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Spec.InterfaceRef.Name}
+ if err := r.Get(ctx, key, &networkInterface); err != nil {
+ return client.IgnoreNotFound(err)
+ }
+
+ interfaceProgrammed := programmed
+ interfaceProgrammed.Type = networkingv1alpha.NetworkInterfaceProgrammed
+ interfaceProgrammed.ObservedGeneration = networkInterface.Generation
+ meta.SetStatusCondition(&networkInterface.Status.Conditions, interfaceProgrammed)
+ if err := r.Status().Update(ctx, &networkInterface); err != nil {
+ return fmt.Errorf("update network interface %s status: %w", key, err)
+ }
+ return nil
+}
+
+// nodeForRouter resolves the node a BGPRouter executes on.
+func (r *BGPAdvertisementReconciler) nodeForRouter(ctx context.Context, namespace, name string) (string, error) {
+ if name == "" {
+ return "", nil
+ }
+ var router bgpv1alpha1.BGPRouter
+ if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &router); err != nil {
+ return "", client.IgnoreNotFound(err)
+ }
+ return router.Spec.TargetRef.Name, nil
+}
+
+// programmedCondition translates the data plane's Advertised condition.
+func programmedCondition(advertisement *bgpv1alpha1.BGPAdvertisement) metav1.Condition {
+ advertised := meta.FindStatusCondition(advertisement.Status.Conditions, galactic.ConditionAdvertised)
+ condition := metav1.Condition{
+ Type: cloudv1alpha1.ConditionTypeProgrammed,
+ Status: metav1.ConditionUnknown,
+ Reason: "AwaitingDataPlane",
+ Message: "the data plane has not reported on this attachment yet",
+ }
+ if advertised != nil {
+ condition.Status = advertised.Status
+ condition.Reason = advertised.Reason
+ condition.Message = advertised.Message
+ }
+ if advertised != nil && advertised.Status == metav1.ConditionTrue &&
+ advertisement.Annotations[galactic.AnnotationNoAddressing] == "true" {
+ condition.Message = "attachment advertised; the guest manages its own addressing"
+ }
+ return condition
+}
+
+// SetupWithManager registers the reconciler with the manager.
+func (r *BGPAdvertisementReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&bgpv1alpha1.BGPAdvertisement{}).
+ Named("bgpadvertisement").
+ Complete(r)
+}
diff --git a/internal/controller/doc.go b/internal/controller/doc.go
new file mode 100644
index 0000000..1607527
--- /dev/null
+++ b/internal/controller/doc.go
@@ -0,0 +1,28 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package controller reconciles VPC and VPCAttachment in a POP cell: it turns a
+// NetworkContext into a VPC identity, creates the attachment and the
+// NetworkAttachmentDefinition when a NetworkInterface claim is fulfilled,
+// reports Prepared, and projects what the galactic data plane published back
+// onto the Datum API.
+package controller
+
+// Leader election is what serializes identifier allocation, so the lease and
+// event permissions below are load-bearing rather than boilerplate.
+// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
diff --git a/internal/controller/networkcontext_controller.go b/internal/controller/networkcontext_controller.go
new file mode 100644
index 0000000..949a05e
--- /dev/null
+++ b/internal/controller/networkcontext_controller.go
@@ -0,0 +1,165 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "time"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/identifier"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+// NetworkContextReconciler gives a network's presence in one location its
+// data-plane identity: one VPC per NetworkContext, carrying the base62 VPC
+// identifier the whole galactic fabric keys on.
+type NetworkContextReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts,verbs=get;list;watch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcs,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcs/status,verbs=get;update;patch
+
+func (r *NetworkContextReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ var networkContext networkingv1alpha.NetworkContext
+ if err := r.Get(ctx, req.NamespacedName, &networkContext); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if !networkContext.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, nil
+ }
+
+ networks, err := r.networksForContext(ctx, &networkContext)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if len(networks) == 0 {
+ // The VPC address space comes from the Subnets IPAM allocated for this
+ // location, and VPCSpec is immutable once written.
+ return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
+ }
+
+ vpc := &cloudv1alpha1.VPC{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: networkContext.Name,
+ Namespace: networkContext.Namespace,
+ },
+ }
+ if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, vpc, func() error {
+ if vpc.CreationTimestamp.IsZero() {
+ vpc.Spec.Networks = networks
+ }
+ return controllerutil.SetControllerReference(&networkContext, vpc, r.Scheme)
+ }); err != nil {
+ return ctrl.Result{}, fmt.Errorf("reconcile VPC %s: %w", vpc.Name, err)
+ }
+
+ if vpc.Status.VPC == "" {
+ allocated, err := r.allocateVPCIdentifier(ctx)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ vpc.Status.VPC = allocated
+ }
+ vpc.Status.ObservedGeneration = vpc.Generation
+ meta.SetStatusCondition(&vpc.Status.Conditions, metav1.Condition{
+ Type: cloudv1alpha1.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "IdentifierAllocated",
+ Message: fmt.Sprintf("VPC identifier %s allocated", vpc.Status.VPC),
+ ObservedGeneration: vpc.Generation,
+ })
+ if err := r.Status().Update(ctx, vpc); err != nil {
+ return ctrl.Result{}, fmt.Errorf("update VPC %s status: %w", vpc.Name, err)
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// networksForContext collects the CIDRs IPAM allocated for this location.
+func (r *NetworkContextReconciler) networksForContext(
+ ctx context.Context, networkContext *networkingv1alpha.NetworkContext,
+) ([]cloudv1alpha1.Network, error) {
+ var subnets networkingv1alpha.SubnetList
+ if err := r.List(ctx, &subnets, client.InNamespace(networkContext.Namespace)); err != nil {
+ return nil, fmt.Errorf("list subnets: %w", err)
+ }
+
+ networks := make([]cloudv1alpha1.Network, 0, len(subnets.Items))
+ for _, subnet := range subnets.Items {
+ if subnet.Spec.NetworkContext.Name != networkContext.Name {
+ continue
+ }
+ if subnet.Status.StartAddress == nil || subnet.Status.PrefixLength == nil {
+ continue
+ }
+ networks = append(networks, cloudv1alpha1.Network(
+ fmt.Sprintf("%s/%d", *subnet.Status.StartAddress, *subnet.Status.PrefixLength)))
+ }
+ slices.Sort(networks)
+ return networks, nil
+}
+
+// allocateVPCIdentifier draws a random 48-bit identifier not already in use.
+// A single leader-elected controller is the only writer, so a list plus a
+// collision check serializes correctly.
+func (r *NetworkContextReconciler) allocateVPCIdentifier(ctx context.Context) (string, error) {
+ var vpcs cloudv1alpha1.VPCList
+ if err := r.List(ctx, &vpcs); err != nil {
+ return "", fmt.Errorf("list VPCs: %w", err)
+ }
+ used := make(map[string]struct{}, len(vpcs.Items))
+ for _, vpc := range vpcs.Items {
+ if vpc.Status.VPC != "" {
+ used[vpc.Status.VPC] = struct{}{}
+ }
+ }
+
+ for range maxIdentifierAttempts {
+ candidate, err := identifier.RandomVPCBase62()
+ if err != nil {
+ return "", err
+ }
+ if _, taken := used[candidate]; !taken {
+ return candidate, nil
+ }
+ }
+ return "", fmt.Errorf("no unused VPC identifier found after %d attempts", maxIdentifierAttempts)
+}
+
+// SetupWithManager registers the reconciler with the manager.
+func (r *NetworkContextReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&networkingv1alpha.NetworkContext{}).
+ Owns(&cloudv1alpha1.VPC{}).
+ Named("networkcontext").
+ Complete(r)
+}
diff --git a/internal/controller/networkinterface_controller.go b/internal/controller/networkinterface_controller.go
new file mode 100644
index 0000000..c990f06
--- /dev/null
+++ b/internal/controller/networkinterface_controller.go
@@ -0,0 +1,361 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/galactic"
+ "go.datum.net/cloud/internal/identifier"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+const (
+ // LabelVPC records the base62 VPC identifier a NAD attaches to.
+ LabelVPC = "cloud.datumapis.com/vpc"
+
+ // LabelVPCAttachment records the base62 attachment identifier a NAD holds.
+ // The NAD is the allocation record for that identifier.
+ LabelVPCAttachment = "cloud.datumapis.com/vpc-attachment"
+
+ // ConditionTypePrepared reports that the data plane's pre-Pod artifacts exist.
+ // Unlike Programmed, which only becomes true at CNI ADD, it is safe to gate
+ // Pod creation on. network-services-operator is adding the type in parallel.
+ ConditionTypePrepared = "Prepared"
+)
+
+// maxIdentifierAttempts bounds the retry loop that draws an unused identifier.
+const maxIdentifierAttempts = 100
+
+// NetworkInterfaceReconciler realizes a fulfilled NetworkInterface claim on the
+// galactic data plane.
+//
+// It creates the VPCAttachment and the NetworkAttachmentDefinition in one pass,
+// so it already holds every render input and never has to look sideways. Both
+// objects are per-interface, which keeps the attachment identifier and the tap
+// device name stable across instance replacement, and it publishes the
+// annotations a workload must carry so no infrastructure provider has to know
+// what a NAD is.
+type NetworkInterfaceReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+
+ // APIReader bypasses the cache when listing allocated identifiers, so a NAD
+ // written moments ago cannot be missed and its identifier reissued.
+ APIReader client.Reader
+
+ // AttachmentMode is how guests in this cell consume an interface. It is
+ // required configuration standing in for a capability class that does not
+ // exist yet, so a cell states what it is rather than defaulting.
+ AttachmentMode cloudv1alpha1.VPCAttachmentInterfaceMode
+}
+
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims,verbs=get;list;watch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcs,verbs=get;list;watch
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcattachments,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=cloud.datumapis.com,resources=vpcattachments/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=k8s.cni.cncf.io,resources=network-attachment-definitions,verbs=get;list;watch;create;update;patch;delete
+
+func (r *NetworkInterfaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ var networkInterface networkingv1alpha.NetworkInterface
+ if err := r.Get(ctx, req.NamespacedName, &networkInterface); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if !networkInterface.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, nil
+ }
+ if !claimFulfilled(&networkInterface) {
+ return ctrl.Result{}, nil
+ }
+
+ var vpc cloudv1alpha1.VPC
+ vpcKey := types.NamespacedName{
+ Namespace: networkInterface.Namespace,
+ Name: networkInterface.Status.NetworkContextRef.Name,
+ }
+ if err := r.Get(ctx, vpcKey, &vpc); err != nil {
+ if apierrors.IsNotFound(err) {
+ return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface,
+ metav1.ConditionFalse, "AwaitingVPC", fmt.Sprintf("VPC %s does not exist yet", vpcKey.Name))
+ }
+ return ctrl.Result{}, fmt.Errorf("get VPC %s: %w", vpcKey, err)
+ }
+ if vpc.Status.VPC == "" {
+ return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface,
+ metav1.ConditionFalse, "AwaitingVPCIdentifier",
+ fmt.Sprintf("VPC %s has no identifier yet", vpc.Name))
+ }
+
+ attachment, err := r.reconcileAttachment(ctx, &networkInterface, &vpc)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ nad, err := r.reconcileNAD(ctx, attachment, &vpc, &networkInterface)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if err := r.publishAttachmentStatus(ctx, attachment, &vpc, nad); err != nil {
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, r.publishToInterface(ctx, &networkInterface, attachment, &vpc)
+}
+
+// claimFulfilled reports whether an interface is bound to a claim and holds
+// every address it must carry. Nothing can be rendered before that.
+func claimFulfilled(networkInterface *networkingv1alpha.NetworkInterface) bool {
+ if networkInterface.Status.Phase != networkingv1alpha.NetworkInterfacePhaseBound {
+ return false
+ }
+ if networkInterface.Status.NetworkContextRef == nil {
+ return false
+ }
+ return meta.IsStatusConditionTrue(networkInterface.Status.Conditions,
+ networkingv1alpha.NetworkInterfaceAllocated)
+}
+
+// reconcileAttachment creates the VPCAttachment for an interface. The controller
+// owns this object, not the infrastructure provider: it is the only component
+// that speaks both the workload vocabulary and the data plane's.
+func (r *NetworkInterfaceReconciler) reconcileAttachment(
+ ctx context.Context, networkInterface *networkingv1alpha.NetworkInterface, vpc *cloudv1alpha1.VPC,
+) (*cloudv1alpha1.VPCAttachment, error) {
+ attachment := &cloudv1alpha1.VPCAttachment{
+ ObjectMeta: metav1.ObjectMeta{Name: networkInterface.Name, Namespace: networkInterface.Namespace},
+ }
+ if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, attachment, func() error {
+ attachment.Spec.VPC = cloudv1alpha1.VPCRef{Name: vpc.Name}
+ attachment.Spec.InterfaceRef = &cloudv1alpha1.NetworkInterfaceRef{Name: networkInterface.Name}
+ attachment.Spec.Interface.Name = networkInterface.Spec.InterfaceName
+ attachment.Spec.Interface.Mode = r.AttachmentMode
+ attachment.Spec.Interface.Addresses = interfaceAddresses(networkInterface)
+ return controllerutil.SetControllerReference(networkInterface, attachment, r.Scheme)
+ }); err != nil {
+ return nil, fmt.Errorf("reconcile VPC attachment %s: %w", attachment.Name, err)
+ }
+ return attachment, nil
+}
+
+// reconcileNAD creates or updates the NAD the attachment owns.
+func (r *NetworkInterfaceReconciler) reconcileNAD(
+ ctx context.Context,
+ attachment *cloudv1alpha1.VPCAttachment,
+ vpc *cloudv1alpha1.VPC,
+ networkInterface *networkingv1alpha.NetworkInterface,
+) (*nadv1.NetworkAttachmentDefinition, error) {
+ nad := &nadv1.NetworkAttachmentDefinition{
+ ObjectMeta: metav1.ObjectMeta{Name: attachment.Name, Namespace: attachment.Namespace},
+ }
+
+ attachmentID := ""
+ if err := r.Get(ctx, client.ObjectKeyFromObject(nad), nad); err == nil {
+ attachmentID = nad.Labels[LabelVPCAttachment]
+ } else if !apierrors.IsNotFound(err) {
+ return nil, fmt.Errorf("get NetworkAttachmentDefinition %s: %w", nad.Name, err)
+ }
+ if attachmentID == "" {
+ allocated, err := r.allocateAttachmentIdentifier(ctx, vpc.Status.VPC)
+ if err != nil {
+ return nil, err
+ }
+ attachmentID = allocated
+ }
+
+ addresses := make([]string, 0, len(networkInterface.Spec.Addresses))
+ for _, address := range networkInterface.Spec.Addresses {
+ addresses = append(addresses, address.Address)
+ }
+ config, err := galactic.ConflistJSON(attachment.Name, masterPlugin(attachment.Spec.Interface.Mode),
+ vpc.Status.VPC, attachmentID, networkInterface.Spec.MTU, addresses)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, nad, func() error {
+ if nad.Labels == nil {
+ nad.Labels = map[string]string{}
+ }
+ nad.Labels[LabelVPC] = vpc.Status.VPC
+ nad.Labels[LabelVPCAttachment] = attachmentID
+ nad.Spec.Config = config
+ return controllerutil.SetControllerReference(attachment, nad, r.Scheme)
+ }); err != nil {
+ return nil, fmt.Errorf("reconcile NetworkAttachmentDefinition %s: %w", nad.Name, err)
+ }
+ return nad, nil
+}
+
+// masterPlugin translates how a guest consumes an interface into the galactic
+// binary that realizes it. This is the only place the two vocabularies meet.
+func masterPlugin(mode cloudv1alpha1.VPCAttachmentInterfaceMode) string {
+ if mode == cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor {
+ return galactic.PluginTap
+ }
+ return galactic.PluginVeth
+}
+
+// interfaceAddresses copies the addresses NSO allocated onto the attachment, so
+// the attachment describes itself without a second read.
+func interfaceAddresses(networkInterface *networkingv1alpha.NetworkInterface) []cloudv1alpha1.IPAddress {
+ addresses := make([]cloudv1alpha1.IPAddress, 0, len(networkInterface.Spec.Addresses))
+ for _, address := range networkInterface.Spec.Addresses {
+ addresses = append(addresses, cloudv1alpha1.IPAddress(address.Address))
+ }
+ return addresses
+}
+
+// allocateAttachmentIdentifier draws a random identifier unused within the VPC.
+// Random rather than lowest-free, so a freed identifier is not immediately
+// reissued while its BGPAdvertisement is still being garbage collected.
+func (r *NetworkInterfaceReconciler) allocateAttachmentIdentifier(ctx context.Context, vpc string) (string, error) {
+ var nads nadv1.NetworkAttachmentDefinitionList
+ if err := r.APIReader.List(ctx, &nads, client.MatchingLabels{LabelVPC: vpc}); err != nil {
+ return "", fmt.Errorf("list NetworkAttachmentDefinitions for VPC %s: %w", vpc, err)
+ }
+ used := make(map[string]struct{}, len(nads.Items))
+ for _, nad := range nads.Items {
+ if id := nad.Labels[LabelVPCAttachment]; id != "" {
+ used[id] = struct{}{}
+ }
+ }
+
+ for range maxIdentifierAttempts {
+ candidate, err := identifier.RandomVPCAttachmentBase62()
+ if err != nil {
+ return "", err
+ }
+ if _, taken := used[candidate]; !taken {
+ return candidate, nil
+ }
+ }
+ return "", fmt.Errorf("no unused attachment identifier found in VPC %s after %d attempts",
+ vpc, maxIdentifierAttempts)
+}
+
+// publishAttachmentStatus records the allocated identifiers on the attachment.
+func (r *NetworkInterfaceReconciler) publishAttachmentStatus(
+ ctx context.Context,
+ attachment *cloudv1alpha1.VPCAttachment,
+ vpc *cloudv1alpha1.VPC,
+ nad *nadv1.NetworkAttachmentDefinition,
+) error {
+ attachment.Status.VPC = vpc.Status.VPC
+ attachment.Status.VPCAttachment = nad.Labels[LabelVPCAttachment]
+ attachment.Status.NetworkAttachmentDefinition = nad.Name
+ attachment.Status.ObservedGeneration = attachment.Generation
+ meta.SetStatusCondition(&attachment.Status.Conditions, metav1.Condition{
+ Type: cloudv1alpha1.ConditionTypeReady,
+ Status: metav1.ConditionTrue,
+ Reason: "AttachmentDefinitionReady",
+ Message: fmt.Sprintf("NetworkAttachmentDefinition %s is ready for use", nad.Name),
+ ObservedGeneration: attachment.Generation,
+ })
+ if err := r.Status().Update(ctx, attachment); err != nil {
+ return fmt.Errorf("update VPC attachment %s status: %w", client.ObjectKeyFromObject(attachment), err)
+ }
+ return nil
+}
+
+// publishToInterface records what realizes the interface and which VPC it landed
+// in, then reports Prepared so compute can release the Pod.
+func (r *NetworkInterfaceReconciler) publishToInterface(
+ ctx context.Context,
+ networkInterface *networkingv1alpha.NetworkInterface,
+ attachment *cloudv1alpha1.VPCAttachment,
+ vpc *cloudv1alpha1.VPC,
+) error {
+ ref := &networkingv1alpha.NetworkInterfaceAttachmentRef{
+ APIGroup: cloudv1alpha1.GroupVersion.Group,
+ Kind: "VPCAttachment",
+ Name: attachment.Name,
+ }
+ networkInterface.Status.AttachmentRef = ref
+ networkInterface.Status.VPC = vpc.Status.VPC
+ return r.markPrepared(ctx, networkInterface, metav1.ConditionTrue, "AttachmentReady",
+ fmt.Sprintf("VPCAttachment %s and its attachment definition exist", attachment.Name))
+}
+
+// markPrepared reports whether the pre-Pod artifacts exist, on the interface and
+// on the claim holding it, which is what compute gates Pod creation on.
+func (r *NetworkInterfaceReconciler) markPrepared(
+ ctx context.Context,
+ networkInterface *networkingv1alpha.NetworkInterface,
+ status metav1.ConditionStatus,
+ reason, message string,
+) error {
+ condition := metav1.Condition{
+ Type: ConditionTypePrepared,
+ Status: status,
+ Reason: reason,
+ Message: message,
+ ObservedGeneration: networkInterface.Generation,
+ }
+ meta.SetStatusCondition(&networkInterface.Status.Conditions, condition)
+ if err := r.Status().Update(ctx, networkInterface); err != nil {
+ return fmt.Errorf("update network interface %s status: %w",
+ client.ObjectKeyFromObject(networkInterface), err)
+ }
+
+ if networkInterface.Spec.ClaimRef == nil {
+ return nil
+ }
+ var claim networkingv1alpha.NetworkInterfaceClaim
+ key := types.NamespacedName{Namespace: networkInterface.Namespace, Name: networkInterface.Spec.ClaimRef.Name}
+ if err := r.Get(ctx, key, &claim); err != nil {
+ return client.IgnoreNotFound(err)
+ }
+ claimCondition := condition
+ claimCondition.ObservedGeneration = claim.Generation
+ meta.SetStatusCondition(&claim.Status.Conditions, claimCondition)
+ if err := r.Status().Update(ctx, &claim); err != nil {
+ return fmt.Errorf("update network interface claim %s status: %w", key, err)
+ }
+ return nil
+}
+
+// SetupWithManager registers the reconciler with the manager.
+func (r *NetworkInterfaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ // A NAD is owned by the attachment rather than the interface, but all three
+ // share a name and namespace, so mapping one back is an identity.
+ nadToInterface := func(_ context.Context, obj client.Object) []ctrl.Request {
+ return []ctrl.Request{{NamespacedName: client.ObjectKeyFromObject(obj)}}
+ }
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&networkingv1alpha.NetworkInterface{}).
+ Owns(&cloudv1alpha1.VPCAttachment{}).
+ Watches(&nadv1.NetworkAttachmentDefinition{}, handler.EnqueueRequestsFromMapFunc(nadToInterface)).
+ Named("networkinterface").
+ Complete(r)
+}
diff --git a/internal/controller/networkinterface_controller_test.go b/internal/controller/networkinterface_controller_test.go
new file mode 100644
index 0000000..a2d7f89
--- /dev/null
+++ b/internal/controller/networkinterface_controller_test.go
@@ -0,0 +1,85 @@
+/*
+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 (
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
+ "go.datum.net/cloud/internal/galactic"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+func TestMasterPlugin(t *testing.T) {
+ tests := []struct {
+ mode cloudv1alpha1.VPCAttachmentInterfaceMode
+ want string
+ }{
+ {cloudv1alpha1.VPCAttachmentInterfaceModeNetns, galactic.PluginVeth},
+ {cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, galactic.PluginTap},
+ }
+ for _, test := range tests {
+ t.Run(string(test.mode), func(t *testing.T) {
+ if got := masterPlugin(test.mode); got != test.want {
+ t.Errorf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
+
+func TestClaimFulfilled(t *testing.T) {
+ newInterface := func(phase networkingv1alpha.NetworkInterfacePhase, allocated metav1.ConditionStatus,
+ context *networkingv1alpha.LocalNetworkContextRef) *networkingv1alpha.NetworkInterface {
+ return &networkingv1alpha.NetworkInterface{
+ Status: networkingv1alpha.NetworkInterfaceStatus{
+ Phase: phase,
+ NetworkContextRef: context,
+ Conditions: []metav1.Condition{{
+ Type: networkingv1alpha.NetworkInterfaceAllocated,
+ Status: allocated,
+ Reason: "Test",
+ }},
+ },
+ }
+ }
+ context := &networkingv1alpha.LocalNetworkContextRef{Name: "default-us-central-1"}
+
+ tests := []struct {
+ name string
+ networkInterface *networkingv1alpha.NetworkInterface
+ want bool
+ }{
+ {"bound and allocated", newInterface(
+ networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, context), true},
+ {"available", newInterface(
+ networkingv1alpha.NetworkInterfacePhaseAvailable, metav1.ConditionTrue, context), false},
+ {"not allocated", newInterface(
+ networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionFalse, context), false},
+ {"no network context", newInterface(
+ networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, nil), false},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if got := claimFulfilled(test.networkInterface); got != test.want {
+ t.Errorf("got %v, want %v", got, test.want)
+ }
+ })
+ }
+}
diff --git a/internal/galactic/galactic.go b/internal/galactic/galactic.go
new file mode 100644
index 0000000..75bc30a
--- /dev/null
+++ b/internal/galactic/galactic.go
@@ -0,0 +1,189 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package galactic is the vocabulary this operator shares with the galactic
+// data plane: the CNI conflist a NetworkAttachmentDefinition carries, the
+// kernel interface names derived from a (vpc, attachment) pair, and the
+// BGPAdvertisement annotations galactic publishes back.
+package galactic
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const (
+ // CNIVersion is the only conflist version galactic-bgp reconstructs a
+ // prevResult from; "1.1.0" also works, anything older fails every ADD.
+ CNIVersion = "1.0.0"
+
+ // PluginVeth is the master plugin for container attachments.
+ PluginVeth = "galactic-veth"
+
+ // PluginTap is the master plugin for virtual machine guests.
+ PluginTap = "galactic-tap"
+
+ // PluginBGP publishes the attachment into BGP and SRv6.
+ PluginBGP = "galactic-bgp"
+
+ // PluginIPAM is the delegated IPAM binary.
+ PluginIPAM = "galactic-ipam"
+
+ // SystemNamespace holds the BGP CRDs the chain reads and writes.
+ SystemNamespace = "galactic-system"
+)
+
+const (
+ // AnnotationNetNS is the BGPAdvertisement annotation key prefix carrying a
+ // container's netns path.
+ AnnotationNetNS = "galactic.datum.net/netns"
+
+ // AnnotationAllocatedSubnetIPv6 is the annotation key prefix carrying a
+ // container's allocated IPv6 pod subnet.
+ AnnotationAllocatedSubnetIPv6 = "galactic.datum.net/allocated-subnet-ipv6"
+
+ // AnnotationAllocatedSubnetIPv4 is the annotation key prefix carrying a
+ // container's allocated IPv4 pod address.
+ AnnotationAllocatedSubnetIPv4 = "galactic.datum.net/allocated-subnet-ipv4"
+
+ // AnnotationNoAddressing marks an advertisement whose guest manages its own
+ // addressing, so an empty prefix list is intentional.
+ AnnotationNoAddressing = "galactic.datum.net/no-addressing"
+
+ // ConditionAdvertised is set on a BGPAdvertisement from live GoBGP state.
+ ConditionAdvertised = "Advertised"
+)
+
+// NetConfList is a CNI conflist, the payload of a NAD's spec.config.
+type NetConfList struct {
+ CNIVersion string `json:"cniVersion"`
+ Name string `json:"name"`
+ Plugins []any `json:"plugins"`
+}
+
+// MasterPlugin is the galactic-veth or galactic-tap stanza.
+type MasterPlugin struct {
+ Type string `json:"type"`
+ VPC string `json:"vpc"`
+ VPCAttachment string `json:"vpcattachment"`
+ Namespace string `json:"namespace"`
+ MTU int32 `json:"mtu,omitempty"`
+ IPAM *IPAM `json:"ipam,omitempty"`
+}
+
+// BGPPlugin is the galactic-bgp stanza. It is never optional: the master plugin
+// fetches its own NAD and fails ADD before creating kernel state without it.
+type BGPPlugin struct {
+ Type string `json:"type"`
+ VPC string `json:"vpc"`
+ VPCAttachment string `json:"vpcattachment"`
+ Namespace string `json:"namespace"`
+}
+
+// IPAM is the delegated IPAM block. Presence alone decides whether IPAM runs.
+type IPAM struct {
+ Type string `json:"type"`
+ Addresses []Address `json:"addresses,omitempty"`
+}
+
+// Address is one pre-decided address, in CIDR notation.
+type Address struct {
+ Address string `json:"address"`
+}
+
+// Conflist renders the conflist for one attachment. Addresses are the addresses
+// NSO already allocated; an empty list means the guest addresses itself and no
+// IPAM block is emitted.
+func Conflist(name, plugin, vpc, vpcAttachment string, mtu int32, addresses []string) NetConfList {
+ master := MasterPlugin{
+ Type: plugin,
+ VPC: vpc,
+ VPCAttachment: vpcAttachment,
+ Namespace: SystemNamespace,
+ MTU: mtu,
+ }
+ if len(addresses) > 0 {
+ ipam := &IPAM{Type: PluginIPAM}
+ for _, address := range addresses {
+ ipam.Addresses = append(ipam.Addresses, Address{Address: address})
+ }
+ master.IPAM = ipam
+ }
+ return NetConfList{
+ CNIVersion: CNIVersion,
+ Name: name,
+ Plugins: []any{
+ master,
+ BGPPlugin{Type: PluginBGP, VPC: vpc, VPCAttachment: vpcAttachment, Namespace: SystemNamespace},
+ },
+ }
+}
+
+// ConflistJSON renders the conflist as the string a NAD's spec.config holds.
+func ConflistJSON(name, plugin, vpc, vpcAttachment string, mtu int32, addresses []string) (string, error) {
+ raw, err := json.Marshal(Conflist(name, plugin, vpc, vpcAttachment, mtu, addresses))
+ if err != nil {
+ return "", fmt.Errorf("marshal CNI conflist: %w", err)
+ }
+ return string(raw), nil
+}
+
+// HostInterfaceName returns the host-side veth or tap device name.
+func HostInterfaceName(vpc, vpcAttachment string) string {
+ return fmt.Sprintf("G%09s%03sH", vpc, vpcAttachment)
+}
+
+// GuestInterfaceName returns the guest-side veth device name.
+func GuestInterfaceName(vpc, vpcAttachment string) string {
+ return fmt.Sprintf("G%09s%03sG", vpc, vpcAttachment)
+}
+
+// VRFInterfaceName returns the VRF device name, which is per-VPC rather than
+// per-attachment.
+func VRFInterfaceName(vpc string) string {
+ return fmt.Sprintf("G%09sV", vpc)
+}
+
+// AdvertisementName returns the BGPAdvertisement name galactic derives from an
+// attachment.
+func AdvertisementName(vpc, vpcAttachment string) string {
+ return fmt.Sprintf("%s-%s", vpc, vpcAttachment)
+}
+
+// SplitAdvertisementName recovers the (vpc, attachment) pair from an
+// advertisement name.
+func SplitAdvertisementName(name string) (vpc, vpcAttachment string, ok bool) {
+ vpc, vpcAttachment, ok = strings.Cut(name, "-")
+ if !ok || vpc == "" || vpcAttachment == "" {
+ return "", "", false
+ }
+ return vpc, vpcAttachment, true
+}
+
+// AllocatedSubnets returns every allocated subnet recorded on an advertisement,
+// across both families and every container ID.
+func AllocatedSubnets(annotations map[string]string) []string {
+ var subnets []string
+ for key, value := range annotations {
+ if strings.HasPrefix(key, AnnotationAllocatedSubnetIPv6+".") ||
+ strings.HasPrefix(key, AnnotationAllocatedSubnetIPv4+".") {
+ subnets = append(subnets, value)
+ }
+ }
+ return subnets
+}
diff --git a/internal/galactic/galactic_test.go b/internal/galactic/galactic_test.go
new file mode 100644
index 0000000..a61b465
--- /dev/null
+++ b/internal/galactic/galactic_test.go
@@ -0,0 +1,119 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package galactic
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestConflistChainIsComplete(t *testing.T) {
+ conflist := Conflist("web-eth0", PluginTap, "0000000jU", "01a", 1400,
+ []string{"fd00:10:ff01:0:1::1/96", "172.20.1.7/32"})
+
+ if conflist.CNIVersion != "1.0.0" {
+ t.Errorf("cniVersion: got %q, want %q", conflist.CNIVersion, "1.0.0")
+ }
+ if len(conflist.Plugins) != 2 {
+ t.Fatalf("plugin count: got %d, want 2", len(conflist.Plugins))
+ }
+
+ master, ok := conflist.Plugins[0].(MasterPlugin)
+ if !ok {
+ t.Fatalf("first plugin: got %T, want MasterPlugin", conflist.Plugins[0])
+ }
+ if master.Type != PluginTap {
+ t.Errorf("master plugin: got %q, want %q", master.Type, PluginTap)
+ }
+ if master.IPAM == nil || len(master.IPAM.Addresses) != 2 {
+ t.Fatalf("ipam addresses: got %v, want two entries", master.IPAM)
+ }
+
+ // The master plugin fails ADD before creating kernel state without this.
+ bgp, ok := conflist.Plugins[1].(BGPPlugin)
+ if !ok {
+ t.Fatalf("second plugin: got %T, want BGPPlugin", conflist.Plugins[1])
+ }
+ if bgp.Type != PluginBGP {
+ t.Errorf("bgp plugin: got %q, want %q", bgp.Type, PluginBGP)
+ }
+}
+
+func TestConflistOmitsIPAMForSelfAddressingGuest(t *testing.T) {
+ raw, err := ConflistJSON("web-eth0", PluginTap, "0000000jU", "01a", 0, nil)
+ if err != nil {
+ t.Fatalf("ConflistJSON: %v", err)
+ }
+
+ var decoded struct {
+ Plugins []map[string]any `json:"plugins"`
+ }
+ if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
+ t.Fatalf("unmarshal conflist: %v", err)
+ }
+ if _, present := decoded.Plugins[0]["ipam"]; present {
+ t.Errorf("ipam block present for a guest managing its own addressing: %s", raw)
+ }
+ if _, present := decoded.Plugins[0]["mtu"]; present {
+ t.Errorf("mtu emitted when unset: %s", raw)
+ }
+}
+
+func TestInterfaceNames(t *testing.T) {
+ tests := []struct {
+ name string
+ got string
+ want string
+ }{
+ {"host", HostInterfaceName("jU", "1a"), "G0000000jU01aH"},
+ {"guest", GuestInterfaceName("jU", "1a"), "G0000000jU01aG"},
+ {"vrf", VRFInterfaceName("jU"), "G0000000jUV"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if test.got != test.want {
+ t.Errorf("got %q, want %q", test.got, test.want)
+ }
+ if len(test.got) > 15 {
+ t.Errorf("interface name %q exceeds the 15 character kernel limit", test.got)
+ }
+ })
+ }
+}
+
+func TestSplitAdvertisementName(t *testing.T) {
+ tests := []struct {
+ input string
+ vpc string
+ vpcAttachment string
+ ok bool
+ }{
+ {"0000000jU-01a", "0000000jU", "01a", true},
+ {"0000000jU", "", "", false},
+ {"-01a", "", "", false},
+ }
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ vpc, vpcAttachment, ok := SplitAdvertisementName(test.input)
+ if vpc != test.vpc || vpcAttachment != test.vpcAttachment || ok != test.ok {
+ t.Errorf("got (%q, %q, %v), want (%q, %q, %v)",
+ vpc, vpcAttachment, ok, test.vpc, test.vpcAttachment, test.ok)
+ }
+ })
+ }
+}
diff --git a/internal/identifier/identifier.go b/internal/identifier/identifier.go
new file mode 100644
index 0000000..5360aa8
--- /dev/null
+++ b/internal/identifier/identifier.go
@@ -0,0 +1,99 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package identifier allocates the VPC and VPC attachment identifiers the
+// galactic data plane keys on. Identifiers are generated as hex and published
+// as base62, which is what keeps a kernel interface name inside 15 characters.
+package identifier
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "strconv"
+ "strings"
+
+ "github.com/kenshaw/baseconv"
+)
+
+const (
+ // MaxVPC is the largest VPC identifier: 48 bits, nine base62 characters.
+ MaxVPC uint64 = 0xFFFFFFFFFFFF
+
+ // MaxVPCAttachment is the largest attachment identifier: 16 bits, three
+ // base62 characters.
+ MaxVPCAttachment uint64 = 0xFFFF
+)
+
+// Hex renders value as a zero-padded hex string wide enough to hold max.
+// Zero and max are reserved.
+func Hex(value, max uint64) (string, error) {
+ if value == 0 || value == max {
+ return "", fmt.Errorf("%d is a reserved identifier value", value)
+ }
+ if value > max {
+ return "", fmt.Errorf("%d exceeds the maximum identifier value %d", value, max)
+ }
+ return fmt.Sprintf("%0*x", len(strconv.FormatUint(max, 16)), value), nil
+}
+
+// Random returns a random hex identifier in (0, max).
+func Random(max uint64) (string, error) {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(max-1)))
+ if err != nil {
+ return "", fmt.Errorf("draw random identifier: %w", err)
+ }
+ return Hex(n.Uint64()+1, max)
+}
+
+// RandomVPC returns a random 48-bit VPC identifier in hex.
+func RandomVPC() (string, error) {
+ return Random(MaxVPC)
+}
+
+// RandomVPCAttachment returns a random 16-bit attachment identifier in hex.
+func RandomVPCAttachment() (string, error) {
+ return Random(MaxVPCAttachment)
+}
+
+// HexToBase62 converts a hex identifier to the base62 form the CNI chain reads.
+func HexToBase62(value string) (string, error) {
+ return baseconv.Convert(strings.ToLower(value), baseconv.DigitsHex, baseconv.Digits62)
+}
+
+// Base62ToHex converts a base62 identifier back to lowercase hex.
+func Base62ToHex(value string) (string, error) {
+ return baseconv.Convert(value, baseconv.Digits62, baseconv.DigitsHex)
+}
+
+// RandomVPCBase62 returns a random VPC identifier in base62.
+func RandomVPCBase62() (string, error) {
+ hex, err := RandomVPC()
+ if err != nil {
+ return "", err
+ }
+ return HexToBase62(hex)
+}
+
+// RandomVPCAttachmentBase62 returns a random attachment identifier in base62.
+func RandomVPCAttachmentBase62() (string, error) {
+ hex, err := RandomVPCAttachment()
+ if err != nil {
+ return "", err
+ }
+ return HexToBase62(hex)
+}
diff --git a/internal/identifier/identifier_test.go b/internal/identifier/identifier_test.go
new file mode 100644
index 0000000..938633b
--- /dev/null
+++ b/internal/identifier/identifier_test.go
@@ -0,0 +1,75 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package identifier
+
+import "testing"
+
+func TestHexRejectsReservedValues(t *testing.T) {
+ for _, value := range []uint64{0, MaxVPC, MaxVPC + 1} {
+ if _, err := Hex(value, MaxVPC); err == nil {
+ t.Errorf("Hex(%d): got no error, want one", value)
+ }
+ }
+}
+
+func TestRandomIdentifiersFitTheirInterfaceNameSegment(t *testing.T) {
+ for range 200 {
+ vpc, err := RandomVPCBase62()
+ if err != nil {
+ t.Fatalf("RandomVPCBase62: %v", err)
+ }
+ if len(vpc) > 9 {
+ t.Errorf("VPC identifier %q exceeds nine base62 characters", vpc)
+ }
+
+ attachment, err := RandomVPCAttachmentBase62()
+ if err != nil {
+ t.Fatalf("RandomVPCAttachmentBase62: %v", err)
+ }
+ if len(attachment) > 3 {
+ t.Errorf("attachment identifier %q exceeds three base62 characters", attachment)
+ }
+ }
+}
+
+func TestBase62RoundTrip(t *testing.T) {
+ hex, err := RandomVPC()
+ if err != nil {
+ t.Fatalf("RandomVPC: %v", err)
+ }
+ base62, err := HexToBase62(hex)
+ if err != nil {
+ t.Fatalf("HexToBase62(%q): %v", hex, err)
+ }
+ back, err := Base62ToHex(base62)
+ if err != nil {
+ t.Fatalf("Base62ToHex(%q): %v", base62, err)
+ }
+ if want := trimLeadingZeros(hex); back != want {
+ t.Errorf("round trip: got %q, want %q", back, want)
+ }
+}
+
+func trimLeadingZeros(value string) string {
+ for i := range value {
+ if value[i] != '0' {
+ return value[i:]
+ }
+ }
+ return "0"
+}
diff --git a/internal/webhook/pod_webhook.go b/internal/webhook/pod_webhook.go
new file mode 100644
index 0000000..d12b1dc
--- /dev/null
+++ b/internal/webhook/pod_webhook.go
@@ -0,0 +1,202 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package webhook delivers a prepared attachment to the Pod that consumes it.
+// Injecting the annotation here is what keeps Multus knowledge inside the one
+// component that writes NetworkAttachmentDefinitions.
+package webhook
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+
+ nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+ "go.datum.net/cloud/internal/controller"
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+)
+
+const (
+ // InjectInterfacesLabel is the opt-in an infrastructure provider stamps on a
+ // Pod. It is a label rather than an annotation because objectSelector matches
+ // only labels, and that selector is what bounds a failurePolicy of Fail to
+ // the Pods that need an interface.
+ InjectInterfacesLabel = "networking.datumapis.com/inject-interfaces"
+
+ // InjectedInterfacesAnnotation records which interfaces were injected, so the
+ // difference between the Pod applied and the Pod admitted is traceable.
+ InjectedInterfacesAnnotation = "networking.datumapis.com/injected-interfaces"
+
+ // MultusNetworksAnnotation is what Multus resolves at sandbox creation.
+ MultusNetworksAnnotation = "k8s.v1.cni.cncf.io/networks"
+
+ // WebhookPath is the path the mutating webhook configuration points at.
+ WebhookPath = "/mutate-v1-pod"
+
+ // instanceKind is the owner a Pod must resolve to for interfaces to inject.
+ instanceKind = "Instance"
+)
+
+// PodInterfaceInjector injects the attachments prepared for a Pod's instance.
+type PodInterfaceInjector struct {
+ client.Client
+ Decoder admission.Decoder
+}
+
+// +kubebuilder:rbac:groups=compute.datumapis.com,resources=instances,verbs=get;list;watch
+
+// Handle resolves a Pod to its instance's interfaces and injects the annotation
+// that delivers them.
+func (i *PodInterfaceInjector) Handle(ctx context.Context, req admission.Request) admission.Response {
+ pod := &corev1.Pod{}
+ if err := i.Decoder.Decode(req, pod); err != nil {
+ return admission.Errored(http.StatusBadRequest, err)
+ }
+ if pod.Labels[InjectInterfacesLabel] != "true" {
+ return admission.Allowed("pod did not opt in to interface injection")
+ }
+
+ namespace := req.Namespace
+ instanceName, found := instanceOwner(pod)
+ if !found {
+ return admission.Denied(fmt.Sprintf(
+ "pod carries %s but is not owned by a %s, so its interfaces cannot be resolved",
+ InjectInterfacesLabel, instanceKind))
+ }
+
+ var instance computev1alpha.Instance
+ if err := i.Get(ctx, types.NamespacedName{Namespace: namespace, Name: instanceName}, &instance); err != nil {
+ if apierrors.IsNotFound(err) {
+ return admission.Denied(fmt.Sprintf("instance %s/%s does not exist", namespace, instanceName))
+ }
+ return admission.Errored(http.StatusInternalServerError, err)
+ }
+
+ networks, injected, response := i.resolveNetworks(ctx, &instance, namespace)
+ if response != nil {
+ return *response
+ }
+ if len(networks) == 0 {
+ return admission.Allowed("instance declares no network interfaces")
+ }
+
+ if pod.Annotations == nil {
+ pod.Annotations = map[string]string{}
+ }
+ pod.Annotations[MultusNetworksAnnotation] = mergeNetworks(pod.Annotations[MultusNetworksAnnotation], networks)
+ pod.Annotations[InjectedInterfacesAnnotation] = strings.Join(injected, ",")
+
+ patched, err := json.Marshal(pod)
+ if err != nil {
+ return admission.Errored(http.StatusInternalServerError, err)
+ }
+ ctrl.LoggerFrom(ctx).Info("injected network interfaces into pod",
+ "instance", instanceName, "interfaces", injected, "networks", pod.Annotations[MultusNetworksAnnotation])
+ return admission.PatchResponseFromRaw(req.Object.Raw, patched)
+}
+
+// resolveNetworks walks the instance's interfaces in declared order and finds the
+// attachment definition prepared for each.
+func (i *PodInterfaceInjector) resolveNetworks(
+ ctx context.Context, instance *computev1alpha.Instance, namespace string,
+) (networks, injected []string, denied *admission.Response) {
+ for _, declared := range instance.Spec.NetworkInterfaces {
+ interfaceName := interfaceRefFor(instance, declared.Name)
+ if interfaceName == "" {
+ response := admission.Denied(fmt.Sprintf(
+ "instance %s has no bound NetworkInterface for %s yet", instance.Name, declared.Name))
+ return nil, nil, &response
+ }
+
+ // The controller created this NAD alongside the interface, so it shares
+ // its name; the convention never crosses a component boundary.
+ var nad nadv1.NetworkAttachmentDefinition
+ key := types.NamespacedName{Namespace: namespace, Name: interfaceName}
+ if err := i.Get(ctx, key, &nad); err != nil {
+ response := admission.Denied(fmt.Sprintf(
+ "no attachment definition prepared for NetworkInterface %s: %v", key, err))
+ return nil, nil, &response
+ }
+ if _, ours := nad.Labels[controller.LabelVPCAttachment]; !ours {
+ response := admission.Denied(fmt.Sprintf(
+ "attachment definition %s carries no attachment identifier", key))
+ return nil, nil, &response
+ }
+
+ networks = append(networks, fmt.Sprintf("%s/%s", nad.Namespace, nad.Name))
+ injected = append(injected, interfaceName)
+ }
+ return networks, injected, nil
+}
+
+// interfaceRefFor returns the NetworkInterface bound to an instance's entry.
+func interfaceRefFor(instance *computev1alpha.Instance, name string) string {
+ for _, status := range instance.Status.NetworkInterfaces {
+ if status.Name != name || status.NetworkInterfaceRef == nil {
+ continue
+ }
+ return status.NetworkInterfaceRef.Name
+ }
+ return ""
+}
+
+// instanceOwner returns the name of the Instance controlling a Pod.
+func instanceOwner(pod *corev1.Pod) (string, bool) {
+ for _, owner := range pod.OwnerReferences {
+ if owner.Kind != instanceKind || owner.Controller == nil || !*owner.Controller {
+ continue
+ }
+ if group, _, _ := strings.Cut(owner.APIVersion, "/"); group != computev1alpha.GroupVersion.Group {
+ continue
+ }
+ return owner.Name, true
+ }
+ return "", false
+}
+
+// mergeNetworks appends the resolved networks to whatever the Pod already asked
+// for, without duplicating an entry.
+func mergeNetworks(existing string, networks []string) string {
+ merged := []string{}
+ for _, entry := range strings.Split(existing, ",") {
+ if entry = strings.TrimSpace(entry); entry != "" {
+ merged = append(merged, entry)
+ }
+ }
+ for _, network := range networks {
+ if !slices.Contains(merged, network) {
+ merged = append(merged, network)
+ }
+ }
+ return strings.Join(merged, ",")
+}
+
+// SetupWithManager registers the webhook with the manager's webhook server.
+func (i *PodInterfaceInjector) SetupWithManager(mgr ctrl.Manager) {
+ i.Decoder = admission.NewDecoder(mgr.GetScheme())
+ mgr.GetWebhookServer().Register(WebhookPath, &admission.Webhook{Handler: i})
+}
diff --git a/internal/webhook/pod_webhook_test.go b/internal/webhook/pod_webhook_test.go
new file mode 100644
index 0000000..86b31af
--- /dev/null
+++ b/internal/webhook/pod_webhook_test.go
@@ -0,0 +1,100 @@
+/*
+Copyright © 2026 Datum Technology, Inc. All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+package webhook
+
+import (
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/utils/ptr"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+)
+
+func TestInstanceOwner(t *testing.T) {
+ tests := []struct {
+ name string
+ owner metav1.OwnerReference
+ want string
+ }{
+ {"controlling instance", metav1.OwnerReference{
+ APIVersion: "compute.datumapis.com/v1alpha", Kind: "Instance",
+ Name: "web-0", Controller: ptr.To(true)}, "web-0"},
+ {"not controlling", metav1.OwnerReference{
+ APIVersion: "compute.datumapis.com/v1alpha", Kind: "Instance", Name: "web-0"}, ""},
+ {"another group's instance", metav1.OwnerReference{
+ APIVersion: "example.com/v1", Kind: "Instance",
+ Name: "web-0", Controller: ptr.To(true)}, ""},
+ {"a replicaset", metav1.OwnerReference{
+ APIVersion: "apps/v1", Kind: "ReplicaSet",
+ Name: "web", Controller: ptr.To(true)}, ""},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{OwnerReferences: []metav1.OwnerReference{test.owner}}}
+ got, found := instanceOwner(pod)
+ if got != test.want || found != (test.want != "") {
+ t.Errorf("got (%q, %v), want (%q, %v)", got, found, test.want, test.want != "")
+ }
+ })
+ }
+}
+
+func TestInterfaceRefFor(t *testing.T) {
+ instance := &computev1alpha.Instance{
+ Status: computev1alpha.InstanceStatus{
+ NetworkInterfaces: []computev1alpha.InstanceNetworkInterfaceStatus{
+ {Name: "eth0", NetworkInterfaceRef: &networkingv1alpha.LocalNetworkInterfaceRef{Name: "web-0-eth0"}},
+ {Name: "eth1"},
+ },
+ },
+ }
+ if got := interfaceRefFor(instance, "eth0"); got != "web-0-eth0" {
+ t.Errorf("bound interface: got %q, want %q", got, "web-0-eth0")
+ }
+ if got := interfaceRefFor(instance, "eth1"); got != "" {
+ t.Errorf("unbound interface: got %q, want empty", got)
+ }
+ if got := interfaceRefFor(instance, "eth9"); got != "" {
+ t.Errorf("undeclared interface: got %q, want empty", got)
+ }
+}
+
+func TestMergeNetworks(t *testing.T) {
+ tests := []struct {
+ name string
+ existing string
+ networks []string
+ want string
+ }{
+ {"empty", "", []string{"ns/a"}, "ns/a"},
+ {"appends in order", "", []string{"ns/a", "ns/b"}, "ns/a,ns/b"},
+ {"preserves what the pod asked for", "ns/other", []string{"ns/a"}, "ns/other,ns/a"},
+ {"does not duplicate", "ns/a", []string{"ns/a"}, "ns/a"},
+ {"tolerates whitespace", " ns/other , ", []string{"ns/a"}, "ns/other,ns/a"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if got := mergeNetworks(test.existing, test.networks); got != test.want {
+ t.Errorf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
diff --git a/test/e2e/tests/vpc-crd-schema/chainsaw-test.yaml b/test/e2e/tests/vpc-crd-schema/chainsaw-test.yaml
index ad13353..e683b1f 100644
--- a/test/e2e/tests/vpc-crd-schema/chainsaw-test.yaml
+++ b/test/e2e/tests/vpc-crd-schema/chainsaw-test.yaml
@@ -182,7 +182,7 @@ spec:
fi
echo "OK: interface name defaulted to eth0"
- - name: reject-vpcattachment-empty-addresses
+ - name: accept-vpcattachment-empty-addresses
try:
- script:
content: |
@@ -191,7 +191,7 @@ spec:
apiVersion: cloud.datumapis.com/v1alpha1
kind: VPCAttachment
metadata:
- name: e2e-invalid-attachment
+ name: e2e-no-addressing-attachment
spec:
vpc:
name: e2e-valid-vpc
@@ -202,12 +202,46 @@ spec:
)
EXIT=$?
set -e
+ if [ "$EXIT" -ne 0 ]; then
+ echo "ERROR: expected a guest managing its own addressing to be accepted"
+ echo "Server response: $OUTPUT"
+ exit 1
+ fi
+ MODE=$(kubectl get vpcattachments.cloud.datumapis.com e2e-no-addressing-attachment \
+ -n "$NAMESPACE" -o jsonpath='{.spec.interface.mode}')
+ if [ "$MODE" != "Netns" ]; then
+ echo "ERROR: expected interface mode default 'Netns' but got '$MODE'"
+ exit 1
+ fi
+ kubectl delete vpcattachments.cloud.datumapis.com e2e-no-addressing-attachment -n "$NAMESPACE"
+ echo "OK: empty addresses accepted, interface mode defaulted to Netns"
+
+ - name: reject-vpcattachment-invalid-interface-mode
+ try:
+ - script:
+ content: |
+ set +e
+ OUTPUT=$(kubectl apply -n "$NAMESPACE" -f - 2>&1 <<'EOF'
+ apiVersion: cloud.datumapis.com/v1alpha1
+ kind: VPCAttachment
+ metadata:
+ name: e2e-invalid-attachment
+ spec:
+ vpc:
+ name: e2e-valid-vpc
+ interface:
+ name: eth0
+ mode: tap
+ EOF
+ )
+ EXIT=$?
+ set -e
if [ "$EXIT" -eq 0 ]; then
- echo "ERROR: expected rejection of empty addresses array but resource was accepted"
+ echo "ERROR: expected rejection of unsupported interface mode but resource was accepted"
kubectl delete vpcattachments.cloud.datumapis.com e2e-invalid-attachment -n "$NAMESPACE" 2>/dev/null || true
exit 1
fi
- echo "OK: empty addresses array correctly rejected"
+ echo "OK: unsupported interface mode correctly rejected"
echo "Server response: $OUTPUT"
- name: reject-vpcattachment-missing-vpc