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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Provisions networking resources using ClusterUserDefinedNetwork (CUDN) on OpenSh

> **Note:** SecurityGroup enforcement (NetworkPolicy) has been extracted to the standalone
> `osac.templates.network_policy` role so it can be reused across any K8s-based NetworkClass.
> `cudn_net`'s `create_security_group`/`delete_security_group` entrypoints delegate to that
> role directly (see [Task Files](#task-files)) — the dispatcher resolves `SecurityGroup` to
> this NetworkClass's fabric manager (`cudn_net`) the same way it does for `VirtualNetwork`
> and `Subnet`, so `cudn_net` must provide these entrypoints even though the underlying
> enforcement mechanism lives in `network_policy`.

## Resources

Expand Down Expand Up @@ -57,6 +62,8 @@ This role implements the `cudn_net` NetworkClass strategy using OpenShift's Clus
- `tasks/delete_virtual_network.yaml` - Removes ClusterUserDefinedNetwork CR
- `tasks/create_subnet.yaml` - Creates namespace with CUDN labels from Subnet resource
- `tasks/delete_subnet.yaml` - Removes namespace
- `tasks/create_security_group.yaml` - Delegates to `osac.templates.network_policy` (`create_security_group`)
- `tasks/delete_security_group.yaml` - Delegates to `osac.templates.network_policy` (`delete_security_group`)

## Usage

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,24 @@ argument_specs:
type: str
required: true
description: Name of the VirtualNetwork resource

# SecurityGroup entrypoints delegate to the network_policy role (see
# tasks/create_security_group.yaml and tasks/delete_security_group.yaml).
create_security_group:
options:
security_group:
type: dict
required: true
description: SecurityGroup CR from fulfillment-api via osac_job_vars
template_parameters:
type: dict
description: Template-specific parameters (reserved for future use)
options: {}
default: {}

delete_security_group:
options:
security_group:
type: dict
required: true
description: SecurityGroup CR from fulfillment-api via osac_job_vars
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ description: >
template_type: network

# NetworkClass registration fields
# Note: no k8s_manager is set here. cudn_net's Subnet role already creates a
# self-contained ClusterUserDefinedNetwork (isolated Layer2/Primary) — there is no
# separate physical fabric to bridge into, so this NetworkClass is fabric-only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] scope-creep

Removing k8s_manager: cudn_localnet changes NetworkClass registration metadata beyond dispatcher wiring scope. Could conflict with existing records if fulfillment-service enforces k8s_manager immutability (NC-VAL-07).

Suggested fix: Confirm removal won't conflict with existing NetworkClass records. Consider splitting into OSAC-1511.

# A future k8sManager (e.g. cudn_localnet, bridging OVN to a physical VLAN via
# LocalNet topology) does not exist yet; see OSAC-1511.
implementation_strategy: cudn_net
fabric_manager: cudn_net
k8s_manager: cudn_localnet
is_default: true
capabilities:
supports_ipv4: true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
# cudn_net delegates SecurityGroup enforcement to the standalone network_policy
# role (NetworkPolicy-based), which is reusable across any K8s-based NetworkClass.
# See README.md for the rationale.

- name: Delegate SecurityGroup creation to network_policy role
ansible.builtin.include_role:
name: osac.templates.network_policy
tasks_from: create_security_group
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
# cudn_net delegates SecurityGroup enforcement to the standalone network_policy
# role (NetworkPolicy-based), which is reusable across any K8s-based NetworkClass.
# See README.md for the rationale.

- name: Delegate SecurityGroup deletion to network_policy role
ansible.builtin.include_role:
name: osac.templates.network_policy
tasks_from: delete_security_group
9 changes: 8 additions & 1 deletion osac-operator/charts/operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ tenants: []
# The operator discovers managers by selecting ConfigMaps with labels
# osac.openshift.io/network-fabric-manager or osac.openshift.io/network-k8s-manager.
networkManagers:
enabled: false
enabled: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] scope-mismatch

PR title says 'behind feature gate' but networkManagers.enabled is flipped from false to true. While controller behavior is still gated by runtime prerequisites (gRPC + namespace), the infrastructure change enables the two-manager model by default for qualifying deployments.

Suggested fix: Either revert the default to false or update PR title/description to accurately describe enablement.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] feature-gate-default-change

