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