Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 3 additions & 35 deletions cmd/galactic-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ type options struct {
webhookCertDir string
leaderElect bool
controllerUsername string
attachmentMode string
identityClass string
identityNamespace string
platformProject string
Expand Down Expand Up @@ -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:<namespace>: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")
Expand All @@ -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"
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions config/galactic-controller/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 6 additions & 7 deletions config/galactic-controller/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
28 changes: 15 additions & 13 deletions docs/migration/galactic-api-consolidation.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions internal/cloud/controller/bgpadvertisement_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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)
}
101 changes: 101 additions & 0 deletions internal/cloud/controller/bgpadvertisement_controller_test.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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: "APIGroupMigration",
})
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)
}
}
Loading
Loading