From dfd1c81e410a72834d9685434418b09865cafbed Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 16 Oct 2026 09:51:13 -0500 Subject: [PATCH 1/2] Reprogram NSO claims on Galactic attachment migration --- .../controller/bgpadvertisement_controller.go | 65 +++++++++++ .../bgpadvertisement_controller_test.go | 101 ++++++++++++++++++ .../controller/networkinterface_controller.go | 21 +++- .../networkinterface_controller_test.go | 18 +++- 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 internal/cloud/controller/bgpadvertisement_controller_test.go diff --git a/internal/cloud/controller/bgpadvertisement_controller.go b/internal/cloud/controller/bgpadvertisement_controller.go index c116ba8b..92d5d7ff 100644 --- a/internal/cloud/controller/bgpadvertisement_controller.go +++ b/internal/cloud/controller/bgpadvertisement_controller.go @@ -26,7 +26,12 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" bgpv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" @@ -114,6 +119,13 @@ func (r *BGPAdvertisementReconciler) projectOntoInterface( if err := r.Get(ctx, key, &networkInterface); err != nil { return client.IgnoreNotFound(err) } + // An advertisement from the new group cannot certify an interface that + // still points at the former attachment API. + if networkInterface.Status.AttachmentRef == nil || + networkInterface.Status.AttachmentRef.APIGroup != cloudv1alpha1.GroupVersion.Group || + networkInterface.Status.AttachmentRef.Name != attachment.Name { + return nil + } interfaceProgrammed := programmed interfaceProgrammed.Type = networkingv1alpha.NetworkInterfaceProgrammed @@ -122,9 +134,50 @@ func (r *BGPAdvertisementReconciler) projectOntoInterface( if err := r.Status().Update(ctx, &networkInterface); err != nil { return fmt.Errorf("update network interface %s status: %w", key, err) } + if networkInterface.Spec.ClaimRef == nil { + return nil + } + claimKey := types.NamespacedName{Namespace: networkInterface.Namespace, Name: networkInterface.Spec.ClaimRef.Name} + var claim networkingv1alpha.NetworkInterfaceClaim + if err := r.Get(ctx, claimKey, &claim); err != nil { + return client.IgnoreNotFound(err) + } + claimProgrammed := programmed + claimProgrammed.Type = networkingv1alpha.NetworkInterfaceClaimProgrammed + claimProgrammed.ObservedGeneration = claim.Generation + meta.SetStatusCondition(&claim.Status.Conditions, claimProgrammed) + if programmed.Status != metav1.ConditionTrue { + meta.SetStatusCondition(&claim.Status.Conditions, metav1.Condition{ + Type: networkingv1alpha.NetworkInterfaceClaimReady, Status: programmed.Status, + Reason: "NotProgrammed", Message: programmed.Message, ObservedGeneration: claim.Generation, + }) + } + if err := r.Status().Update(ctx, &claim); err != nil { + return fmt.Errorf("update network interface claim %s status: %w", claimKey, err) + } return nil } +// advertisementForInterface replays an existing advertisement when an +// interface switches to the Galactic API group after the data plane reported. +func (r *BGPAdvertisementReconciler) advertisementForInterface(ctx context.Context, obj client.Object) []reconcile.Request { + iface, ok := obj.(*networkingv1alpha.NetworkInterface) + if !ok || iface.Status.AttachmentRef == nil || + iface.Status.AttachmentRef.APIGroup != cloudv1alpha1.GroupVersion.Group { + return nil + } + var attachment cloudv1alpha1.VPCAttachment + key := types.NamespacedName{Namespace: iface.Namespace, Name: iface.Status.AttachmentRef.Name} + if err := r.Get(ctx, key, &attachment); err != nil || + attachment.Status.VPC == "" || attachment.Status.VPCAttachment == "" { + return nil + } + return []reconcile.Request{{NamespacedName: types.NamespacedName{ + Namespace: iface.Namespace, + Name: galactic.AdvertisementName(attachment.Status.VPC, attachment.Status.VPCAttachment), + }}} +} + // nodeForRouter resolves the node a BGPRouter executes on. func (r *BGPAdvertisementReconciler) nodeForRouter(ctx context.Context, namespace, name string) (string, error) { if name == "" { @@ -162,6 +215,18 @@ func programmedCondition(advertisement *bgpv1alpha1.BGPAdvertisement) metav1.Con func (r *BGPAdvertisementReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&bgpv1alpha1.BGPAdvertisement{}). + Watches(&networkingv1alpha.NetworkInterface{}, + handler.EnqueueRequestsFromMapFunc(r.advertisementForInterface), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldIface, oldOK := e.ObjectOld.(*networkingv1alpha.NetworkInterface) + newIface, newOK := e.ObjectNew.(*networkingv1alpha.NetworkInterface) + return oldOK && newOK && oldIface.Status.AttachmentRef != nil && + newIface.Status.AttachmentRef != nil && + oldIface.Status.AttachmentRef.APIGroup != newIface.Status.AttachmentRef.APIGroup && + newIface.Status.AttachmentRef.APIGroup == cloudv1alpha1.GroupVersion.Group + }, + })). Named("bgpadvertisement"). Complete(r) } diff --git a/internal/cloud/controller/bgpadvertisement_controller_test.go b/internal/cloud/controller/bgpadvertisement_controller_test.go new file mode 100644 index 00000000..dc4fa45c --- /dev/null +++ b/internal/cloud/controller/bgpadvertisement_controller_test.go @@ -0,0 +1,101 @@ +/* +Copyright © 2026 Datum Technology, Inc. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package controller + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" + "go.datum.net/galactic/internal/cloud/galactic" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func TestAdvertisementReprogramsGalacticInterfaceAndClaim(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} + attachment.Spec.InterfaceRef = &cloudv1alpha1.NetworkInterfaceRef{Name: "nic"} + attachment.Status.VPC = "vpc-1" + attachment.Status.VPCAttachment = "attachment-1" + iface := &networkingv1alpha.NetworkInterface{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} + iface.Spec.ClaimRef = &networkingv1alpha.NetworkInterfaceClaimRef{Name: "claim"} + iface.Status.AttachmentRef = &networkingv1alpha.NetworkInterfaceAttachmentRef{ + APIGroup: cloudv1alpha1.GroupVersion.Group, Kind: "VPCAttachment", Name: "nic", + } + claim := &networkingv1alpha.NetworkInterfaceClaim{ObjectMeta: metav1.ObjectMeta{Name: "claim", Namespace: "tenant"}} + meta.SetStatusCondition(&claim.Status.Conditions, metav1.Condition{ + Type: networkingv1alpha.NetworkInterfaceClaimProgrammed, Status: metav1.ConditionUnknown, Reason: reasonAPIGroupMigration, + }) + cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface, claim, attachment). + WithObjects(iface, claim, attachment).Build() + r := &BGPAdvertisementReconciler{Client: cl} + programmed := metav1.Condition{Type: cloudv1alpha1.ConditionTypeProgrammed, Status: metav1.ConditionTrue, Reason: "Advertised"} + legacy := iface.DeepCopy() + legacy.Status.AttachmentRef.APIGroup = "cloud.datumapis.com" + if err := cl.Status().Update(ctx, legacy); err != nil { + t.Fatal(err) + } + if err := r.projectOntoInterface(ctx, attachment, programmed); err != nil { + t.Fatal(err) + } + var before networkingv1alpha.NetworkInterfaceClaim + if err := cl.Get(ctx, client.ObjectKeyFromObject(claim), &before); err != nil { + t.Fatal(err) + } + if condition := meta.FindStatusCondition(before.Status.Conditions, networkingv1alpha.NetworkInterfaceClaimProgrammed); condition == nil || condition.Status != metav1.ConditionUnknown { + t.Fatalf("legacy claim Programmed = %#v, want Unknown", condition) + } + legacy.Status.AttachmentRef.APIGroup = cloudv1alpha1.GroupVersion.Group + if err := cl.Status().Update(ctx, legacy); err != nil { + t.Fatal(err) + } + requests := r.advertisementForInterface(ctx, iface) + if len(requests) != 1 || requests[0].Name != galactic.AdvertisementName("vpc-1", "attachment-1") { + t.Fatalf("advertisement replay requests = %#v", requests) + } + if err := r.projectOntoInterface(ctx, attachment, programmed); err != nil { + t.Fatal(err) + } + var got networkingv1alpha.NetworkInterface + if err := cl.Get(ctx, client.ObjectKeyFromObject(iface), &got); err != nil { + t.Fatal(err) + } + if condition := meta.FindStatusCondition(got.Status.Conditions, networkingv1alpha.NetworkInterfaceProgrammed); condition == nil || condition.Status != metav1.ConditionTrue { + t.Fatalf("interface Programmed = %#v, want True", condition) + } + var gotClaim networkingv1alpha.NetworkInterfaceClaim + if err := cl.Get(ctx, client.ObjectKeyFromObject(claim), &gotClaim); err != nil { + t.Fatal(err) + } + if condition := meta.FindStatusCondition(gotClaim.Status.Conditions, networkingv1alpha.NetworkInterfaceClaimProgrammed); condition == nil || condition.Status != metav1.ConditionTrue { + t.Fatalf("claim Programmed = %#v, want True", condition) + } +} diff --git a/internal/cloud/controller/networkinterface_controller.go b/internal/cloud/controller/networkinterface_controller.go index 085ec819..a3c2ef48 100644 --- a/internal/cloud/controller/networkinterface_controller.go +++ b/internal/cloud/controller/networkinterface_controller.go @@ -49,6 +49,7 @@ const ( legacyLabelVPC = "cloud.datumapis.com/vpc" legacyLabelVPCAttachment = "cloud.datumapis.com/vpc-attachment" legacyAttachmentKind = "VPCAttachment" + reasonAPIGroupMigration = "APIGroupMigration" // AnnotationHostInterface records the host device this attachment will get, // on the NAD, at the moment the NAD is written. The name is derived from the @@ -374,7 +375,7 @@ func (r *NetworkInterfaceReconciler) publishToInterface( meta.SetStatusCondition(&networkInterface.Status.Conditions, metav1.Condition{ Type: networkingv1alpha.NetworkInterfaceProgrammed, Status: metav1.ConditionUnknown, - Reason: "APIGroupMigration", + Reason: reasonAPIGroupMigration, Message: "waiting for the Galactic attachment to be programmed", ObservedGeneration: networkInterface.Generation, }) @@ -417,6 +418,24 @@ func (r *NetworkInterfaceReconciler) markPrepared( claimCondition := condition claimCondition.ObservedGeneration = claim.Generation meta.SetStatusCondition(&claim.Status.Conditions, claimCondition) + // A claim can have Programmed and Ready from the old attachment even after + // its interface points at Galactic. Invalidate both until the new group's + // advertisement reports that the replacement attachment is carrying traffic. + if programmed := meta.FindStatusCondition(networkInterface.Status.Conditions, + networkingv1alpha.NetworkInterfaceProgrammed); programmed != nil && + programmed.Status == metav1.ConditionUnknown && programmed.Reason == reasonAPIGroupMigration { + claimProgrammed := *programmed + claimProgrammed.Type = networkingv1alpha.NetworkInterfaceClaimProgrammed + claimProgrammed.ObservedGeneration = claim.Generation + meta.SetStatusCondition(&claim.Status.Conditions, claimProgrammed) + meta.SetStatusCondition(&claim.Status.Conditions, metav1.Condition{ + Type: networkingv1alpha.NetworkInterfaceClaimReady, + Status: metav1.ConditionUnknown, + Reason: "NotProgrammed", + Message: "waiting for the Galactic attachment to be programmed", + ObservedGeneration: claim.Generation, + }) + } if err := r.Status().Update(ctx, &claim); err != nil { return fmt.Errorf("update network interface claim %s status: %w", key, err) } diff --git a/internal/cloud/controller/networkinterface_controller_test.go b/internal/cloud/controller/networkinterface_controller_test.go index 9c58e540..c839e987 100644 --- a/internal/cloud/controller/networkinterface_controller_test.go +++ b/internal/cloud/controller/networkinterface_controller_test.go @@ -77,6 +77,12 @@ func TestPublishToInterfaceReprogramsNSOAttachmentGroup(t *testing.T) { t.Fatal(err) } iface := &networkingv1alpha.NetworkInterface{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} + iface.Spec.ClaimRef = &networkingv1alpha.NetworkInterfaceClaimRef{Name: "claim"} + claim := &networkingv1alpha.NetworkInterfaceClaim{ObjectMeta: metav1.ObjectMeta{Name: "claim", Namespace: "tenant"}} + claim.Status.Conditions = []metav1.Condition{ + {Type: networkingv1alpha.NetworkInterfaceClaimProgrammed, Status: metav1.ConditionTrue, Reason: "OldGroupProgrammed", LastTransitionTime: metav1.Now()}, + {Type: networkingv1alpha.NetworkInterfaceClaimReady, Status: metav1.ConditionTrue, Reason: "OldGroupReady", LastTransitionTime: metav1.Now()}, + } iface.Status.AttachmentRef = &networkingv1alpha.NetworkInterfaceAttachmentRef{ APIGroup: "cloud.datumapis.com", Kind: "VPCAttachment", Name: "nic", } @@ -84,7 +90,7 @@ func TestPublishToInterfaceReprogramsNSOAttachmentGroup(t *testing.T) { Type: networkingv1alpha.NetworkInterfaceProgrammed, Status: metav1.ConditionTrue, Reason: "OldGroupProgrammed", LastTransitionTime: metav1.Now(), }} - cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface).WithObjects(iface).Build() + cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface, claim).WithObjects(iface, claim).Build() r := &NetworkInterfaceReconciler{Client: cl} attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} vpc := &cloudv1alpha1.VPC{} @@ -103,6 +109,16 @@ func TestPublishToInterfaceReprogramsNSOAttachmentGroup(t *testing.T) { if condition == nil || condition.Status != metav1.ConditionUnknown || condition.Reason != "APIGroupMigration" { t.Fatalf("Programmed condition = %#v, want Unknown/APIGroupMigration", condition) } + var gotClaim networkingv1alpha.NetworkInterfaceClaim + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(claim), &gotClaim); err != nil { + t.Fatal(err) + } + for _, conditionType := range []string{networkingv1alpha.NetworkInterfaceClaimProgrammed, networkingv1alpha.NetworkInterfaceClaimReady} { + condition := apimeta.FindStatusCondition(gotClaim.Status.Conditions, conditionType) + if condition == nil || condition.Status != metav1.ConditionUnknown { + t.Fatalf("claim %s condition = %#v, want Unknown during migration", conditionType, condition) + } + } } func TestMasterPlugin(t *testing.T) { From 9e9b2cd8a1271cdb4ef898e31862f27ed9105561 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 16 Oct 2026 10:24:03 -0500 Subject: [PATCH 2/2] Leave Galactic cell controller with data plane status ownership --- cmd/galactic-controller/main.go | 38 +- .../galactic-controller/base/deployment.yaml | 2 - config/galactic-controller/rbac.yaml | 13 +- docs/migration/galactic-api-consolidation.md | 28 +- .../bgpadvertisement_controller_test.go | 2 +- .../controller/networkcontext_controller.go | 251 ---------- .../networkcontext_controller_test.go | 244 ---------- .../controller/networkinterface_controller.go | 459 ------------------ .../networkinterface_controller_test.go | 235 --------- .../cloud/controller/subnet_controller.go | 180 ------- .../controller/subnet_controller_test.go | 163 ------- internal/cloud/galactic/galactic.go | 2 + internal/cloud/webhook/pod_webhook.go | 4 +- 13 files changed, 29 insertions(+), 1592 deletions(-) delete mode 100644 internal/cloud/controller/networkcontext_controller.go delete mode 100644 internal/cloud/controller/networkcontext_controller_test.go delete mode 100644 internal/cloud/controller/networkinterface_controller.go delete mode 100644 internal/cloud/controller/networkinterface_controller_test.go delete mode 100644 internal/cloud/controller/subnet_controller.go delete mode 100644 internal/cloud/controller/subnet_controller_test.go diff --git a/cmd/galactic-controller/main.go b/cmd/galactic-controller/main.go index 3b82f106..ef113f53 100644 --- a/cmd/galactic-controller/main.go +++ b/cmd/galactic-controller/main.go @@ -56,7 +56,6 @@ type options struct { webhookCertDir string leaderElect bool controllerUsername string - attachmentMode string identityClass string identityNamespace string platformProject string @@ -94,8 +93,6 @@ func newRootCommand() *cobra.Command { f.StringVar(&o.controllerUsername, "controller-username", "", "Authenticated username this controller runs as, the only one allowed to write the reserved "+ "default policy (default system:serviceaccount::galactic-controller)") - f.StringVar(&o.attachmentMode, "attachment-mode", "", - "Required for cell controllers: Netns, Hypervisor or HypervisorDeclared") f.StringVar(&o.identityClass, "identity-class", "", "Fabric role: IPClass for globally unique fabric identities") f.StringVar(&o.identityNamespace, "identity-namespace", "default", "Fabric role: namespace for platform IP claims") f.StringVar(&o.platformProject, "platform-project", "", "Fabric role: platform project for IPAM") @@ -111,10 +108,6 @@ func run(parent context.Context, o *options) error { if o.role != RoleCell { return fmt.Errorf("--role must be %q or %q, got %q", RoleCell, RoleFabric, o.role) } - mode, err := parseAttachmentMode(o.attachmentMode) - if err != nil { - return err - } if o.controllerUsername == "" { o.controllerUsername = "system:serviceaccount:" + o.namespace + ":galactic-controller" } @@ -152,7 +145,7 @@ func run(parent context.Context, o *options) error { if err := Setup(mgr, o.controllerUsername); err != nil { return err } - if err := SetupCloud(mgr, mode); err != nil { + if err := SetupCloud(mgr); err != nil { return err } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { @@ -165,19 +158,8 @@ func run(parent context.Context, o *options) error { return mgr.Start(ctx) } -func parseAttachmentMode(value string) (cloudv1alpha1.VPCAttachmentInterfaceMode, error) { - switch mode := cloudv1alpha1.VPCAttachmentInterfaceMode(value); mode { - case cloudv1alpha1.VPCAttachmentInterfaceModeNetns, - cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, - cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared: - return mode, nil - default: - return "", fmt.Errorf("--attachment-mode must be Netns, Hypervisor or HypervisorDeclared, got %q", value) - } -} - -// SetupCloud registers the cloud cell controllers on the same manager and lease. -func SetupCloud(mgr ctrl.Manager, mode cloudv1alpha1.VPCAttachmentInterfaceMode) error { +// SetupCloud registers the Galactic data-plane status controller. +func SetupCloud(mgr ctrl.Manager) error { if err := mgr.GetFieldIndexer().IndexField(context.Background(), &cloudv1alpha1.VPCAttachment{}, cloudcontroller.IndexVPCAttachmentIdentity, func(obj client.Object) []string { attachment, ok := obj.(*cloudv1alpha1.VPCAttachment) @@ -193,20 +175,6 @@ func SetupCloud(mgr ctrl.Manager, mode cloudv1alpha1.VPCAttachmentInterfaceMode) return fmt.Errorf("index VPC attachment identity: %w", err) } for name, setup := range map[string]func() error{ - "NetworkContext": func() error { - reconciler := &cloudcontroller.NetworkContextReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} - return reconciler.SetupWithManager(mgr) - }, - "Subnet": func() error { - reconciler := &cloudcontroller.SubnetReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} - return reconciler.SetupWithManager(mgr) - }, - "NetworkInterface": func() error { - reconciler := &cloudcontroller.NetworkInterfaceReconciler{ - Client: mgr.GetClient(), Scheme: mgr.GetScheme(), APIReader: mgr.GetAPIReader(), AttachmentMode: mode, - } - return reconciler.SetupWithManager(mgr) - }, "BGPAdvertisement": func() error { reconciler := &cloudcontroller.BGPAdvertisementReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()} return reconciler.SetupWithManager(mgr) diff --git a/config/galactic-controller/base/deployment.yaml b/config/galactic-controller/base/deployment.yaml index 40cf4c98..954baa79 100644 --- a/config/galactic-controller/base/deployment.yaml +++ b/config/galactic-controller/base/deployment.yaml @@ -39,8 +39,6 @@ spec: image: ghcr.io/datum-cloud/galactic-controller:latest args: - --role=cell - # Override in Hypervisor and HypervisorDeclared cells. - - --attachment-mode=Netns env: - name: POD_NAMESPACE valueFrom: diff --git a/config/galactic-controller/rbac.yaml b/config/galactic-controller/rbac.yaml index c38d204f..f81cd4f9 100644 --- a/config/galactic-controller/rbac.yaml +++ b/config/galactic-controller/rbac.yaml @@ -14,28 +14,27 @@ rules: - apiGroups: [galactic.datumapis.com] resources: [trafficpolicybindings] verbs: [get, list, watch] - # Cell VPC, attachment and fabric identity reconcilers. - apiGroups: [galactic.datumapis.com] - resources: [networkfabricidentities, vpcs, vpcattachments] - verbs: [get, list, watch, create, update, patch, delete] + resources: [vpcattachments] + verbs: [get, list, watch] - apiGroups: [galactic.datumapis.com] - resources: [vpcs/status, vpcattachments/status] + resources: [vpcattachments/status] verbs: [get, update, patch] - apiGroups: [galactic.datumapis.com] resources: [bgpadvertisements, bgprouters] verbs: [get, list, watch] - apiGroups: [networking.datumapis.com] - resources: [networkcontexts, networkinterfaceclaims, networkinterfaces, networks, subnets] + resources: [networkinterfaceclaims, networkinterfaces] verbs: [get, list, watch] - apiGroups: [networking.datumapis.com] - resources: [networkinterfaceclaims/status, networkinterfaces/status, subnets/status] + resources: [networkinterfaceclaims/status, networkinterfaces/status] verbs: [get, update, patch] - apiGroups: [compute.datumapis.com] resources: [instances] verbs: [get, list, watch] - apiGroups: [k8s.cni.cncf.io] resources: [network-attachment-definitions] - verbs: [get, list, watch, create, update, patch, delete] + verbs: [get, list, watch] - apiGroups: [""] resources: [pods] verbs: [get, list, watch] diff --git a/docs/migration/galactic-api-consolidation.md b/docs/migration/galactic-api-consolidation.md index cd12ce57..713fc26e 100644 --- a/docs/migration/galactic-api-consolidation.md +++ b/docs/migration/galactic-api-consolidation.md @@ -1,13 +1,14 @@ # Consolidating the cloud and network APIs This change moves the `cloud` and `network` main-branch API types and the cloud -controllers into Galactic. The served API group for VPC, BGP, gateway, traffic +API types into Galactic. The served API group for VPC, BGP, gateway, traffic policy, and FabricQuery resources is `galactic.datumapis.com/v1alpha1`. `NetworkRule` is now `NetworkService`, matching the network main branch. The source revisions are cloud `55c38b5`, network `5a51626`, and Galactic -`c1769cb`. The cloud cell reconcilers run in `galactic-controller --role=cell` -with the traffic-policy reconcilers. The central fabric identity reconciler +`c1769cb`. The VPC, subnet status, and attachment reconcilers run in the NSO +cell manager with `galactic.enabled: true` and the cell's `attachmentMode`. +The Galactic cell role retains BGP status projection and traffic policy. The central fabric identity reconciler runs in the same binary with `--role=fabric` and a separate deployment because its hub and IPAM credentials do not belong in each cell. @@ -22,7 +23,8 @@ its hub and IPAM credentials do not belong in each cell. Apply `config/fabric-api/crd/galactic.datumapis.com_fabricqueries.yaml` wherever FabricQuery is served. Install the `galactic-controller` cell RBAC, webhook certificate, and both webhook configurations before routing - requests to the new controller. + requests to the new controllers. Upgrade NSO's cell manager with Galactic + reconciliation disabled until the old cell writer is stopped. 3. Deploy `config/galactic-controller/fabric` centrally, with a hub credential bound to `config/galactic-controller/fabric/hub-rbac.yaml` on the hub and an IPAM credential for the platform project. Confirm the new @@ -35,10 +37,10 @@ its hub and IPAM credentials do not belong in each cell. `NetworkRule` becomes `NetworkService`. Copy specs and selected labels and annotations, not Kubernetes UIDs, resource versions, or status. The old objects do not become visible to the new controllers automatically. -5. Stop the old cloud cell controller before starting the new cell role, so - only one controller writes each NetworkAttachmentDefinition and NSO - `NetworkInterface` status. Start `galactic-controller --role=cell` with the - correct `--attachment-mode` for the cell. It reuses the legacy NAD's VPC and +5. Stop the old cloud cell controller before enabling Galactic reconciliation + in NSO's cell manager, so only one controller writes each + NetworkAttachmentDefinition and NSO `NetworkInterface` status. Configure + the correct `galactic.attachmentMode` for the cell. NSO reuses the legacy NAD's VPC and attachment IDs, transfers its owner reference to the new attachment, and updates `NetworkInterface.status.attachmentRef.apiGroup` to `galactic.datumapis.com`. On group change it sets `Programmed=Unknown` until @@ -50,11 +52,11 @@ its hub and IPAM credentials do not belong in each cell. the Galactic attachment group and `Programmed=True`, and check gateway and BGP node status before retiring old CRDs and controllers. -NSO's `NetworkInterfaceAttachmentRef.APIGroup` is a free-form string written -by the provider. NSO preserves that field and the provider-owned `Programmed` -condition across reconcile, release, and rebind. Its regression test now -covers the change from the old cloud group to the Galactic group; no NSO -controller code change is required for that reference transition. +NSO writes `NetworkInterfaceAttachmentRef.APIGroup` as +`galactic.datumapis.com` when its cell reconciler creates a new attachment. +The tenant-facing NSO CRDs remain in `networking.datumapis.com`. Galactic's +BGP reconciler projects data-plane programming status to the Galactic +attachment and NSO interface and claim. The old group's VPC finalizers require the old controller to finish deletion, or an operator-reviewed cleanup after the old objects are no longer serving. diff --git a/internal/cloud/controller/bgpadvertisement_controller_test.go b/internal/cloud/controller/bgpadvertisement_controller_test.go index dc4fa45c..5191501b 100644 --- a/internal/cloud/controller/bgpadvertisement_controller_test.go +++ b/internal/cloud/controller/bgpadvertisement_controller_test.go @@ -52,7 +52,7 @@ func TestAdvertisementReprogramsGalacticInterfaceAndClaim(t *testing.T) { } claim := &networkingv1alpha.NetworkInterfaceClaim{ObjectMeta: metav1.ObjectMeta{Name: "claim", Namespace: "tenant"}} meta.SetStatusCondition(&claim.Status.Conditions, metav1.Condition{ - Type: networkingv1alpha.NetworkInterfaceClaimProgrammed, Status: metav1.ConditionUnknown, Reason: reasonAPIGroupMigration, + Type: networkingv1alpha.NetworkInterfaceClaimProgrammed, Status: metav1.ConditionUnknown, Reason: "APIGroupMigration", }) cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface, claim, attachment). WithObjects(iface, claim, attachment).Build() diff --git a/internal/cloud/controller/networkcontext_controller.go b/internal/cloud/controller/networkcontext_controller.go deleted file mode 100644 index 812ee561..00000000 --- a/internal/cloud/controller/networkcontext_controller.go +++ /dev/null @@ -1,251 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "fmt" - "slices" - "time" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - "go.datum.net/galactic/internal/cloud/identifier" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -const reasonAwaitingFabricIdentity = "AwaitingFabricIdentity" - -// NetworkContextReconciler gives a network's presence in one location its -// data-plane identity: one VPC per NetworkContext, carrying the base62 VPC -// identifier the whole galactic fabric keys on. -type NetworkContextReconciler struct { - client.Client - Scheme *runtime.Scheme -} - -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkcontexts,verbs=get;list;watch -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=networkfabricidentities,verbs=get;list;watch -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs/status,verbs=get;update;patch - -func (r *NetworkContextReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var networkContext networkingv1alpha.NetworkContext - if err := r.Get(ctx, req.NamespacedName, &networkContext); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - if !networkContext.DeletionTimestamp.IsZero() { - return ctrl.Result{}, nil - } - - networks, err := r.networksForContext(ctx, &networkContext) - if err != nil { - return ctrl.Result{}, err - } - if len(networks) == 0 { - // The VPC address space comes from the Subnets IPAM allocated for this - // location, and VPCSpec is immutable once written. - return ctrl.Result{RequeueAfter: 10 * time.Second}, nil - } - - vpc := &cloudv1alpha1.VPC{ - ObjectMeta: metav1.ObjectMeta{ - Name: networkContext.Name, - Namespace: networkContext.Namespace, - }, - } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, vpc, func() error { - if vpc.CreationTimestamp.IsZero() { - vpc.Spec.Networks = networks - } - return controllerutil.SetControllerReference(&networkContext, vpc, r.Scheme) - }); err != nil { - return ctrl.Result{}, fmt.Errorf("reconcile VPC %s: %w", vpc.Name, err) - } - - // Everything below is conditioned on the VPC not having an identifier yet. A - // VPC that already has one keeps it: the VRF device is named from it and the - // Route Target derives from it, so renumbering a live VPC would rename its - // interface and change its routes under running traffic. - if vpc.Status.VPC == "" { - identity, found, err := r.fabricIdentity(ctx, &networkContext) - if err != nil { - return ctrl.Result{}, err - } - if !found { - return ctrl.Result{}, r.markAwaitingFabricIdentity(ctx, vpc) - } - encoded, err := identifier.VPCBase62(uint64(identity)) - if err != nil { - return ctrl.Result{}, fmt.Errorf("encode fabric identity %d for VPC %s: %w", identity, vpc.Name, err) - } - vpc.Status.VPC = encoded - } - vpc.Status.ObservedGeneration = vpc.Generation - meta.SetStatusCondition(&vpc.Status.Conditions, metav1.Condition{ - Type: cloudv1alpha1.ConditionTypeReady, - Status: metav1.ConditionTrue, - Reason: "IdentifierAllocated", - Message: fmt.Sprintf("VPC identifier %s allocated", vpc.Status.VPC), - ObservedGeneration: vpc.Generation, - }) - if err := r.Status().Update(ctx, vpc); err != nil { - return ctrl.Result{}, fmt.Errorf("update VPC %s status: %w", vpc.Name, err) - } - - return ctrl.Result{}, nil -} - -// fabricIdentity reads the identity carried to this cell for the context's -// network. It is one object per network, named after the network, in the -// network's namespace. A missing one is an ordinary answer, not a failure. -// -// The identity is allocated centrally, once for the whole network, and carried -// to each cell the network reaches. Deriving the VPC identifier from it is what -// makes two locations of one network the same network on the fabric; drawing a -// random value per location, which is what this used to do, made them two. -// -// A VPC whose identity has not arrived waits, and there is deliberately nothing -// else it can do. The identifier is immutable, so a random value taken while -// waiting is permanent, and a network whose locations then disagree is worse -// than one with no VPC at all: a NetworkService spanning them binds no VRF and -// fails every request, including through healthy members. Waiting ends the -// moment the identity lands. A random draw never ends. -func (r *NetworkContextReconciler) fabricIdentity( - ctx context.Context, networkContext *networkingv1alpha.NetworkContext, -) (int64, bool, error) { - networkName := networkContext.Spec.Network.Name - if networkName == "" { - return 0, false, nil - } - - var identity cloudv1alpha1.NetworkFabricIdentity - key := client.ObjectKey{Namespace: networkContext.Namespace, Name: networkName} - if err := r.Get(ctx, key, &identity); err != nil { - if apierrors.IsNotFound(err) { - return 0, false, nil - } - return 0, false, fmt.Errorf("read the fabric identity for network %q: %w", networkName, err) - } - if identity.Spec.Identity == 0 { - return 0, false, nil - } - return identity.Spec.Identity, true, nil -} - -// markAwaitingFabricIdentity says out loud that the VPC has no identifier yet -// and why, so a network stuck waiting on a central allocation is visible as -// that rather than as a VPC that silently never became ready. -func (r *NetworkContextReconciler) markAwaitingFabricIdentity( - ctx context.Context, vpc *cloudv1alpha1.VPC, -) error { - vpc.Status.ObservedGeneration = vpc.Generation - meta.SetStatusCondition(&vpc.Status.Conditions, metav1.Condition{ - Type: cloudv1alpha1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: reasonAwaitingFabricIdentity, - Message: "Waiting for the identity the fabric knows this network by", - ObservedGeneration: vpc.Generation, - }) - if err := r.Status().Update(ctx, vpc); err != nil { - return fmt.Errorf("update VPC %s status: %w", vpc.Name, err) - } - return nil -} - -// networksForContext collects the CIDRs IPAM allocated for this location. -func (r *NetworkContextReconciler) networksForContext( - ctx context.Context, networkContext *networkingv1alpha.NetworkContext, -) ([]cloudv1alpha1.Network, error) { - var subnets networkingv1alpha.SubnetList - if err := r.List(ctx, &subnets, client.InNamespace(networkContext.Namespace)); err != nil { - return nil, fmt.Errorf("list subnets: %w", err) - } - - networks := make([]cloudv1alpha1.Network, 0, len(subnets.Items)) - for _, subnet := range subnets.Items { - if subnet.Spec.NetworkContext.Name != networkContext.Name { - continue - } - start, prefixLength, ok := subnetRange(&subnet) - if !ok { - continue - } - networks = append(networks, cloudv1alpha1.Network( - fmt.Sprintf("%s/%d", start, prefixLength))) - } - slices.Sort(networks) - return networks, nil -} - -// subnetRange reads a subnet's allocated range, preferring status and falling -// back to spec. A location's copy arrives by propagation, which carries spec -// and never status. -func subnetRange(subnet *networkingv1alpha.Subnet) (string, int32, bool) { - if subnet.Status.StartAddress != nil && subnet.Status.PrefixLength != nil { - return *subnet.Status.StartAddress, *subnet.Status.PrefixLength, true - } - if subnet.Spec.StartAddress != "" && subnet.Spec.PrefixLength != 0 { - return subnet.Spec.StartAddress, subnet.Spec.PrefixLength, true - } - return "", 0, false -} - -// SetupWithManager registers the reconciler with the manager. -func (r *NetworkContextReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&networkingv1alpha.NetworkContext{}). - Owns(&cloudv1alpha1.VPC{}). - Watches(&cloudv1alpha1.NetworkFabricIdentity{}, - handler.EnqueueRequestsFromMapFunc(r.contextsForFabricIdentity)). - Named("networkcontext"). - Complete(r) -} - -// contextsForFabricIdentity wakes a network's presences the moment its identity -// reaches this cell, so a VPC waiting on one takes it immediately instead of -// sitting out its poll interval. -func (r *NetworkContextReconciler) contextsForFabricIdentity( - ctx context.Context, object client.Object, -) []reconcile.Request { - var contexts networkingv1alpha.NetworkContextList - if err := r.List(ctx, &contexts, client.InNamespace(object.GetNamespace())); err != nil { - return nil - } - - requests := make([]reconcile.Request, 0, len(contexts.Items)) - for i := range contexts.Items { - if contexts.Items[i].Spec.Network.Name != object.GetName() { - continue - } - requests = append(requests, reconcile.Request{ - NamespacedName: client.ObjectKeyFromObject(&contexts.Items[i]), - }) - } - return requests -} diff --git a/internal/cloud/controller/networkcontext_controller_test.go b/internal/cloud/controller/networkcontext_controller_test.go deleted file mode 100644 index 98ee196f..00000000 --- a/internal/cloud/controller/networkcontext_controller_test.go +++ /dev/null @@ -1,244 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "testing" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -// A location's Subnet arrives by propagation, which carries spec and never -// status, so the allocated range has to be readable from either. -func TestSubnetRangePrefersStatusAndFallsBackToSpec(t *testing.T) { - start := "fd00::" - var length int32 = 48 - - withStatus := &networkingv1alpha.Subnet{ - Spec: networkingv1alpha.SubnetSpec{StartAddress: "fd20::", PrefixLength: 64}, - Status: networkingv1alpha.SubnetStatus{StartAddress: &start, PrefixLength: &length}, - } - gotStart, gotLength, ok := subnetRange(withStatus) - if !ok || gotStart != "fd00::" || gotLength != 48 { - t.Fatalf("status should win when present, got %q/%d ok=%v", gotStart, gotLength, ok) - } - - specOnly := &networkingv1alpha.Subnet{ - Spec: networkingv1alpha.SubnetSpec{StartAddress: "fd20::", PrefixLength: 64}, - } - gotStart, gotLength, ok = subnetRange(specOnly) - if !ok || gotStart != "fd20::" || gotLength != 64 { - t.Fatalf("a propagated copy carries spec only, got %q/%d ok=%v", gotStart, gotLength, ok) - } - - if _, _, ok := subnetRange(&networkingv1alpha.Subnet{}); ok { - t.Fatal("an unallocated subnet should yield nothing") - } -} - -const ( - vpcTestNamespace = "ns-project" - vpcTestNetwork = "prod" - vpcTestLocation = "us-central-1" - - // The identity the fabric knows the test network by, and the identifier a - // VPC derives from it. - vpcTestIdentity = 16 - vpcTestIdentifier = "g" -) - -type vpcFixture struct { - t *testing.T - ctx context.Context - client client.Client - reconciler *NetworkContextReconciler -} - -// newVPCFixture stands up one network present at one location, with the subnet -// that gives the VPC an address space to be created for. -func newVPCFixture(t *testing.T, objects ...client.Object) *vpcFixture { - t.Helper() - - scheme := runtime.NewScheme() - if err := networkingv1alpha.AddToScheme(scheme); err != nil { - t.Fatalf("build the networking scheme: %v", err) - } - if err := cloudv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("build the cloud scheme: %v", err) - } - - presence := &networkingv1alpha.NetworkContext{} - presence.Namespace = vpcTestNamespace - presence.Name = vpcTestNetwork + "-" + vpcTestLocation - presence.Spec.Network = networkingv1alpha.LocalNetworkRef{Name: vpcTestNetwork} - presence.Spec.Location = networkingv1alpha.LocationReference{Name: vpcTestLocation} - - subnet := &networkingv1alpha.Subnet{} - subnet.Namespace = vpcTestNamespace - subnet.Name = presence.Name - subnet.Spec.NetworkContext = networkingv1alpha.LocalNetworkContextRef{Name: presence.Name} - subnet.Spec.StartAddress = "fd00::" - subnet.Spec.PrefixLength = 48 - - all := append([]client.Object{presence, subnet}, objects...) - fakeClient := fake.NewClientBuilder().WithScheme(scheme). - WithStatusSubresource(&cloudv1alpha1.VPC{}). - WithObjects(all...).Build() - - return &vpcFixture{ - t: t, - ctx: context.Background(), - client: fakeClient, - reconciler: &NetworkContextReconciler{Client: fakeClient, Scheme: scheme}, - } -} - -func (f *vpcFixture) reconcile() ctrl.Result { - f.t.Helper() - result, err := f.reconciler.Reconcile(f.ctx, ctrl.Request{NamespacedName: types.NamespacedName{ - Namespace: vpcTestNamespace, - Name: vpcTestNetwork + "-" + vpcTestLocation, - }}) - if err != nil { - f.t.Fatalf("reconcile: %v", err) - } - return result -} - -func (f *vpcFixture) vpc() *cloudv1alpha1.VPC { - f.t.Helper() - vpc := &cloudv1alpha1.VPC{} - key := types.NamespacedName{Namespace: vpcTestNamespace, Name: vpcTestNetwork + "-" + vpcTestLocation} - if err := f.client.Get(f.ctx, key, vpc); err != nil { - f.t.Fatalf("read the VPC: %v", err) - } - return vpc -} - -func fabricIdentityObject() *cloudv1alpha1.NetworkFabricIdentity { - object := &cloudv1alpha1.NetworkFabricIdentity{} - object.Namespace = vpcTestNamespace - object.Name = vpcTestNetwork - object.Spec.Identity = vpcTestIdentity - object.Spec.NetworkRef = cloudv1alpha1.NetworkFabricIdentityNetworkRef{Name: vpcTestNetwork} - return object -} - -// The whole point: every location of one network reaches the same identifier, -// because every one of them reads the same centrally allocated identity. -func TestVPCTakesItsIdentifierFromTheNetworksFabricIdentity(t *testing.T) { - fixture := newVPCFixture(t, fabricIdentityObject()) - - fixture.reconcile() - - vpc := fixture.vpc() - if vpc.Status.VPC != vpcTestIdentifier { - t.Fatalf("VPC identifier: got %q, want %q", vpc.Status.VPC, vpcTestIdentifier) - } - if condition := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady); condition == nil || - condition.Status != metav1.ConditionTrue { - t.Fatalf("a VPC with an identifier should be ready, got %+v", condition) - } -} - -// A network whose identity has not reached this cell yet waits, and writes -// nothing. The identifier is immutable, so a value taken while waiting is -// permanent, and locations that disagree bind no VRF at all. -func TestVPCWaitsForAnIdentityThatHasNotArrived(t *testing.T) { - fixture := newVPCFixture(t) - - if result := fixture.reconcile(); result.RequeueAfter != 0 { - t.Fatalf("the identity's arrival is the trigger, not a poll, got %v", result) - } - - vpc := fixture.vpc() - if vpc.Status.VPC != "" { - t.Fatalf("no identifier should be written while waiting, got %q", vpc.Status.VPC) - } - condition := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady) - if condition == nil || condition.Status != metav1.ConditionFalse || - condition.Reason != "AwaitingFabricIdentity" { - t.Fatalf("the wait should be visible on the VPC, got %+v", condition) - } -} - -// The identity landing is what ends the wait, and the identifier that follows -// is the derived one rather than a random draw. -func TestVPCTakesTheIdentityOnceItArrives(t *testing.T) { - fixture := newVPCFixture(t) - - fixture.reconcile() - if got := fixture.vpc().Status.VPC; got != "" { - t.Fatalf("no identifier should be written while waiting, got %q", got) - } - - if err := fixture.client.Create(fixture.ctx, fabricIdentityObject()); err != nil { - t.Fatalf("publish the identity: %v", err) - } - fixture.reconcile() - - if got := fixture.vpc().Status.VPC; got != vpcTestIdentifier { - t.Fatalf("VPC identifier: got %q, want %q", got, vpcTestIdentifier) - } -} - -// Renumbering a live VPC would rename its VRF device and change its Route -// Target under running traffic, so an identifier already written stays written -// even when it disagrees with the identity that later arrived. -func TestVPCKeepsAnIdentifierItAlreadyHas(t *testing.T) { - existing := &cloudv1alpha1.VPC{} - existing.Namespace = vpcTestNamespace - existing.Name = vpcTestNetwork + "-" + vpcTestLocation - existing.Status.VPC = "R2POk4jT" - - fixture := newVPCFixture(t, existing, fabricIdentityObject()) - - fixture.reconcile() - - if got := fixture.vpc().Status.VPC; got != "R2POk4jT" { - t.Fatalf("an allocated VPC must keep its identifier, got %q", got) - } -} - -// An identity reaching the cell has to wake the presences waiting on it, or a -// VPC would sit out its poll interval for a value already there. -func TestFabricIdentityWakesTheNetworksPresences(t *testing.T) { - fixture := newVPCFixture(t) - - requests := fixture.reconciler.contextsForFabricIdentity(fixture.ctx, fabricIdentityObject()) - - if len(requests) != 1 || requests[0].Name != vpcTestNetwork+"-"+vpcTestLocation { - t.Fatalf("the network's presence should be enqueued, got %v", requests) - } - - other := fabricIdentityObject() - other.Name = "unrelated" - if requests := fixture.reconciler.contextsForFabricIdentity(fixture.ctx, other); len(requests) != 0 { - t.Fatalf("another network's identity should enqueue nothing, got %v", requests) - } -} diff --git a/internal/cloud/controller/networkinterface_controller.go b/internal/cloud/controller/networkinterface_controller.go deleted file mode 100644 index a3c2ef48..00000000 --- a/internal/cloud/controller/networkinterface_controller.go +++ /dev/null @@ -1,459 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "fmt" - "time" - - nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - "go.datum.net/galactic/internal/cloud/galactic" - "go.datum.net/galactic/internal/cloud/identifier" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -const ( - // LabelVPC records the base62 VPC identifier a NAD attaches to. - LabelVPC = "galactic.datumapis.com/vpc" - - // LabelVPCAttachment records the base62 attachment identifier a NAD holds. - // The NAD is the allocation record for that identifier. - LabelVPCAttachment = "galactic.datumapis.com/vpc-attachment" - legacyLabelVPC = "cloud.datumapis.com/vpc" - legacyLabelVPCAttachment = "cloud.datumapis.com/vpc-attachment" - legacyAttachmentKind = "VPCAttachment" - reasonAPIGroupMigration = "APIGroupMigration" - - // AnnotationHostInterface records the host device this attachment will get, - // on the NAD, at the moment the NAD is written. The name is derived from the - // VPC and the attachment identifier, so it is known here — well before the - // CNI ADD that creates the device. A runtime that must name the device when - // it asks for an interface, rather than learn it from the ADD result, has - // nowhere else to read it in time. galactic writes the same key during ADD - // from the same inputs, so the two always agree. - AnnotationHostInterface = "k8s.v1.cni.cncf.io/host-interface" - - // ConditionTypePrepared reports that the data plane's pre-Pod artifacts exist. - // Unlike Programmed, which only becomes true at CNI ADD, it is safe to gate - // Pod creation on. network-services-operator is adding the type in parallel. - ConditionTypePrepared = "Prepared" -) - -// maxIdentifierAttempts bounds the retry loop that draws an unused identifier. -const maxIdentifierAttempts = 100 - -// NetworkInterfaceReconciler realizes a fulfilled NetworkInterface claim on the -// galactic data plane. -// -// It creates the VPCAttachment and the NetworkAttachmentDefinition in one pass, -// so it already holds every render input and never has to look sideways. Both -// objects are per-interface, which keeps the attachment identifier and the tap -// device name stable across instance replacement, and it publishes the -// annotations a workload must carry so no infrastructure provider has to know -// what a NAD is. -type NetworkInterfaceReconciler struct { - client.Client - Scheme *runtime.Scheme - - // APIReader bypasses the cache when listing allocated identifiers, so a NAD - // written moments ago cannot be missed and its identifier reissued. - APIReader client.Reader - - // AttachmentMode is how guests in this cell consume an interface. It is - // required configuration standing in for a capability class that does not - // exist yet, so a cell states what it is rather than defaulting. It covers - // every interface that states no mode of its own. - AttachmentMode cloudv1alpha1.VPCAttachmentInterfaceMode -} - -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces,verbs=get;list;watch -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaces/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims,verbs=get;list;watch -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=networkinterfaceclaims/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcattachments,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcattachments/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=k8s.cni.cncf.io,resources=network-attachment-definitions,verbs=get;list;watch;create;update;patch;delete - -func (r *NetworkInterfaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var networkInterface networkingv1alpha.NetworkInterface - if err := r.Get(ctx, req.NamespacedName, &networkInterface); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - if !networkInterface.DeletionTimestamp.IsZero() { - return ctrl.Result{}, nil - } - if !claimFulfilled(&networkInterface) { - return ctrl.Result{}, nil - } - - var vpc cloudv1alpha1.VPC - vpcKey := types.NamespacedName{ - Namespace: networkInterface.Namespace, - Name: networkInterface.Status.NetworkContextRef.Name, - } - if err := r.Get(ctx, vpcKey, &vpc); err != nil { - if apierrors.IsNotFound(err) { - return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface, - metav1.ConditionFalse, "AwaitingVPC", fmt.Sprintf("VPC %s does not exist yet", vpcKey.Name)) - } - return ctrl.Result{}, fmt.Errorf("get VPC %s: %w", vpcKey, err) - } - if vpc.Status.VPC == "" { - return ctrl.Result{RequeueAfter: 10 * time.Second}, r.markPrepared(ctx, &networkInterface, - metav1.ConditionFalse, "AwaitingVPCIdentifier", - fmt.Sprintf("VPC %s has no identifier yet", vpc.Name)) - } - - attachment, err := r.reconcileAttachment(ctx, &networkInterface, &vpc) - if err != nil { - return ctrl.Result{}, err - } - nad, err := r.reconcileNAD(ctx, attachment, &vpc, &networkInterface) - if err != nil { - return ctrl.Result{}, err - } - if err := r.publishAttachmentStatus(ctx, attachment, &vpc, nad); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, r.publishToInterface(ctx, &networkInterface, attachment, &vpc) -} - -// claimFulfilled reports whether an interface is bound to a claim and holds -// every address it must carry. Nothing can be rendered before that. -func claimFulfilled(networkInterface *networkingv1alpha.NetworkInterface) bool { - if networkInterface.Status.Phase != networkingv1alpha.NetworkInterfacePhaseBound { - return false - } - if networkInterface.Status.NetworkContextRef == nil { - return false - } - return meta.IsStatusConditionTrue(networkInterface.Status.Conditions, - networkingv1alpha.NetworkInterfaceAllocated) -} - -// reconcileAttachment creates the VPCAttachment for an interface. The controller -// owns this object, not the infrastructure provider: it is the only component -// that speaks both the workload vocabulary and the data plane's. -func (r *NetworkInterfaceReconciler) reconcileAttachment( - ctx context.Context, networkInterface *networkingv1alpha.NetworkInterface, vpc *cloudv1alpha1.VPC, -) (*cloudv1alpha1.VPCAttachment, error) { - attachment := &cloudv1alpha1.VPCAttachment{ - ObjectMeta: metav1.ObjectMeta{Name: networkInterface.Name, Namespace: networkInterface.Namespace}, - } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, attachment, func() error { - attachment.Spec.VPC = cloudv1alpha1.VPCRef{Name: vpc.Name} - attachment.Spec.InterfaceRef = &cloudv1alpha1.NetworkInterfaceRef{Name: networkInterface.Name} - attachment.Spec.Interface.Name = networkInterface.Spec.InterfaceName - attachment.Spec.Interface.Mode = r.attachmentMode(networkInterface) - attachment.Spec.Interface.Addresses = interfaceAddresses(networkInterface) - return controllerutil.SetControllerReference(networkInterface, attachment, r.Scheme) - }); err != nil { - return nil, fmt.Errorf("reconcile VPC attachment %s: %w", attachment.Name, err) - } - return attachment, nil -} - -// reconcileNAD creates or updates the NAD the attachment owns. -func (r *NetworkInterfaceReconciler) reconcileNAD( - ctx context.Context, - attachment *cloudv1alpha1.VPCAttachment, - vpc *cloudv1alpha1.VPC, - networkInterface *networkingv1alpha.NetworkInterface, -) (*nadv1.NetworkAttachmentDefinition, error) { - nad := &nadv1.NetworkAttachmentDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: attachment.Name, Namespace: attachment.Namespace}, - } - - attachmentID := "" - if err := r.Get(ctx, client.ObjectKeyFromObject(nad), nad); err == nil { - attachmentID = nad.Labels[LabelVPCAttachment] - if attachmentID == "" { - attachmentID = nad.Labels[legacyLabelVPCAttachment] - } - } else if !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("get NetworkAttachmentDefinition %s: %w", nad.Name, err) - } - if attachmentID == "" { - allocated, err := r.allocateAttachmentIdentifier(ctx, vpc.Status.VPC) - if err != nil { - return nil, err - } - attachmentID = allocated - } - - addresses := make([]galactic.Address, 0, len(networkInterface.Spec.Addresses)) - for _, address := range networkInterface.Spec.Addresses { - addresses = append(addresses, galactic.Address{ - Address: address.Address, - Gateway: address.Gateway, - }) - } - config, err := galactic.ConflistJSON(attachment.Name, masterPlugin(attachment.Spec.Interface.Mode), - vpc.Status.VPC, attachmentID, networkInterface.Spec.MTU, addresses, - declaresDevice(attachment.Spec.Interface.Mode)) - if err != nil { - return nil, err - } - - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, nad, func() error { - if nad.Labels == nil { - nad.Labels = map[string]string{} - } - nad.Labels[LabelVPC] = vpc.Status.VPC - nad.Labels[LabelVPCAttachment] = attachmentID - if nad.Annotations == nil { - nad.Annotations = map[string]string{} - } - nad.Annotations[AnnotationHostInterface] = galactic.HostInterfaceName(vpc.Status.VPC, attachmentID) - nad.Spec.Config = config - // The former cloud-group attachment owned this NAD. Its controller - // must be stopped before rollout; transfer ownership in the same write - // as the group/label update while retaining its stable attachment ID. - refs := nad.OwnerReferences[:0] - for _, ref := range nad.OwnerReferences { - if ref.APIVersion == "cloud.datumapis.com/v1alpha1" && ref.Kind == legacyAttachmentKind && ref.Name == attachment.Name { - continue - } - refs = append(refs, ref) - } - nad.OwnerReferences = refs - return controllerutil.SetControllerReference(attachment, nad, r.Scheme) - }); err != nil { - return nil, fmt.Errorf("reconcile NetworkAttachmentDefinition %s: %w", nad.Name, err) - } - return nad, nil -} - -// attachmentMode resolves how one guest consumes its interface. An interface -// that states a mode carries the workload's own requirement, so it wins. The -// cell-wide mode covers everything else, which is every interface written -// before a workload could state one. -func (r *NetworkInterfaceReconciler) attachmentMode( - networkInterface *networkingv1alpha.NetworkInterface, -) cloudv1alpha1.VPCAttachmentInterfaceMode { - switch networkInterface.Spec.AttachmentMode { - case networkingv1alpha.NetworkInterfaceAttachmentModeNetns: - return cloudv1alpha1.VPCAttachmentInterfaceModeNetns - case networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor: - return cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor - case networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared: - return cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared - default: - return r.AttachmentMode - } -} - -// masterPlugin translates how a guest consumes an interface into the galactic -// binary that realizes it. This is the only place the two vocabularies meet. -func masterPlugin(mode cloudv1alpha1.VPCAttachmentInterfaceMode) string { - switch mode { - case cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, - cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared: - return galactic.PluginTap - default: - return galactic.PluginVeth - } -} - -// declaresDevice reports whether the tap plugin describes the device to the -// hypervisor instead of leaving the hypervisor to discover it. -func declaresDevice(mode cloudv1alpha1.VPCAttachmentInterfaceMode) bool { - return mode == cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared -} - -// interfaceAddresses copies the addresses NSO allocated onto the attachment, so -// the attachment describes itself without a second read. -func interfaceAddresses(networkInterface *networkingv1alpha.NetworkInterface) []cloudv1alpha1.IPAddress { - addresses := make([]cloudv1alpha1.IPAddress, 0, len(networkInterface.Spec.Addresses)) - for _, address := range networkInterface.Spec.Addresses { - addresses = append(addresses, cloudv1alpha1.IPAddress(address.Address)) - } - return addresses -} - -// allocateAttachmentIdentifier draws a random identifier unused within the VPC. -// Random rather than lowest-free, so a freed identifier is not immediately -// reissued while its BGPAdvertisement is still being garbage collected. -func (r *NetworkInterfaceReconciler) allocateAttachmentIdentifier(ctx context.Context, vpc string) (string, error) { - used := make(map[string]struct{}) - for _, label := range []string{LabelVPC, legacyLabelVPC} { - var nads nadv1.NetworkAttachmentDefinitionList - if err := r.APIReader.List(ctx, &nads, client.MatchingLabels{label: vpc}); err != nil { - return "", fmt.Errorf("list NetworkAttachmentDefinitions for VPC %s: %w", vpc, err) - } - for _, nad := range nads.Items { - for _, key := range []string{LabelVPCAttachment, legacyLabelVPCAttachment} { - if id := nad.Labels[key]; id != "" { - used[id] = struct{}{} - } - } - } - } - - for range maxIdentifierAttempts { - candidate, err := identifier.RandomVPCAttachmentBase62() - if err != nil { - return "", err - } - if _, taken := used[candidate]; !taken { - return candidate, nil - } - } - return "", fmt.Errorf("no unused attachment identifier found in VPC %s after %d attempts", - vpc, maxIdentifierAttempts) -} - -// publishAttachmentStatus records the allocated identifiers on the attachment. -func (r *NetworkInterfaceReconciler) publishAttachmentStatus( - ctx context.Context, - attachment *cloudv1alpha1.VPCAttachment, - vpc *cloudv1alpha1.VPC, - nad *nadv1.NetworkAttachmentDefinition, -) error { - attachment.Status.VPC = vpc.Status.VPC - attachment.Status.VPCAttachment = nad.Labels[LabelVPCAttachment] - attachment.Status.NetworkAttachmentDefinition = nad.Name - attachment.Status.ObservedGeneration = attachment.Generation - meta.SetStatusCondition(&attachment.Status.Conditions, metav1.Condition{ - Type: cloudv1alpha1.ConditionTypeReady, - Status: metav1.ConditionTrue, - Reason: "AttachmentDefinitionReady", - Message: fmt.Sprintf("NetworkAttachmentDefinition %s is ready for use", nad.Name), - ObservedGeneration: attachment.Generation, - }) - if err := r.Status().Update(ctx, attachment); err != nil { - return fmt.Errorf("update VPC attachment %s status: %w", client.ObjectKeyFromObject(attachment), err) - } - return nil -} - -// publishToInterface records what realizes the interface and which VPC it landed -// in, then reports Prepared so compute can release the Pod. -func (r *NetworkInterfaceReconciler) publishToInterface( - ctx context.Context, - networkInterface *networkingv1alpha.NetworkInterface, - attachment *cloudv1alpha1.VPCAttachment, - vpc *cloudv1alpha1.VPC, -) error { - ref := &networkingv1alpha.NetworkInterfaceAttachmentRef{ - APIGroup: cloudv1alpha1.GroupVersion.Group, - Kind: legacyAttachmentKind, - Name: attachment.Name, - } - if old := networkInterface.Status.AttachmentRef; old != nil && old.APIGroup != ref.APIGroup { - // NSO preserves the provider-owned Programmed condition. A condition - // earned by the former API group cannot certify this new attachment; - // the new BGPAdvertisement reconciler will report it again. - meta.SetStatusCondition(&networkInterface.Status.Conditions, metav1.Condition{ - Type: networkingv1alpha.NetworkInterfaceProgrammed, - Status: metav1.ConditionUnknown, - Reason: reasonAPIGroupMigration, - Message: "waiting for the Galactic attachment to be programmed", - ObservedGeneration: networkInterface.Generation, - }) - } - networkInterface.Status.AttachmentRef = ref - networkInterface.Status.VPC = vpc.Status.VPC - return r.markPrepared(ctx, networkInterface, metav1.ConditionTrue, "AttachmentReady", - fmt.Sprintf("VPCAttachment %s and its attachment definition exist", attachment.Name)) -} - -// markPrepared reports whether the pre-Pod artifacts exist, on the interface and -// on the claim holding it, which is what compute gates Pod creation on. -func (r *NetworkInterfaceReconciler) markPrepared( - ctx context.Context, - networkInterface *networkingv1alpha.NetworkInterface, - status metav1.ConditionStatus, - reason, message string, -) error { - condition := metav1.Condition{ - Type: ConditionTypePrepared, - Status: status, - Reason: reason, - Message: message, - ObservedGeneration: networkInterface.Generation, - } - meta.SetStatusCondition(&networkInterface.Status.Conditions, condition) - if err := r.Status().Update(ctx, networkInterface); err != nil { - return fmt.Errorf("update network interface %s status: %w", - client.ObjectKeyFromObject(networkInterface), err) - } - - if networkInterface.Spec.ClaimRef == nil { - return nil - } - var claim networkingv1alpha.NetworkInterfaceClaim - key := types.NamespacedName{Namespace: networkInterface.Namespace, Name: networkInterface.Spec.ClaimRef.Name} - if err := r.Get(ctx, key, &claim); err != nil { - return client.IgnoreNotFound(err) - } - claimCondition := condition - claimCondition.ObservedGeneration = claim.Generation - meta.SetStatusCondition(&claim.Status.Conditions, claimCondition) - // A claim can have Programmed and Ready from the old attachment even after - // its interface points at Galactic. Invalidate both until the new group's - // advertisement reports that the replacement attachment is carrying traffic. - if programmed := meta.FindStatusCondition(networkInterface.Status.Conditions, - networkingv1alpha.NetworkInterfaceProgrammed); programmed != nil && - programmed.Status == metav1.ConditionUnknown && programmed.Reason == reasonAPIGroupMigration { - claimProgrammed := *programmed - claimProgrammed.Type = networkingv1alpha.NetworkInterfaceClaimProgrammed - claimProgrammed.ObservedGeneration = claim.Generation - meta.SetStatusCondition(&claim.Status.Conditions, claimProgrammed) - meta.SetStatusCondition(&claim.Status.Conditions, metav1.Condition{ - Type: networkingv1alpha.NetworkInterfaceClaimReady, - Status: metav1.ConditionUnknown, - Reason: "NotProgrammed", - Message: "waiting for the Galactic attachment to be programmed", - ObservedGeneration: claim.Generation, - }) - } - if err := r.Status().Update(ctx, &claim); err != nil { - return fmt.Errorf("update network interface claim %s status: %w", key, err) - } - return nil -} - -// SetupWithManager registers the reconciler with the manager. -func (r *NetworkInterfaceReconciler) SetupWithManager(mgr ctrl.Manager) error { - // A NAD is owned by the attachment rather than the interface, but all three - // share a name and namespace, so mapping one back is an identity. - nadToInterface := func(_ context.Context, obj client.Object) []ctrl.Request { - return []ctrl.Request{{NamespacedName: client.ObjectKeyFromObject(obj)}} - } - - return ctrl.NewControllerManagedBy(mgr). - For(&networkingv1alpha.NetworkInterface{}). - Owns(&cloudv1alpha1.VPCAttachment{}). - Watches(&nadv1.NetworkAttachmentDefinition{}, handler.EnqueueRequestsFromMapFunc(nadToInterface)). - Named("networkinterface"). - Complete(r) -} diff --git a/internal/cloud/controller/networkinterface_controller_test.go b/internal/cloud/controller/networkinterface_controller_test.go deleted file mode 100644 index c839e987..00000000 --- a/internal/cloud/controller/networkinterface_controller_test.go +++ /dev/null @@ -1,235 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "testing" - - nadv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - "go.datum.net/galactic/internal/cloud/galactic" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -func TestReconcileNADTakesOverLegacyAttachment(t *testing.T) { - scheme := runtime.NewScheme() - if err := cloudv1alpha1.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - if err := nadv1.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - controller := true - old := &nadv1.NetworkAttachmentDefinition{ObjectMeta: metav1.ObjectMeta{ - Name: "nic", Namespace: "tenant", - Labels: map[string]string{legacyLabelVPC: "1", legacyLabelVPCAttachment: "2"}, - OwnerReferences: []metav1.OwnerReference{{APIVersion: "cloud.datumapis.com/v1alpha1", Kind: "VPCAttachment", Name: "nic", UID: types.UID("old"), Controller: &controller}}, - }} - cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old).Build() - r := &NetworkInterfaceReconciler{Client: cl, APIReader: cl, Scheme: scheme} - attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant", UID: types.UID("new")}} - attachment.Spec.Interface.Mode = cloudv1alpha1.VPCAttachmentInterfaceModeNetns - vpc := &cloudv1alpha1.VPC{} - vpc.Status.VPC = "1" - iface := &networkingv1alpha.NetworkInterface{} - iface.Spec.MTU = 1500 - got, err := r.reconcileNAD(context.Background(), attachment, vpc, iface) - if err != nil { - t.Fatal(err) - } - if got.Labels[LabelVPCAttachment] != "2" { - t.Fatalf("attachment ID changed: %q", got.Labels[LabelVPCAttachment]) - } - if len(got.OwnerReferences) != 1 || got.OwnerReferences[0].APIVersion != cloudv1alpha1.GroupVersion.String() || got.OwnerReferences[0].UID != attachment.UID { - t.Fatalf("owner references = %#v", got.OwnerReferences) - } -} - -func TestPublishToInterfaceReprogramsNSOAttachmentGroup(t *testing.T) { - scheme := runtime.NewScheme() - if err := networkingv1alpha.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - if err := cloudv1alpha1.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - iface := &networkingv1alpha.NetworkInterface{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} - iface.Spec.ClaimRef = &networkingv1alpha.NetworkInterfaceClaimRef{Name: "claim"} - claim := &networkingv1alpha.NetworkInterfaceClaim{ObjectMeta: metav1.ObjectMeta{Name: "claim", Namespace: "tenant"}} - claim.Status.Conditions = []metav1.Condition{ - {Type: networkingv1alpha.NetworkInterfaceClaimProgrammed, Status: metav1.ConditionTrue, Reason: "OldGroupProgrammed", LastTransitionTime: metav1.Now()}, - {Type: networkingv1alpha.NetworkInterfaceClaimReady, Status: metav1.ConditionTrue, Reason: "OldGroupReady", LastTransitionTime: metav1.Now()}, - } - iface.Status.AttachmentRef = &networkingv1alpha.NetworkInterfaceAttachmentRef{ - APIGroup: "cloud.datumapis.com", Kind: "VPCAttachment", Name: "nic", - } - iface.Status.Conditions = []metav1.Condition{{ - Type: networkingv1alpha.NetworkInterfaceProgrammed, - Status: metav1.ConditionTrue, Reason: "OldGroupProgrammed", LastTransitionTime: metav1.Now(), - }} - cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(iface, claim).WithObjects(iface, claim).Build() - r := &NetworkInterfaceReconciler{Client: cl} - attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Name: "nic", Namespace: "tenant"}} - vpc := &cloudv1alpha1.VPC{} - vpc.Status.VPC = "vpc-1" - if err := r.publishToInterface(context.Background(), iface, attachment, vpc); err != nil { - t.Fatal(err) - } - var got networkingv1alpha.NetworkInterface - if err := cl.Get(context.Background(), client.ObjectKeyFromObject(iface), &got); err != nil { - t.Fatal(err) - } - if got.Status.AttachmentRef == nil || got.Status.AttachmentRef.APIGroup != "galactic.datumapis.com" { - t.Fatalf("attachment reference = %#v, want Galactic API group", got.Status.AttachmentRef) - } - condition := apimeta.FindStatusCondition(got.Status.Conditions, networkingv1alpha.NetworkInterfaceProgrammed) - if condition == nil || condition.Status != metav1.ConditionUnknown || condition.Reason != "APIGroupMigration" { - t.Fatalf("Programmed condition = %#v, want Unknown/APIGroupMigration", condition) - } - var gotClaim networkingv1alpha.NetworkInterfaceClaim - if err := cl.Get(context.Background(), client.ObjectKeyFromObject(claim), &gotClaim); err != nil { - t.Fatal(err) - } - for _, conditionType := range []string{networkingv1alpha.NetworkInterfaceClaimProgrammed, networkingv1alpha.NetworkInterfaceClaimReady} { - condition := apimeta.FindStatusCondition(gotClaim.Status.Conditions, conditionType) - if condition == nil || condition.Status != metav1.ConditionUnknown { - t.Fatalf("claim %s condition = %#v, want Unknown during migration", conditionType, condition) - } - } -} - -func TestMasterPlugin(t *testing.T) { - tests := []struct { - mode cloudv1alpha1.VPCAttachmentInterfaceMode - want string - }{ - {cloudv1alpha1.VPCAttachmentInterfaceModeNetns, galactic.PluginVeth}, - {cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor, galactic.PluginTap}, - {cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared, galactic.PluginTap}, - } - for _, test := range tests { - t.Run(string(test.mode), func(t *testing.T) { - if got := masterPlugin(test.mode); got != test.want { - t.Errorf("got %q, want %q", got, test.want) - } - }) - } -} - -func TestClaimFulfilled(t *testing.T) { - newInterface := func(phase networkingv1alpha.NetworkInterfacePhase, allocated metav1.ConditionStatus, - contextRef *networkingv1alpha.LocalNetworkContextRef) *networkingv1alpha.NetworkInterface { - return &networkingv1alpha.NetworkInterface{ - Status: networkingv1alpha.NetworkInterfaceStatus{ - Phase: phase, - NetworkContextRef: contextRef, - Conditions: []metav1.Condition{{ - Type: networkingv1alpha.NetworkInterfaceAllocated, - Status: allocated, - Reason: "Test", - }}, - }, - } - } - contextRef := &networkingv1alpha.LocalNetworkContextRef{Name: "default-us-central-1"} - - tests := []struct { - name string - networkInterface *networkingv1alpha.NetworkInterface - want bool - }{ - {"bound and allocated", newInterface( - networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, contextRef), true}, - {"available", newInterface( - networkingv1alpha.NetworkInterfacePhaseAvailable, metav1.ConditionTrue, contextRef), false}, - {"not allocated", newInterface( - networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionFalse, contextRef), false}, - {"no network context", newInterface( - networkingv1alpha.NetworkInterfacePhaseBound, metav1.ConditionTrue, nil), false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := claimFulfilled(test.networkInterface); got != test.want { - t.Errorf("got %v, want %v", got, test.want) - } - }) - } -} - -// The cell-wide mode is the only signal every attachment written until now -// carries, so it has to keep deciding for all of them. An interface that -// states its own mode is the exception. -func TestAttachmentModeFallsBackToTheCell(t *testing.T) { - r := &NetworkInterfaceReconciler{ - AttachmentMode: cloudv1alpha1.VPCAttachmentInterfaceModeNetns, - } - - tests := []struct { - name string - iface networkingv1alpha.NetworkInterfaceAttachmentMode - want cloudv1alpha1.VPCAttachmentInterfaceMode - }{ - {"unset", "", cloudv1alpha1.VPCAttachmentInterfaceModeNetns}, - {"netns", networkingv1alpha.NetworkInterfaceAttachmentModeNetns, - cloudv1alpha1.VPCAttachmentInterfaceModeNetns}, - {"hypervisor", networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor, - cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor}, - {"declared", networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared, - cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - networkInterface := &networkingv1alpha.NetworkInterface{ - Spec: networkingv1alpha.NetworkInterfaceSpec{AttachmentMode: test.iface}, - } - if got := r.attachmentMode(networkInterface); got != test.want { - t.Errorf("got %q, want %q", got, test.want) - } - }) - } -} - -// Only the declared mode asks the tap plugin to describe the device. -// TestHostInterfaceAnnotationKey pins the annotation key this controller writes -// on the NAD. The key is a contract with the data plane: galactic writes the -// same one during CNI ADD, and a runtime reads it to learn its host device. -// Renaming it here alone would leave that runtime with nothing to read, and -// nothing else in this repository would fail. -func TestHostInterfaceAnnotationKey(t *testing.T) { - const want = "k8s.v1.cni.cncf.io/host-interface" - if AnnotationHostInterface != want { - t.Errorf("AnnotationHostInterface = %q, want %q", AnnotationHostInterface, want) - } -} - -func TestDeclaresDevice(t *testing.T) { - if declaresDevice(cloudv1alpha1.VPCAttachmentInterfaceModeHypervisor) { - t.Error("a discovered hypervisor attachment must not ask for a description") - } - if !declaresDevice(cloudv1alpha1.VPCAttachmentInterfaceModeHypervisorDeclared) { - t.Error("a declared hypervisor attachment must ask for a description") - } -} diff --git a/internal/cloud/controller/subnet_controller.go b/internal/cloud/controller/subnet_controller.go deleted file mode 100644 index 46c735dd..00000000 --- a/internal/cloud/controller/subnet_controller.go +++ /dev/null @@ -1,180 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "fmt" - "slices" - "strconv" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -// upstreamGenerationAnnotation names the generation of the project's subnet -// that the copy propagated here was taken from. The operator that owns the -// subnet stamps it on the copy, and reads a report back only for the -// generation it stamped. The key is the operator's; it is spelled out here -// because the operator version this module pins does not export it yet. -const upstreamGenerationAnnotation = "networking.datumapis.com/upstream-generation" - -// SubnetReconciler reports whether a location's address range is in place on -// this cell: the VPC built for the subnet's network context carries the range -// and is ready. It closes the Programmed condition the networking operator -// leaves for the data plane, on the copy of the Subnet propagated to this cell, -// and the federation carries the report back to the project. -// -// The range is in place when traffic can be routed in it. A VPC carries the -// range the moment it is built, because its address space is fixed at -// creation; what it waits for is the identity the fabric routes it by. -type SubnetReconciler struct { - client.Client - Scheme *runtime.Scheme -} - -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets,verbs=get;list;watch -// +kubebuilder:rbac:groups=networking.datumapis.com,resources=subnets/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=galactic.datumapis.com,resources=vpcs,verbs=get;list;watch - -func (r *SubnetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var subnet networkingv1alpha.Subnet - if err := r.Get(ctx, req.NamespacedName, &subnet); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - if !subnet.DeletionTimestamp.IsZero() { - return ctrl.Result{}, nil - } - - var vpc *cloudv1alpha1.VPC - if subnet.Spec.NetworkContext.Name != "" { - vpc = &cloudv1alpha1.VPC{} - key := client.ObjectKey{Namespace: subnet.Namespace, Name: subnet.Spec.NetworkContext.Name} - if err := r.Get(ctx, key, vpc); err != nil { - if !apierrors.IsNotFound(err) { - return ctrl.Result{}, fmt.Errorf("read the VPC for subnet %s: %w", req.NamespacedName, err) - } - vpc = nil - } - } - - condition := subnetProgrammedCondition(&subnet, vpc) - condition.ObservedGeneration = reportedGeneration(&subnet) - if !meta.SetStatusCondition(&subnet.Status.Conditions, condition) { - return ctrl.Result{}, nil - } - if err := r.Status().Update(ctx, &subnet); err != nil { - return ctrl.Result{}, fmt.Errorf("update subnet %s status: %w", req.NamespacedName, err) - } - return ctrl.Result{}, nil -} - -// subnetProgrammedCondition says whether the subnet's range is in place on this -// cell, given the VPC built for its network context, or nil for none yet. -func subnetProgrammedCondition(subnet *networkingv1alpha.Subnet, vpc *cloudv1alpha1.VPC) metav1.Condition { - condition := metav1.Condition{ - Type: networkingv1alpha.SubnetProgrammed, - Status: metav1.ConditionFalse, - } - - start, prefixLength, ok := subnetRange(subnet) - if !ok { - condition.Reason = networkingv1alpha.SubnetProgrammedReasonNotProgrammed - condition.Message = "the subnet states no range to put in place" - return condition - } - network := cloudv1alpha1.Network(fmt.Sprintf("%s/%d", start, prefixLength)) - - if vpc == nil { - condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress - condition.Message = fmt.Sprintf("waiting for the VPC for network context %q to be built", subnet.Spec.NetworkContext.Name) - return condition - } - - // The address space is fixed when the VPC is built. A range that was not - // there then is not routed by it now, and no amount of waiting changes that. - if !slices.Contains(vpc.Spec.Networks, network) { - condition.Reason = networkingv1alpha.SubnetProgrammedReasonNotProgrammed - condition.Message = fmt.Sprintf("VPC %q was built without %s; its address space is fixed at creation", vpc.Name, network) - return condition - } - - ready := meta.FindStatusCondition(vpc.Status.Conditions, cloudv1alpha1.ConditionTypeReady) - if ready == nil || ready.Status != metav1.ConditionTrue || vpc.Status.VPC == "" { - condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress - condition.Message = fmt.Sprintf("VPC %q carries %s but is not ready yet", vpc.Name, network) - if ready != nil && ready.Message != "" { - condition.Message += ": " + ready.Message - } - return condition - } - - condition.Status = metav1.ConditionTrue - condition.Reason = networkingv1alpha.SubnetProgrammedReasonProgrammed - condition.Message = fmt.Sprintf("%s is routed by VPC %s", network, vpc.Status.VPC) - return condition -} - -// reportedGeneration is the generation the report is about. Each API server -// counts generation on its own, so the copy's own generation says nothing to -// the project; the report is made against the upstream generation stamped on -// the copy, and falls back to the copy's generation for one that carries none. -func reportedGeneration(subnet *networkingv1alpha.Subnet) int64 { - if generation, err := strconv.ParseInt(subnet.Annotations[upstreamGenerationAnnotation], 10, 64); err == nil { - return generation - } - return subnet.Generation -} - -// SetupWithManager registers the reconciler with the manager. -func (r *SubnetReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&networkingv1alpha.Subnet{}). - Watches(&cloudv1alpha1.VPC{}, handler.EnqueueRequestsFromMapFunc(r.subnetsForVPC)). - Named("subnet"). - Complete(r) -} - -// subnetsForVPC names the subnets a VPC was built for, so its becoming ready -// is reported on each of them. -func (r *SubnetReconciler) subnetsForVPC(ctx context.Context, obj client.Object) []ctrl.Request { - vpc, ok := obj.(*cloudv1alpha1.VPC) - if !ok { - return nil - } - var subnets networkingv1alpha.SubnetList - if err := r.List(ctx, &subnets, client.InNamespace(vpc.Namespace)); err != nil { - return nil - } - requests := make([]ctrl.Request, 0, len(subnets.Items)) - for i := range subnets.Items { - if subnets.Items[i].Spec.NetworkContext.Name != vpc.Name { - continue - } - requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&subnets.Items[i])}) - } - return requests -} diff --git a/internal/cloud/controller/subnet_controller_test.go b/internal/cloud/controller/subnet_controller_test.go deleted file mode 100644 index 384179d0..00000000 --- a/internal/cloud/controller/subnet_controller_test.go +++ /dev/null @@ -1,163 +0,0 @@ -/* -Copyright © 2026 Datum Technology, Inc. All rights reserved. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -*/ - -package controller - -import ( - "context" - "testing" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - cloudv1alpha1 "go.datum.net/galactic/api/cloud/v1alpha1" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -// propagatedSubnet is the copy of a project's subnet a cell is handed: spec -// only, with the upstream generation the operator stamped on it. -func propagatedSubnet(upstreamGeneration string) *networkingv1alpha.Subnet { - subnet := &networkingv1alpha.Subnet{} - subnet.Namespace = vpcTestNamespace - subnet.Name = vpcTestNetwork + "-" + vpcTestLocation + "-ipv6" - subnet.Generation = 7 - if upstreamGeneration != "" { - subnet.Annotations = map[string]string{upstreamGenerationAnnotation: upstreamGeneration} - } - subnet.Spec.NetworkContext = networkingv1alpha.LocalNetworkContextRef{Name: vpcTestNetwork + "-" + vpcTestLocation} - subnet.Spec.StartAddress = "fd00::" - subnet.Spec.PrefixLength = 48 - return subnet -} - -func vpcFor(networks []cloudv1alpha1.Network, ready bool) *cloudv1alpha1.VPC { - vpc := &cloudv1alpha1.VPC{} - vpc.Namespace = vpcTestNamespace - vpc.Name = vpcTestNetwork + "-" + vpcTestLocation - vpc.Spec.Networks = networks - if ready { - vpc.Status.VPC = vpcTestIdentifier - vpc.Status.Conditions = []metav1.Condition{{ - Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue, Reason: "IdentifierAllocated", - }} - } else { - vpc.Status.Conditions = []metav1.Condition{{ - Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: "AwaitingFabricIdentity", - Message: "Waiting for the identity the fabric knows this network by", - }} - } - return vpc -} - -func reconcileSubnet(t *testing.T, objects ...client.Object) *networkingv1alpha.Subnet { - t.Helper() - - scheme := runtime.NewScheme() - if err := networkingv1alpha.AddToScheme(scheme); err != nil { - t.Fatalf("build the networking scheme: %v", err) - } - if err := cloudv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("build the cloud scheme: %v", err) - } - cl := fake.NewClientBuilder().WithScheme(scheme). - WithStatusSubresource(&networkingv1alpha.Subnet{}). - WithObjects(objects...).Build() - - reconciler := &SubnetReconciler{Client: cl, Scheme: scheme} - key := types.NamespacedName{Namespace: vpcTestNamespace, Name: vpcTestNetwork + "-" + vpcTestLocation + "-ipv6"} - if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}); err != nil { - t.Fatalf("reconcile: %v", err) - } - - subnet := &networkingv1alpha.Subnet{} - if err := cl.Get(context.Background(), key, subnet); err != nil { - t.Fatalf("read the subnet: %v", err) - } - return subnet -} - -func programmedOf(t *testing.T, subnet *networkingv1alpha.Subnet) metav1.Condition { - t.Helper() - condition := meta.FindStatusCondition(subnet.Status.Conditions, networkingv1alpha.SubnetProgrammed) - if condition == nil { - t.Fatalf("the cell should have reported on the subnet, got %+v", subnet.Status.Conditions) - } - return *condition -} - -// The signal: a range is in place once the VPC built for its network context -// carries it and is routed by the fabric. The report is made against the -// generation the project knows, not the copy's own. -func TestSubnetIsProgrammedWhenAReadyVPCCarriesItsRange(t *testing.T) { - subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, true)) - - condition := programmedOf(t, subnet) - if condition.Status != metav1.ConditionTrue || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammed { - t.Fatalf("a ready VPC carrying the range should read as programmed, got %+v", condition) - } - if condition.ObservedGeneration != 3 { - t.Fatalf("the report is about the project's generation 3, got %d", condition.ObservedGeneration) - } -} - -// A VPC that carries the range but has no identity yet is on its way, and -// says so, rather than claiming the range is routed. -func TestSubnetWaitsForTheVPCToBecomeReady(t *testing.T) { - subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, false)) - - condition := programmedOf(t, subnet) - if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress { - t.Fatalf("a VPC awaiting its identity should read as in progress, got %+v", condition) - } -} - -// No VPC yet is the same wait: the network context reconciler builds it once -// the subnet it reads from is there. -func TestSubnetWaitsForTheVPCToBeBuilt(t *testing.T) { - subnet := reconcileSubnet(t, propagatedSubnet("3")) - - condition := programmedOf(t, subnet) - if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonProgrammingInProgress { - t.Fatalf("a VPC not built yet should read as in progress, got %+v", condition) - } -} - -// A VPC's address space is fixed when it is built. A range it was built -// without is not routed by it, and that is a verdict, not a wait. -func TestSubnetIsNotProgrammedWhenTheVPCWasBuiltWithoutIt(t *testing.T) { - subnet := reconcileSubnet(t, propagatedSubnet("3"), vpcFor([]cloudv1alpha1.Network{"fd00:1::/48"}, true)) - - condition := programmedOf(t, subnet) - if condition.Status != metav1.ConditionFalse || condition.Reason != networkingv1alpha.SubnetProgrammedReasonNotProgrammed { - t.Fatalf("a range the VPC was built without should read as not programmed, got %+v", condition) - } -} - -// A copy with no upstream generation stamped on it reports against its own, -// so a project running an older operator still sees a report. -func TestSubnetReportFallsBackToTheCopysOwnGeneration(t *testing.T) { - subnet := reconcileSubnet(t, propagatedSubnet(""), vpcFor([]cloudv1alpha1.Network{"fd00::/48"}, true)) - - if got := programmedOf(t, subnet).ObservedGeneration; got != 7 { - t.Fatalf("an unstamped copy reports against its own generation 7, got %d", got) - } -} diff --git a/internal/cloud/galactic/galactic.go b/internal/cloud/galactic/galactic.go index be1fbd11..db9db73e 100644 --- a/internal/cloud/galactic/galactic.go +++ b/internal/cloud/galactic/galactic.go @@ -30,6 +30,8 @@ import ( ) const ( + LabelVPCAttachment = "galactic.datumapis.com/vpc-attachment" + // 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" diff --git a/internal/cloud/webhook/pod_webhook.go b/internal/cloud/webhook/pod_webhook.go index caa65fd8..0e783da8 100644 --- a/internal/cloud/webhook/pod_webhook.go +++ b/internal/cloud/webhook/pod_webhook.go @@ -37,7 +37,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/galactic/internal/cloud/controller" + "go.datum.net/galactic/internal/cloud/galactic" ) const ( @@ -148,7 +148,7 @@ func (i *PodInterfaceInjector) resolveNetworks( "no attachment definition prepared for NetworkInterface %s: %v", key, err)) return nil, nil, &response } - if _, ours := nad.Labels[controller.LabelVPCAttachment]; !ours { + if _, ours := nad.Labels[galactic.LabelVPCAttachment]; !ours { response := admission.Denied(fmt.Sprintf( "attachment definition %s carries no attachment identifier", key)) return nil, nil, &response