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
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "NetworkContext")
os.Exit(1)
}
if err := (&controller.SubnetReconciler{
Client: mgr.GetClient(), Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Subnet")
os.Exit(1)
}
if err := (&controller.NetworkInterfaceReconciler{
Client: mgr.GetClient(), Scheme: mgr.GetScheme(), APIReader: mgr.GetAPIReader(),
AttachmentMode: attachmentMode,
Expand Down
1 change: 1 addition & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ rules:
resources:
- networkinterfaceclaims/status
- networkinterfaces/status
- subnets/status
verbs:
- get
- patch
Expand Down
180 changes: 180 additions & 0 deletions internal/controller/subnet_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
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"
"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/cloud/api/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=cloud.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
}
163 changes: 163 additions & 0 deletions internal/controller/subnet_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
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"
"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/cloud/api/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)
}
}
Loading