The networkManagers.enabled default is changed from false to true, making it active for all deployments on upgrade. The new cudn_net fabric manager entry is also enabled: true, so upgrading creates a new ConfigMap and triggers NetworkClass capability reconciliation. While the downstream impact is limited (read-reconcile loop, no unsafe mutations), changing a Helm default warrants documentation.

Suggested fix: Document in the PR description or release notes that this is an intentional default change, or keep false as the default with environment-specific overrides.

# capabilitiesSyncInterval controls how often the operator recomputes NetworkClass
# capabilities from the fabric/k8s manager ConfigMaps (in addition to reacting
# immediately to ConfigMap changes).
Expand All @@ -80,4 +80,11 @@ networkManagers:
Netris SDN controller for physical fabric management.
Manages VLAN/VxLAN segments, ACLs, public IP allocation, and NAT gateways.
capabilities: "ipv4"
cudn_net:
enabled: true
description: >-
CUDN-based isolated networking (ClusterUserDefinedNetwork). Self-contained
VirtualNetwork/Subnet provisioning with no separate physical fabric to
bridge into — used as the platform default NetworkClass.
capabilities: "ipv4,ipv6,dualStack"
k8sManagers: {}
36 changes: 24 additions & 12 deletions osac-operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,29 +508,42 @@ func setupNetworkingControllers(
return fmt.Errorf("externalip attachment provider: %w", err)
}

// Build a shared dispatcher Resolver for controllers that support the two-manager
// model (VirtualNetwork, Subnet, SecurityGroup). Only available when a
// fulfillment-service connection and networking namespace are both configured;
// nil otherwise, in which case those controllers always use the legacy
// implementation-strategy path.
var resolver *dispatcher.Resolver
if grpcConn != nil && networkingNamespace != "" {
disc, err := networkmanager.NewDiscovery(localMgr.GetClient(), networkingNamespace)
if err != nil {
return fmt.Errorf("network manager discovery: %w", err)
}
networkClassAdapter := dispatcheradapter.NewNetworkClassAdapter(privatev1.NewNetworkClassesClient(grpcConn))
resolver = dispatcher.NewResolver(networkClassAdapter, disc)

if err := setupNetworkClassCapabilitiesController(
mgr, localMgr, grpcConn, networkingNamespace,
mgr, localMgr, grpcConn, networkingNamespace, resolver,
); err != nil {
return err
}
}

