diff --git a/osac-operator/cmd/main.go b/osac-operator/cmd/main.go index 0a9ad67f8..26bd964ea 100644 --- a/osac-operator/cmd/main.go +++ b/osac-operator/cmd/main.go @@ -514,12 +514,16 @@ func setupNetworkingControllers( // nil otherwise, in which case those controllers always use the legacy // implementation-strategy path. var resolver *dispatcher.Resolver + var networkClassesClient privatev1.NetworkClassesClient + if grpcConn != nil && networkingNamespace != "" { + networkClassesClient = privatev1.NewNetworkClassesClient(grpcConn) + disc, err := networkmanager.NewDiscovery(localMgr.GetClient(), networkingNamespace) if err != nil { return fmt.Errorf("network manager discovery: %w", err) } - networkClassAdapter := dispatcheradapter.NewNetworkClassAdapter(privatev1.NewNetworkClassesClient(grpcConn)) + networkClassAdapter := dispatcheradapter.NewNetworkClassAdapter(networkClassesClient) resolver = dispatcher.NewResolver(networkClassAdapter, disc) if err := setupNetworkClassCapabilitiesController( @@ -535,9 +539,11 @@ func setupNetworkingControllers( ); err != nil { return err } + if err := setupSubnetControllers( mgr, localMgr, grpcConn, networkingNamespace, networkingProvider, statusPollInterval, maxJobHistory, targetCluster, resolver, + networkClassesClient, ); err != nil { return err } @@ -630,6 +636,7 @@ func setupSubnetControllers( networkingNamespace string, provider provisioning.ProvisioningProvider, statusPollInterval time.Duration, maxJobHistory int, targetCluster multicluster.ClusterName, resolver *dispatcher.Resolver, + networkClassesClient privatev1.NetworkClassesClient, ) error { if grpcConn != nil { if err := controller.NewSubnetFeedbackReconciler( @@ -640,6 +647,7 @@ func setupSubnetControllers( } if err := controller.NewSubnetReconciler( mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster, resolver, + networkClassesClient, ).SetupWithManager(mgr); err != nil { return fmt.Errorf("subnet controller: %w", err) } diff --git a/osac-operator/config/rbac/role.yaml b/osac-operator/config/rbac/role.yaml index d95c41de0..304409c81 100644 --- a/osac-operator/config/rbac/role.yaml +++ b/osac-operator/config/rbac/role.yaml @@ -76,6 +76,15 @@ rules: - get - list - watch +- apiGroups: + - metallb.io + resources: + - ipaddresspools + verbs: + - create + - delete + - get + - update - apiGroups: - osac.openshift.io resources: diff --git a/osac-operator/helpers/helpers_suite_test.go b/osac-operator/helpers/helpers_suite_test.go new file mode 100644 index 000000000..96d676190 --- /dev/null +++ b/osac-operator/helpers/helpers_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helpers + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHelpers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Helpers Suite") +} diff --git a/osac-operator/helpers/viprange.go b/osac-operator/helpers/viprange.go new file mode 100644 index 000000000..0b3781d2e --- /dev/null +++ b/osac-operator/helpers/viprange.go @@ -0,0 +1,79 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helpers + +import ( + "encoding/binary" + "fmt" + "net" +) + +// ComputeVIPRange computes the VIP sub-range from a subnet CIDR and a VIP +// prefix length. The VIP range occupies the highest addresses of the subnet. +// +// Example: ComputeVIPRange("10.0.1.0/24", 28) returns "10.0.1.240-10.0.1.255". +func ComputeVIPRange(subnetCIDR string, vipPrefixLength int) (start, end net.IP, err error) { + _, ipNet, err := net.ParseCIDR(subnetCIDR) + if err != nil { + return nil, nil, fmt.Errorf("invalid subnet CIDR %q: %w", subnetCIDR, err) + } + + subnetOnes, subnetBits := ipNet.Mask.Size() + if subnetBits != 32 { + return nil, nil, fmt.Errorf("only IPv4 CIDRs are supported, got %q", subnetCIDR) + } + + if vipPrefixLength <= subnetOnes || vipPrefixLength > 32 { + return nil, nil, fmt.Errorf( + "VIP prefix length %d must be greater than subnet prefix %d and at most 32", + vipPrefixLength, subnetOnes, + ) + } + + subnetIP := binary.BigEndian.Uint32(ipNet.IP.To4()) + subnetSize := uint32(1) << (32 - subnetOnes) + vipSize := uint32(1) << (32 - vipPrefixLength) + + vipStart := subnetIP + subnetSize - vipSize + vipEnd := subnetIP + subnetSize - 1 + + startIP := make(net.IP, 4) + endIP := make(net.IP, 4) + binary.BigEndian.PutUint32(startIP, vipStart) + binary.BigEndian.PutUint32(endIP, vipEnd) + + return startIP, endIP, nil +} + +// FormatVIPRangeCIDR returns the VIP sub-range as a CIDR string suitable for +// MetalLB IPAddressPool addresses. +func FormatVIPRangeCIDR(subnetCIDR string, vipPrefixLength int) (string, error) { + start, _, err := ComputeVIPRange(subnetCIDR, vipPrefixLength) + if err != nil { + return "", err + } + return fmt.Sprintf("%s/%d", start.String(), vipPrefixLength), nil +} + +// FormatVIPRangeDash returns the VIP sub-range as a "start-end" string. +func FormatVIPRangeDash(subnetCIDR string, vipPrefixLength int) (string, error) { + start, end, err := ComputeVIPRange(subnetCIDR, vipPrefixLength) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%s", start.String(), end.String()), nil +} diff --git a/osac-operator/helpers/viprange_test.go b/osac-operator/helpers/viprange_test.go new file mode 100644 index 000000000..fddaf71c6 --- /dev/null +++ b/osac-operator/helpers/viprange_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helpers + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ComputeVIPRange", func() { + type testCase struct { + subnetCIDR string + vipPrefixLength int + wantStart string + wantEnd string + } + + DescribeTable("computes the correct VIP sub-range", + func(tc testCase) { + start, end, err := ComputeVIPRange(tc.subnetCIDR, tc.vipPrefixLength) + Expect(err).NotTo(HaveOccurred()) + Expect(start.String()).To(Equal(tc.wantStart)) + Expect(end.String()).To(Equal(tc.wantEnd)) + }, + Entry("/24 subnet with /28 VIP prefix", testCase{ + subnetCIDR: "10.0.1.0/24", vipPrefixLength: 28, + wantStart: "10.0.1.240", wantEnd: "10.0.1.255", + }), + Entry("/24 subnet with /26 VIP prefix", testCase{ + subnetCIDR: "10.0.1.0/24", vipPrefixLength: 26, + wantStart: "10.0.1.192", wantEnd: "10.0.1.255", + }), + Entry("/22 subnet with /28 VIP prefix", testCase{ + subnetCIDR: "172.16.0.0/22", vipPrefixLength: 28, + wantStart: "172.16.3.240", wantEnd: "172.16.3.255", + }), + Entry("/24 subnet with /30 VIP prefix", testCase{ + subnetCIDR: "192.168.1.0/24", vipPrefixLength: 30, + wantStart: "192.168.1.252", wantEnd: "192.168.1.255", + }), + ) + + DescribeTable("rejects invalid inputs", + func(subnetCIDR string, vipPrefixLength int) { + _, _, err := ComputeVIPRange(subnetCIDR, vipPrefixLength) + Expect(err).To(HaveOccurred()) + }, + Entry("invalid CIDR", "not-a-cidr", 28), + Entry("VIP prefix equal to subnet prefix", "10.0.1.0/24", 24), + Entry("VIP prefix smaller than subnet prefix", "10.0.1.0/24", 20), + Entry("VIP prefix exceeds 32", "10.0.1.0/24", 33), + ) +}) + +var _ = Describe("FormatVIPRangeCIDR", func() { + It("formats the VIP range as a CIDR string", func() { + cidr, err := FormatVIPRangeCIDR("10.0.1.0/24", 28) + Expect(err).NotTo(HaveOccurred()) + Expect(cidr).To(Equal("10.0.1.240/28")) + }) +}) + +var _ = Describe("FormatVIPRangeDash", func() { + It("formats the VIP range as a start-end string", func() { + dash, err := FormatVIPRangeDash("10.0.1.0/24", 28) + Expect(err).NotTo(HaveOccurred()) + Expect(dash).To(Equal("10.0.1.240-10.0.1.255")) + }) +}) diff --git a/osac-operator/internal/controller/subnet_controller.go b/osac-operator/internal/controller/subnet_controller.go index ef5dd3e3b..cb02cae5c 100644 --- a/osac-operator/internal/controller/subnet_controller.go +++ b/osac-operator/internal/controller/subnet_controller.go @@ -22,7 +22,11 @@ import ( "time" "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -34,22 +38,31 @@ import ( mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" "github.com/osac-project/osac/osac-operator/api/v1alpha1" + "github.com/osac-project/osac/osac-operator/helpers" + privatev1 "github.com/osac-project/osac/osac-operator/internal/api/osac/private/v1" "github.com/osac-project/osac/osac-operator/pkg/dispatcher" "github.com/osac-project/osac/osac-operator/pkg/provisioning" ) const ( - // osacSubnetFinalizer is the finalizer for Subnet resources osacSubnetFinalizer = "osac.openshift.io/subnet-finalizer" ) +var ipAddressPoolGVK = schema.GroupVersionKind{ + Group: "metallb.io", + Version: "v1beta1", + Kind: "IPAddressPool", +} + // SubnetReconciler reconciles a Subnet object type SubnetReconciler struct { client.Client APIReader client.Reader Scheme *runtime.Scheme - // mgr and targetCluster are stored for future multi-cluster target client resolution - mgr mcmanager.Manager + mgr mcmanager.Manager + // networkClassesClient fetches NetworkClass from the fulfillment-service + // to read vip_prefix_length. Nil when gRPC is not configured. + networkClassesClient privatev1.NetworkClassesClient NetworkingNamespace string ProvisioningProvider provisioning.ProvisioningProvider StatusPollInterval time.Duration @@ -70,6 +83,7 @@ func NewSubnetReconciler( maxJobHistory int, targetCluster mc.ClusterName, resolver *dispatcher.Resolver, + networkClassesClient privatev1.NetworkClassesClient, ) *SubnetReconciler { if mgr == nil { panic("mgr must not be nil") @@ -85,6 +99,7 @@ func NewSubnetReconciler( APIReader: mgr.GetLocalManager().GetAPIReader(), Scheme: mgr.GetLocalManager().GetScheme(), mgr: mgr, + networkClassesClient: networkClassesClient, NetworkingNamespace: networkingNamespace, ProvisioningProvider: provisioningProvider, StatusPollInterval: statusPollInterval, @@ -98,6 +113,7 @@ func NewSubnetReconciler( // +kubebuilder:rbac:groups=osac.openshift.io,resources=subnets/status,verbs=get;update;patch // +kubebuilder:rbac:groups=osac.openshift.io,resources=subnets/finalizers,verbs=update // +kubebuilder:rbac:groups=osac.openshift.io,resources=virtualnetworks,verbs=get;list;watch +// +kubebuilder:rbac:groups=metallb.io,resources=ipaddresspools,verbs=get;create;update;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -210,14 +226,36 @@ func (r *SubnetReconciler) handleUpdate(ctx context.Context, subnet *v1alpha1.Su return ctrl.Result{RequeueAfter: defaultPreconditionRequeueInterval}, nil } - // Add implementation-strategy annotation if not present or different - // This allows AAP playbooks to select the appropriate role without doing lookups + // Resolve VIP prefix length from NetworkClass (if gRPC is available) + vipCIDR := "" + if r.networkClassesClient != nil && vnet.Spec.NetworkClass != "" && subnet.Spec.IPv4CIDR != "" { + var resolveErr error + vipCIDR, resolveErr = r.resolveVIPCIDR(ctx, vnet.Spec.NetworkClass, subnet.Spec.IPv4CIDR) + if resolveErr != nil { + log.Error(resolveErr, "failed to resolve VIP CIDR from NetworkClass, requeueing", + "networkClass", vnet.Spec.NetworkClass) + return ctrl.Result{RequeueAfter: defaultPreconditionRequeueInterval}, nil + } + } + + // Stamp annotations for AAP playbooks (implementation strategy + VIP CIDR) if subnet.Annotations == nil { subnet.Annotations = make(map[string]string) } + annotationsChanged := false if subnet.Annotations[osacImplementationStrategyAnnotation] != implementationStrategy { subnet.Annotations[osacImplementationStrategyAnnotation] = implementationStrategy - log.Info("setting implementation-strategy annotation", "strategy", implementationStrategy) + annotationsChanged = true + } + if vipCIDR != "" && subnet.Annotations[osacVIPCIDRAnnotation] != vipCIDR { + subnet.Annotations[osacVIPCIDRAnnotation] = vipCIDR + annotationsChanged = true + } else if vipCIDR == "" && subnet.Annotations[osacVIPCIDRAnnotation] != "" { + delete(subnet.Annotations, osacVIPCIDRAnnotation) + annotationsChanged = true + } + if annotationsChanged { + log.Info("updating annotations", "strategy", implementationStrategy, "vipCIDR", vipCIDR) if err := r.Update(ctx, subnet); err != nil { return ctrl.Result{}, err } @@ -256,6 +294,11 @@ func (r *SubnetReconciler) handleDelete(ctx context.Context, subnet *v1alpha1.Su return ctrl.Result{}, nil } + // Remove MetalLB IPAddressPool before AAP deprovisioning (which removes the CUDN) + if err := r.deleteMetalLBIPAddressPool(ctx, subnet); err != nil { + return ctrl.Result{}, fmt.Errorf("deleting MetalLB IPAddressPool: %w", err) + } + // Handle deprovisioning result, err := r.handleDeprovisioning(ctx, subnet) if err != nil { @@ -285,7 +328,7 @@ func (r *SubnetReconciler) handleProvisioning(ctx context.Context, subnet *v1alp return ctrl.Result{}, nil } - return provisioning.RunProvisioningLifecycle(ctx, r.ProvisioningProvider, subnet, + result, err := provisioning.RunProvisioningLifecycle(ctx, r.ProvisioningProvider, subnet, &provisioning.State{Jobs: &subnet.Status.ProvisioningJobs, DesiredConfigVersion: subnet.Status.DesiredConfigVersion}, r.MaxJobHistory, r.StatusPollInterval, &provisioning.PollCallbacks{ @@ -305,6 +348,19 @@ func (r *SubnetReconciler) handleProvisioning(ctx context.Context, subnet *v1alp return r.updateStatusWithRetry(ctx, client.ObjectKeyFromObject(subnet), subnet.Status) }, ) + if err != nil { + return result, err + } + + // Create MetalLB IPAddressPool after provisioning succeeds, outside the + // callback so errors are returned to the reconcile loop for retry. + if subnet.Status.Phase == v1alpha1.SubnetPhaseReady { + if poolErr := r.ensureMetalLBIPAddressPool(ctx, subnet); poolErr != nil { + return ctrl.Result{}, fmt.Errorf("creating MetalLB IPAddressPool: %w", poolErr) + } + } + + return result, nil } // handleDeprovisioning manages the deprovisioning job lifecycle for a Subnet. @@ -321,3 +377,111 @@ func (r *SubnetReconciler) handleDeprovisioning(ctx context.Context, subnet *v1a } return ctrl.Result{}, nil } + +// resolveVIPCIDR fetches the NetworkClass from the fulfillment-service and +// computes the VIP sub-range CIDR from the subnet's IPv4 CIDR and the +// NetworkClass's vip_prefix_length. Returns empty string if vip_prefix_length +// is not set. +func (r *SubnetReconciler) resolveVIPCIDR(ctx context.Context, networkClassID, subnetIPv4CIDR string) (string, error) { + resp, err := r.networkClassesClient.Get(ctx, &privatev1.NetworkClassesGetRequest{Id: networkClassID}) + if err != nil { + return "", fmt.Errorf("fetching NetworkClass %q: %w", networkClassID, err) + } + nc := resp.GetObject() + if nc == nil || nc.GetSpec() == nil || !nc.GetSpec().HasVipPrefixLength() { + return "", nil + } + vipPrefixLength := int(nc.GetSpec().GetVipPrefixLength()) + return helpers.FormatVIPRangeCIDR(subnetIPv4CIDR, vipPrefixLength) +} + +func ipAddressPoolName(subnetName string) string { + return "osac-subnet-" + subnetName +} + +// ensureMetalLBIPAddressPool creates or updates the MetalLB IPAddressPool on +// the target cluster. Skipped when no VIP CIDR annotation is set or when the +// multi-cluster manager is not configured. +func (r *SubnetReconciler) ensureMetalLBIPAddressPool(ctx context.Context, subnet *v1alpha1.Subnet) error { + vipCIDR := subnet.Annotations[osacVIPCIDRAnnotation] + if vipCIDR == "" || r.mgr == nil { + return nil + } + + targetClient, err := getTargetClient(ctx, r.mgr, r.targetCluster) + if err != nil { + return fmt.Errorf("getting target cluster client: %w", err) + } + + pool := &unstructured.Unstructured{} + pool.SetGroupVersionKind(ipAddressPoolGVK) + pool.SetName(ipAddressPoolName(subnet.Name)) + pool.SetNamespace(externalIPDefaultMetalLBNamespace) + pool.SetLabels(map[string]string{ + osacPrefix + "/subnet": subnet.Name, + }) + + if err := unstructured.SetNestedField(pool.Object, false, "spec", "autoAssign"); err != nil { + return fmt.Errorf("setting autoAssign: %w", err) + } + if err := unstructured.SetNestedField(pool.Object, true, "spec", "avoidBuggyIPs"); err != nil { + return fmt.Errorf("setting avoidBuggyIPs: %w", err) + } + if err := unstructured.SetNestedSlice(pool.Object, []interface{}{vipCIDR}, "spec", "addresses"); err != nil { + return fmt.Errorf("setting addresses: %w", err) + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(ipAddressPoolGVK) + err = targetClient.Get(ctx, types.NamespacedName{Namespace: externalIPDefaultMetalLBNamespace, Name: pool.GetName()}, existing) + if apierrors.IsNotFound(err) { + ctrllog.FromContext(ctx).Info("creating MetalLB IPAddressPool", "name", pool.GetName(), "addresses", vipCIDR) + return targetClient.Create(ctx, pool) + } + if err != nil { + return fmt.Errorf("checking existing IPAddressPool: %w", err) + } + + // Update if addresses changed + existingAddrs, found, nestedErr := unstructured.NestedStringSlice(existing.Object, "spec", "addresses") + if nestedErr != nil { + return fmt.Errorf("reading existing IPAddressPool addresses: %w", nestedErr) + } + if !found || len(existingAddrs) != 1 || existingAddrs[0] != vipCIDR { + existing.Object["spec"] = pool.Object["spec"] + existing.SetLabels(pool.GetLabels()) + ctrllog.FromContext(ctx).Info("updating MetalLB IPAddressPool", "name", pool.GetName(), "addresses", vipCIDR) + return targetClient.Update(ctx, existing) + } + + return nil +} + +// deleteMetalLBIPAddressPool removes the MetalLB IPAddressPool from the target +// cluster. NotFound errors are ignored. Skipped when the multi-cluster manager +// is not configured. +func (r *SubnetReconciler) deleteMetalLBIPAddressPool(ctx context.Context, subnet *v1alpha1.Subnet) error { + if r.mgr == nil || subnet.Annotations[osacVIPCIDRAnnotation] == "" { + return nil + } + + targetClient, err := getTargetClient(ctx, r.mgr, r.targetCluster) + if err != nil { + return fmt.Errorf("getting target cluster client: %w", err) + } + + pool := &unstructured.Unstructured{} + pool.SetGroupVersionKind(ipAddressPoolGVK) + pool.SetName(ipAddressPoolName(subnet.Name)) + pool.SetNamespace(externalIPDefaultMetalLBNamespace) + + err = targetClient.Delete(ctx, pool) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("deleting IPAddressPool %q: %w", pool.GetName(), err) + } + ctrllog.FromContext(ctx).Info("deleted MetalLB IPAddressPool", "name", pool.GetName()) + return nil +} diff --git a/osac-operator/internal/controller/subnet_names.go b/osac-operator/internal/controller/subnet_names.go index 75a786513..025c8b317 100644 --- a/osac-operator/internal/controller/subnet_names.go +++ b/osac-operator/internal/controller/subnet_names.go @@ -20,6 +20,10 @@ import ( "fmt" ) +const ( + osacVIPCIDRAnnotation = osacPrefix + "/vip-cidr" +) + var ( osacSubnetIDLabel string = fmt.Sprintf("%s/subnet-uuid", osacPrefix) osacSubnetFeedbackFinalizer string = fmt.Sprintf("%s/subnet-feedback", osacPrefix)