if err := setupVirtualNetworkControllers(
mgr, localMgr, grpcConn, networkingNamespace,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster, resolver,
); err != nil {
return err
}
if err := setupSubnetControllers(
mgr, localMgr, grpcConn, networkingNamespace,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster, resolver,
); err != nil {
return err
}
if err := setupSecurityGroupControllers(
mgr, localMgr, grpcConn, networkingNamespace,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster,
networkingProvider, statusPollInterval, maxJobHistory, targetCluster, resolver,
); err != nil {
return err
}
Expand Down Expand Up @@ -569,13 +582,9 @@ func setupNetworkingControllers(
// is only called when grpcConn is set and a networking namespace is configured.
func setupNetworkClassCapabilitiesController(
mgr mcmanager.Manager, localMgr ctrl.Manager, grpcConn *grpc.ClientConn, networkingNamespace string,
resolver *dispatcher.Resolver,
) error {
disc, err := networkmanager.NewDiscovery(localMgr.GetClient(), networkingNamespace)
if err != nil {
return fmt.Errorf("network manager discovery: %w", err)
}
networkClassesClient := privatev1.NewNetworkClassesClient(grpcConn)
resolver := dispatcher.NewResolver(dispatcheradapter.NewNetworkClassAdapter(networkClassesClient), disc)

ncReconciler := controller.NewNetworkClassCapabilitiesReconciler(
networkClassesClient, resolver, networkingNamespace,
Expand All @@ -599,6 +608,7 @@ func setupVirtualNetworkControllers(
mgr mcmanager.Manager, localMgr ctrl.Manager, grpcConn *grpc.ClientConn,
networkingNamespace string, provider provisioning.ProvisioningProvider,
statusPollInterval time.Duration, maxJobHistory int, targetCluster multicluster.ClusterName,
resolver *dispatcher.Resolver,
) error {
if grpcConn != nil {
if err := controller.NewVirtualNetworkFeedbackReconciler(
Expand All @@ -608,7 +618,7 @@ func setupVirtualNetworkControllers(
}
}
if err := controller.NewVirtualNetworkReconciler(
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster,
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster, resolver,
).SetupWithManager(mgr); err != nil {
return fmt.Errorf("virtualnetwork controller: %w", err)
}
Expand All @@ -619,6 +629,7 @@ func setupSubnetControllers(
mgr mcmanager.Manager, localMgr ctrl.Manager, grpcConn *grpc.ClientConn,
networkingNamespace string, provider provisioning.ProvisioningProvider,
statusPollInterval time.Duration, maxJobHistory int, targetCluster multicluster.ClusterName,
resolver *dispatcher.Resolver,
) error {
if grpcConn != nil {
if err := controller.NewSubnetFeedbackReconciler(
Expand All @@ -628,7 +639,7 @@ func setupSubnetControllers(
}
}
if err := controller.NewSubnetReconciler(
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster,
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster, resolver,
).SetupWithManager(mgr); err != nil {
return fmt.Errorf("subnet controller: %w", err)
}
Expand All @@ -639,6 +650,7 @@ func setupSecurityGroupControllers(
mgr mcmanager.Manager, localMgr ctrl.Manager, grpcConn *grpc.ClientConn,
networkingNamespace string, provider provisioning.ProvisioningProvider,
statusPollInterval time.Duration, maxJobHistory int, targetCluster multicluster.ClusterName,
resolver *dispatcher.Resolver,
) error {
if grpcConn != nil {
if err := controller.NewSecurityGroupFeedbackReconciler(
Expand All @@ -648,7 +660,7 @@ func setupSecurityGroupControllers(
}
}
if err := controller.NewSecurityGroupReconciler(
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster,
mgr, networkingNamespace, provider, statusPollInterval, maxJobHistory, targetCluster, resolver,
).SetupWithManager(mgr); err != nil {
return fmt.Errorf("securitygroup controller: %w", err)
}
Expand Down
7 changes: 4 additions & 3 deletions osac-operator/internal/controller/constants_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ const (
// Used by ExternalIPPool (from its own spec) and ExternalIP (inherited from parent pool).
defaultExternalIPPoolImplementationStrategy = "metallb-l2"

// defaultSecurityGroupImplementationStrategy is the implementation strategy for SecurityGroup.
// SecurityGroup enforcement uses standard Kubernetes NetworkPolicy, independent of the
// VirtualNetwork's NetworkClass.
// defaultSecurityGroupImplementationStrategy is the fallback implementation strategy
// for SecurityGroup (standard Kubernetes NetworkPolicy) when neither the SecurityGroup
// spec nor the parent VirtualNetwork's NetworkClass (via the dispatcher path) resolve
// one.
defaultSecurityGroupImplementationStrategy = "network_policy"

conditionReasonConfigurationApplied = "ConfigurationApplied"
Expand Down
70 changes: 70 additions & 0 deletions osac-operator/internal/controller/dispatcher_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
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 controller

import (
"context"
"errors"
"fmt"

"github.com/osac-project/osac/osac-operator/pkg/dispatcher"
)

// resolveImplementationStrategy determines the value a networking controller should
// write into osacImplementationStrategyAnnotation for AAP playbook selection.
//
// When resolver is configured (non-nil, i.e. the gRPC connection and networking
// namespace needed for manager discovery are set up) and networkClassID is non-empty,
// it resolves the NetworkClass's fabric manager via the dispatcher package (the
// "dispatcher path") and returns the resolved manager's name. If the NetworkClass has

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] doc-style

Doc comment has orphaned line break mid-sentence splitting 'Any other resolution error'.

Suggested fix: Join the broken sentence.

// neither a fabricManager nor a k8sManager set yet (dispatcher.ErrNoManagerConfigured),
// it falls back to legacyStrategy (the "implementation_strategy annotation path"). Any
// other resolution error (e.g. a fabricManager referencing an unregistered manager
// ConfigMap) is returned to the caller as a real reconcile error, since that indicates
// a misconfiguration rather than an expected pre-migration state.
//
// When resolver is nil or networkClassID is empty, dispatch is skipped entirely and
// legacyStrategy is returned unchanged — this is the behavior for deployments without
// the two-manager model configured, or resources using the platform-default
// NetworkClass (which has no ID to resolve against).
func resolveImplementationStrategy(
ctx context.Context,
resolver *dispatcher.Resolver,
kind string,
networkClassID string,
legacyStrategy string,
) (string, error) {
if resolver == nil || networkClassID == "" {
return legacyStrategy, nil
}

plan, err := dispatcher.NewDispatcher(resolver).Dispatch(ctx, kind, networkClassID)
switch {
case err == nil:
target := plan.FabricTarget()
if target == nil {
// Defensive: every entry in the dispatch table includes the fabric role,
// so this should not happen in practice.
return legacyStrategy, nil
}
return target.Manager.Name, nil
case errors.Is(err, dispatcher.ErrNoManagerConfigured):
return legacyStrategy, nil
default:
return "", fmt.Errorf("resolving dispatch plan for %s (networkClass %q): %w", kind, networkClassID, err)
}
}
41 changes: 37 additions & 4 deletions osac-operator/internal/controller/securitygroup_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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/pkg/dispatcher"
"github.com/osac-project/osac/osac-operator/pkg/provisioning"
)

Expand All @@ -53,6 +54,10 @@ type SecurityGroupReconciler struct {
StatusPollInterval time.Duration
MaxJobHistory int
targetCluster mc.ClusterName
// Resolver resolves a NetworkClass to its registered managers. Nil when the
// two-manager model isn't configured (no gRPC connection / networking namespace),
// in which case the controller always uses the legacy implementation-strategy path.
Resolver *dispatcher.Resolver
}

// NewSecurityGroupReconciler creates a new reconciler for SecurityGroup resources.
Expand All @@ -63,6 +68,7 @@ func NewSecurityGroupReconciler(
statusPollInterval time.Duration,
maxJobHistory int,
targetCluster mc.ClusterName,
resolver *dispatcher.Resolver,
) *SecurityGroupReconciler {
if mgr == nil {
panic("mgr must not be nil")
Expand All @@ -83,6 +89,7 @@ func NewSecurityGroupReconciler(
StatusPollInterval: statusPollInterval,
MaxJobHistory: maxJobHistory,
targetCluster: targetCluster,
Resolver: resolver,
}
}

Expand Down Expand Up @@ -149,10 +156,36 @@ func (r *SecurityGroupReconciler) handleUpdate(ctx context.Context, sg *v1alpha1
sg.Status.Phase = v1alpha1.SecurityGroupPhaseProgressing
}

// Read implementation strategy from spec (set by fulfillment-service), fall back to default
implementationStrategy := sg.Spec.ImplementationStrategy
if implementationStrategy == "" {
implementationStrategy = defaultSecurityGroupImplementationStrategy
// Look up the parent VirtualNetwork's NetworkClass to check whether it has a
// fabricManager registered (dispatcher path).
var networkClassID string
vnetList := &v1alpha1.VirtualNetworkList{}
if err := r.List(ctx, vnetList,
client.InNamespace(sg.Namespace),
client.MatchingLabels{osacVirtualNetworkIDLabel: sg.Spec.VirtualNetwork},
); err != nil {
return ctrl.Result{}, err
} else if len(vnetList.Items) > 1 {
return ctrl.Result{}, fmt.Errorf(
"expected exactly one parent VirtualNetwork with uuid %q but found %d",
sg.Spec.VirtualNetwork, len(vnetList.Items))
} else if len(vnetList.Items) == 1 {
networkClassID = vnetList.Items[0].Spec.NetworkClass
} else {
log.Info("parent VirtualNetwork not found, using legacy implementation strategy", "uuid", sg.Spec.VirtualNetwork)
}

// Read implementation strategy from spec (set by fulfillment-service), fall back to
// default. This is the legacy value; resolveImplementationStrategy below only uses
// it when the dispatcher path isn't available (see doc comment).
legacyStrategy := sg.Spec.ImplementationStrategy
if legacyStrategy == "" {
legacyStrategy = defaultSecurityGroupImplementationStrategy

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] logic-error

SecurityGroup controller silently falls back to legacy strategy when multiple parent VirtualNetworks match the UUID label (len > 1), while the Subnet controller errors. This masks data corruption and could provision the SecurityGroup against the wrong NetworkClass.

Suggested fix: Add an ambiguity guard matching the Subnet controller pattern — error on len > 1.

}

implementationStrategy, err := resolveImplementationStrategy(ctx, r.Resolver, "SecurityGroup", networkClassID, legacyStrategy)
if err != nil {
return ctrl.Result{}, err
}

// Add implementation-strategy annotation if not present or different
Expand Down
Loading
Loading