From 072f132f24eff85f9cb4e13e847385aa37ac0dfc Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 11 Sep 2026 18:58:46 -0500 Subject: [PATCH] feat(compute): publish a class's network attachment with the class A general-purpose guest and a unikernel guest both take a hypervisor interface, but their runtimes learn about the device differently. Nothing compute created said so, so the networking layer had only a cell-wide setting to go on and every guest in a cell was attached the same way. The provider that publishes a runtime class now states how guests in that class take an interface, so adding a class stays one declarative act with no configuration to edit and no rollout anywhere. The catalog is readable only where deployments are created, so the answer is resolved there and carried to the cell, which asks the networking layer for it when it claims an interface. A class that states nothing, which is every class published today, asks for nothing and leaves the cell deciding exactly as it does now. Co-Authored-By: Claude Opus 5 (1M context) --- api/v1alpha/runtimeclass_types.go | 59 +++++++++- api/v1alpha/workloaddeployment_types.go | 11 ++ .../compute.datumapis.com_runtimeclasses.yaml | 22 ++++ ...ute.datumapis.com_workloaddeployments.yaml | 14 +++ docs/enhancements/runtime-classes/README.md | 17 +++ go.mod | 2 +- go.sum | 4 +- internal/controller/networkinterfaceclaim.go | 18 ++- .../controller/networkinterfaceclaim_test.go | 9 +- internal/controller/workload_controller.go | 43 +++++++ .../workload_networkattachment_test.go | 108 ++++++++++++++++++ .../workloaddeployment_controller.go | 21 +++- 12 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 internal/controller/workload_networkattachment_test.go diff --git a/api/v1alpha/runtimeclass_types.go b/api/v1alpha/runtimeclass_types.go index 17913bdc..36c00c7e 100644 --- a/api/v1alpha/runtimeclass_types.go +++ b/api/v1alpha/runtimeclass_types.go @@ -115,6 +115,29 @@ const ( RuntimeClassLifecycleSnapshot RuntimeClassLifecycleOperation = "Snapshot" ) +// RuntimeClassNetworkAttachment is how a guest in the class takes the network +// interface the platform gives it. The platform reads it to wire the instance; +// it is never handed to a runtime. +// +// +kubebuilder:validation:Enum=Netns;Hypervisor;HypervisorDeclared +type RuntimeClassNetworkAttachment string + +const ( + // RuntimeClassNetworkAttachmentNetns places the interface in the guest's + // network namespace, which is what an ordinary container takes. + RuntimeClassNetworkAttachmentNetns RuntimeClassNetworkAttachment = "Netns" + + // RuntimeClassNetworkAttachmentHypervisor hands the interface to a + // hypervisor as a device that the hypervisor finds from what the node + // publishes. + RuntimeClassNetworkAttachmentHypervisor RuntimeClassNetworkAttachment = "Hypervisor" + + // RuntimeClassNetworkAttachmentHypervisorDeclared hands the interface to a + // hypervisor as a device and has the platform state that device to the + // hypervisor. A runtime that reads no node state needs it. + RuntimeClassNetworkAttachmentHypervisorDeclared RuntimeClassNetworkAttachment = "HypervisorDeclared" +) + // RuntimeClassIsolation describes what separates a workload in the class from // other tenants' workloads. Multi-tenant customers report this boundary to // their own auditors, so the API publishes it and holds it stable across @@ -194,12 +217,17 @@ type RuntimeClassLifecycle struct { // billing dimensions a class is metered on, and customers are billed against // the catalog. Restating a price here would create a second source of truth. // -// Provider-specific parameters are deliberately absent as well. Everything here -// is what a customer is promised, and the platform reserves the right to change -// which runtime a provider uses to keep that promise. A provider slot on this -// object would also put runtime configuration one RBAC mistake away from a -// tenant, which is the escape path this design closes. Provider configuration -// stays with the provider's own deployment. +// This object carries the contract published to a customer, plus the minimum +// the platform itself needs to wire an instance of the class. Nothing else +// belongs here. +// +// Opaque provider parameters remain deliberately absent. The platform reserves +// the right to change which runtime a provider uses to keep the published +// promise, and a pass-through slot on this object would put runtime +// configuration one RBAC mistake away from a tenant, which is the escape path +// this design closes. Provider configuration stays with the provider's own +// deployment. A field the platform reads itself, drawn from a closed set of +// values, reaches no runtime and is not such a slot. type RuntimeClassSpec struct { // The controller that implements this class. A provider watches for classes // carrying its own controller name, claims them, and reports through the @@ -252,6 +280,25 @@ type RuntimeClassSpec struct { // // +kubebuilder:validation:Optional Lifecycle RuntimeClassLifecycle `json:"lifecycle,omitempty"` + + // How a guest in this class takes the network interface the platform gives + // it. The provider that publishes the class states it, because only the + // provider knows what its runtime expects. + // + // Leaving it empty is the right answer for any class whose guests take the + // interface the cell already gives them, and it is what every class + // published today does. An empty value asks the networking layer for + // nothing, so the cell's own setting continues to decide. Stating a value + // overrides that setting for every guest in the class, in every cell, so + // state one only for a class whose runtime cannot use what the cell would + // otherwise give it. + // + // The value is resolved when a deployment is created and then fixed for the + // life of each instance, so correcting it here moves new instances without + // disturbing running ones. + // + // +kubebuilder:validation:Optional + NetworkAttachment RuntimeClassNetworkAttachment `json:"networkAttachment,omitempty"` } // Condition types reported on a RuntimeClass. diff --git a/api/v1alpha/workloaddeployment_types.go b/api/v1alpha/workloaddeployment_types.go index f6dc1c29..c8dbefe2 100644 --- a/api/v1alpha/workloaddeployment_types.go +++ b/api/v1alpha/workloaddeployment_types.go @@ -37,6 +37,17 @@ type WorkloadDeploymentSpec struct { // // +kubebuilder:validation:Optional Replicas *int32 `json:"replicas,omitempty"` + + // How a guest takes the network interface the platform gives it, resolved + // from the runtime class this deployment runs in. + // + // The class catalog is readable only where a deployment is created, so the + // answer is resolved once here and carried to the cell that acts on it. An + // empty value asks the networking layer for nothing and leaves the cell's + // own setting deciding, which is what every deployment does today. + // + // +kubebuilder:validation:Optional + NetworkAttachment RuntimeClassNetworkAttachment `json:"networkAttachment,omitempty"` } // WorkloadDeploymentStatus defines the observed state of WorkloadDeployment diff --git a/config/base/crd/bases/compute.datumapis.com_runtimeclasses.yaml b/config/base/crd/bases/compute.datumapis.com_runtimeclasses.yaml index 04bb1926..1bed9b23 100644 --- a/config/base/crd/bases/compute.datumapis.com_runtimeclasses.yaml +++ b/config/base/crd/bases/compute.datumapis.com_runtimeclasses.yaml @@ -213,6 +213,28 @@ spec: measure it. type: string type: object + networkAttachment: + description: |- + How a guest in this class takes the network interface the platform gives + it. The provider that publishes the class states it, because only the + provider knows what its runtime expects. + + Leaving it empty is the right answer for any class whose guests take the + interface the cell already gives them, and it is what every class + published today does. An empty value asks the networking layer for + nothing, so the cell's own setting continues to decide. Stating a value + overrides that setting for every guest in the class, in every cell, so + state one only for a class whose runtime cannot use what the cell would + otherwise give it. + + The value is resolved when a deployment is created and then fixed for the + life of each instance, so correcting it here moves new instances without + disturbing running ones. + enum: + - Netns + - Hypervisor + - HypervisorDeclared + type: string required: - capabilities - controllerName diff --git a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml index dfd6c30a..19abf26f 100644 --- a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml +++ b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml @@ -75,6 +75,20 @@ spec: required: - name type: object + networkAttachment: + description: |- + How a guest takes the network interface the platform gives it, resolved + from the runtime class this deployment runs in. + + The class catalog is readable only where a deployment is created, so the + answer is resolved once here and carried to the cell that acts on it. An + empty value asks the networking layer for nothing and leaves the cell's + own setting deciding, which is what every deployment does today. + enum: + - Netns + - Hypervisor + - HypervisorDeclared + type: string placementName: description: The placement in the workload which is driving a deployment type: string diff --git a/docs/enhancements/runtime-classes/README.md b/docs/enhancements/runtime-classes/README.md index cdfc3f18..c26e2790 100644 --- a/docs/enhancements/runtime-classes/README.md +++ b/docs/enhancements/runtime-classes/README.md @@ -255,6 +255,23 @@ one class may run its instances as containers on a host, another may provision a machine from a cloud provider. The platform's abstraction is the instance, not any particular realization of it, and this proposal does not narrow that. +**Realization a neighboring system needs is published with the class.** Some classes need +the networking layer to attach an interface differently, because their runtime is told +about the device rather than discovering it. The provider that publishes the class states +that alongside it, so adding a class carries its own answer and stays a single declarative +act. Requiring an operator to edit configuration and roll a manager in every cell before a +new class worked would make the class object documentation rather than an API. + +The class object holds the published contract plus the minimum the platform needs to wire +an instance. This field is read by the platform and drawn from a closed set of values, so +it never reaches a runtime; opaque provider parameters remain excluded. Because the +catalog is readable where deployments are created and not in a cell, the answer is +resolved once there and travels with the deployment, which also removes any chance of a +deployment reaching a cell before the class does. + +A class that states nothing leaves the cell's own setting deciding, which is what every +class published today does. + **Capability gaps are validated, not silently dropped.** A class will not support everything the instance API can express — some won't support disk-backed volumes, some will constrain where images may be pulled from. Today an unsupported feature can be diff --git a/go.mod b/go.mod index bca9a2d5..8a4e69e5 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 - go.datum.net/network-services-operator v0.26.5-0.20260911135634-aeaa6ff1ab23 + go.datum.net/network-services-operator v0.26.5-0.20260911235442-196ee79e9ebf // Pinned by pseudo-version to the commit deployed to staging, which is the // same one network-services-operator pins. The module publishes no tag yet. go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c diff --git a/go.sum b/go.sum index 99a75413..1c36740c 100644 --- a/go.sum +++ b/go.sum @@ -337,8 +337,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= go.datum.net/datumctl v0.17.1-0.20260710003126-296c2fcbbd67 h1:Mhgt688CeTh2hX7ql61ySR3+/YHdZMgYBSFhBW7W2gA= go.datum.net/datumctl v0.17.1-0.20260710003126-296c2fcbbd67/go.mod h1:6skEjcE7aT8VPf/HVamA/BB6Dc9IISA6c/DdYKhqWNc= -go.datum.net/network-services-operator v0.26.5-0.20260911135634-aeaa6ff1ab23 h1:a6qaqnhT1eLueTvAgkebWmZ+8taFaQwAX7knVEPYEW4= -go.datum.net/network-services-operator v0.26.5-0.20260911135634-aeaa6ff1ab23/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= +go.datum.net/network-services-operator v0.26.5-0.20260911235442-196ee79e9ebf h1:kShApE3Ghq/wv4G38CFb91jMSWTwD+SiPd84yyHzffk= +go.datum.net/network-services-operator v0.26.5-0.20260911235442-196ee79e9ebf/go.mod h1:9nuuBWdrkdnIBMaWJsWM3j4CcbKJIF1GDuCmAjdpIHo= go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c h1:+BQirT3wYCgv7H2lEZtAH+dpMiWu9j/Wa+c8s4jJUIA= go.miloapis.com/locations v0.0.0-20260825185141-507ac2cbd48c/go.mod h1:gzfAfHhSMwl/N68k/uSNYXOKK3IOBJCdXYaBgCE3gdE= go.miloapis.com/milo v0.32.0 h1:TkNIQu/37d+SEquLJ5+GmdisSl+K2RT7eEC4idg6RIs= diff --git a/internal/controller/networkinterfaceclaim.go b/internal/controller/networkinterfaceclaim.go index e6e1950d..c8d4d5c1 100644 --- a/internal/controller/networkinterfaceclaim.go +++ b/internal/controller/networkinterfaceclaim.go @@ -70,12 +70,20 @@ func instanceInterfaceName(networkInterface computev1alpha.InstanceNetworkInterf // is already location scoped. networkInterfaceName is left unset so the claim // binds the interface of its own name — the retained interface, when there is // one. -func desiredNetworkInterfaceClaimSpec(networkInterface computev1alpha.InstanceNetworkInterface) networkingv1alpha.NetworkInterfaceClaimSpec { +// +// An empty attachment mode is left off the claim, which leaves the networking +// API's own default in force and the data plane free to attach the interface +// the way the cell already attaches every other one. +func desiredNetworkInterfaceClaimSpec( + networkInterface computev1alpha.InstanceNetworkInterface, + attachmentMode networkingv1alpha.NetworkInterfaceAttachmentMode, +) networkingv1alpha.NetworkInterfaceClaimSpec { spec := networkingv1alpha.NetworkInterfaceClaimSpec{ - Network: networkingv1alpha.LocalNetworkRef{Name: networkInterface.Network.Name}, - InterfaceName: instanceInterfaceName(networkInterface), - IPFamilies: append([]networkingv1alpha.IPFamily(nil), networkInterface.IPFamilies...), - ReclaimPolicy: networkInterface.ReclaimPolicy, + Network: networkingv1alpha.LocalNetworkRef{Name: networkInterface.Network.Name}, + InterfaceName: instanceInterfaceName(networkInterface), + IPFamilies: append([]networkingv1alpha.IPFamily(nil), networkInterface.IPFamilies...), + ReclaimPolicy: networkInterface.ReclaimPolicy, + AttachmentMode: attachmentMode, } for _, address := range networkInterface.Addresses { diff --git a/internal/controller/networkinterfaceclaim_test.go b/internal/controller/networkinterfaceclaim_test.go index d2996dc2..9e223998 100644 --- a/internal/controller/networkinterfaceclaim_test.go +++ b/internal/controller/networkinterfaceclaim_test.go @@ -103,7 +103,7 @@ func TestDesiredNetworkInterfaceClaimSpec(t *testing.T) { Addresses: []computev1alpha.InstanceNetworkInterfaceAddressRequest{ {Class: claimTestClass}, }, - }) + }, networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared) assert.Equal(t, claimTestNetwork, spec.Network.Name) assert.Equal(t, "eth1", spec.InterfaceName) @@ -114,10 +114,15 @@ func TestDesiredNetworkInterfaceClaimSpec(t *testing.T) { assert.Empty(t, spec.NetworkInterfaceName, "the claim must bind the interface of its own name so a retained one is reused") + assert.Equal(t, networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared, + spec.AttachmentMode) + defaulted := desiredNetworkInterfaceClaimSpec(computev1alpha.InstanceNetworkInterface{ Network: networkingv1alpha.NetworkRef{Name: claimTestNetwork}, - }) + }, "") assert.Equal(t, defaultInterfaceName, defaulted.InterfaceName) + assert.Empty(t, defaulted.AttachmentMode, + "a cell that states no mode leaves the networking default in force") } // TestNetworkInterfaceClaimSatisfied is the regression guard for the readiness diff --git a/internal/controller/workload_controller.go b/internal/controller/workload_controller.go index 74e54d1c..1bcfc266 100644 --- a/internal/controller/workload_controller.go +++ b/internal/controller/workload_controller.go @@ -31,7 +31,9 @@ import ( mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/features" "go.datum.net/compute/internal/locations" + "go.datum.net/compute/pkg/runtimeclass" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" @@ -60,6 +62,7 @@ type WorkloadReconciler struct { } // +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloads,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=compute.datumapis.com,resources=runtimeclasses,verbs=get;list;watch // +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloads/status,verbs=get;update;patch // +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloads/finalizers,verbs=update // +kubebuilder:rbac:groups=networking.datumapis.com,resources=networks,verbs=get;list;watch @@ -471,6 +474,39 @@ func (r *WorkloadReconciler) Finalize(ctx context.Context, obj client.Object) (f // getDeploymentsForWorkload returns both deployments that are desired to exist // for a workload, and deployments that have been orphaned and should be // removed. +// networkAttachment resolves how the workload's guests take a network +// interface, from the runtime class the workload runs in. +// +// The catalog is readable here and nowhere downstream, so the answer travels +// with the deployment rather than being looked up again in a cell. A class the +// catalog does not publish, or one that states nothing, resolves to nothing +// and leaves the cell deciding. +func (r *WorkloadReconciler) networkAttachment( + ctx context.Context, + upstreamClient client.Client, + workload *computev1alpha.Workload, +) (computev1alpha.RuntimeClassNetworkAttachment, error) { + if !features.FeatureGate.Enabled(features.RuntimeClasses) { + return "", nil + } + + className := workload.Spec.Template.Spec.Runtime.Class + if className == "" { + return "", nil + } + + var classes computev1alpha.RuntimeClassList + if err := upstreamClient.List(ctx, &classes); err != nil { + return "", fmt.Errorf("failed listing runtime classes: %w", err) + } + + class := runtimeclass.Catalog(classes.Items).Find(className) + if class == nil { + return "", nil + } + return class.Spec.NetworkAttachment, nil +} + func (r *WorkloadReconciler) getDeploymentsForWorkload( ctx context.Context, upstreamClient client.Client, @@ -497,6 +533,11 @@ func (r *WorkloadReconciler) getDeploymentsForWorkload( return nil, nil, err } + networkAttachment, err := r.networkAttachment(ctx, upstreamClient, workload) + if err != nil { + return nil, nil, err + } + if len(placementLocations) == 0 { return nil, nil, fmt.Errorf("no locations are registered with the system") } @@ -540,6 +581,8 @@ func (r *WorkloadReconciler) getDeploymentsForWorkload( Template: workload.Spec.Template, ScaleSettings: placement.ScaleSettings, Replicas: new(placement.ScaleSettings.MinReplicas), + + NetworkAttachment: networkAttachment, }, }) } diff --git a/internal/controller/workload_networkattachment_test.go b/internal/controller/workload_networkattachment_test.go new file mode 100644 index 00000000..1c83c317 --- /dev/null +++ b/internal/controller/workload_networkattachment_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + featuregatetesting "k8s.io/component-base/featuregate/testing" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/features" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +func networkAttachmentTestClient(classes ...client.Object) client.Client { + scheme := runtime.NewScheme() + if err := computev1alpha.AddToScheme(scheme); err != nil { + panic(err) + } + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(classes...).Build() +} + +func runtimeClass(name string, attachment computev1alpha.RuntimeClassNetworkAttachment) *computev1alpha.RuntimeClass { + return &computev1alpha.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: computev1alpha.RuntimeClassSpec{ + ControllerName: "compute.datumapis.com/test-provider", + Isolation: computev1alpha.RuntimeClassIsolation{Boundary: "test"}, + NetworkAttachment: attachment, + }, + } +} + +func workloadInClass(class string) *computev1alpha.Workload { + workload := &computev1alpha.Workload{} + workload.Spec.Template.Spec.Runtime.Class = class + return workload +} + +// The class catalog is readable only where a deployment is created, so the +// answer is resolved there. A class that states nothing, and a class the +// catalog does not publish, both leave the cell deciding. +func TestWorkloadNetworkAttachmentResolvesFromTheCatalog(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, features.MutableFeatureGate, features.RuntimeClasses, true) + + cl := networkAttachmentTestClient( + runtimeClass("general-purpose", computev1alpha.RuntimeClassNetworkAttachmentHypervisorDeclared), + runtimeClass("unikernel", ""), + ) + r := &WorkloadReconciler{} + + attachment, err := r.networkAttachment(context.Background(), cl, workloadInClass("general-purpose")) + require.NoError(t, err) + assert.Equal(t, computev1alpha.RuntimeClassNetworkAttachmentHypervisorDeclared, attachment) + + attachment, err = r.networkAttachment(context.Background(), cl, workloadInClass("unikernel")) + require.NoError(t, err) + assert.Empty(t, attachment, "a class that states nothing leaves the cell deciding") + + attachment, err = r.networkAttachment(context.Background(), cl, workloadInClass("not-published")) + require.NoError(t, err) + assert.Empty(t, attachment, "a class the catalog does not publish resolves to nothing") + + attachment, err = r.networkAttachment(context.Background(), cl, workloadInClass("")) + require.NoError(t, err) + assert.Empty(t, attachment, "a workload in no class resolves to nothing") +} + +// With runtime class selection off, the catalog is not consulted at all. +func TestWorkloadNetworkAttachmentIgnoredWhenClassesDisabled(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, features.MutableFeatureGate, features.RuntimeClasses, false) + + cl := networkAttachmentTestClient( + runtimeClass("general-purpose", computev1alpha.RuntimeClassNetworkAttachmentHypervisorDeclared)) + r := &WorkloadReconciler{} + + attachment, err := r.networkAttachment(context.Background(), cl, workloadInClass("general-purpose")) + require.NoError(t, err) + assert.Empty(t, attachment) +} + +// A deployment carries the resolved answer, and the cell translates it without +// consulting anything. +func TestNetworkAttachmentMode(t *testing.T) { + t.Parallel() + + deployment := func(attachment computev1alpha.RuntimeClassNetworkAttachment) *computev1alpha.WorkloadDeployment { + return &computev1alpha.WorkloadDeployment{ + Spec: computev1alpha.WorkloadDeploymentSpec{NetworkAttachment: attachment}, + } + } + + assert.Equal(t, networkingv1alpha.NetworkInterfaceAttachmentModeNetns, + networkAttachmentMode(deployment(computev1alpha.RuntimeClassNetworkAttachmentNetns))) + assert.Equal(t, networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor, + networkAttachmentMode(deployment(computev1alpha.RuntimeClassNetworkAttachmentHypervisor))) + assert.Equal(t, networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared, + networkAttachmentMode(deployment(computev1alpha.RuntimeClassNetworkAttachmentHypervisorDeclared))) + assert.Empty(t, networkAttachmentMode(deployment("")), + "a deployment carrying nothing asks the networking layer for nothing") +} diff --git a/internal/controller/workloaddeployment_controller.go b/internal/controller/workloaddeployment_controller.go index 67cf7307..b2c79341 100644 --- a/internal/controller/workloaddeployment_controller.go +++ b/internal/controller/workloaddeployment_controller.go @@ -69,6 +69,25 @@ type WorkloadDeploymentReconciler struct { LocationSource locations.Source } +// networkAttachmentMode translates the attachment the deployment carries into +// what the networking API calls it. The deployment holds the answer already, +// resolved where the runtime class catalog is readable, so nothing is looked +// up here. +func networkAttachmentMode( + deployment *computev1alpha.WorkloadDeployment, +) networkingv1alpha.NetworkInterfaceAttachmentMode { + switch deployment.Spec.NetworkAttachment { + case computev1alpha.RuntimeClassNetworkAttachmentNetns: + return networkingv1alpha.NetworkInterfaceAttachmentModeNetns + case computev1alpha.RuntimeClassNetworkAttachmentHypervisor: + return networkingv1alpha.NetworkInterfaceAttachmentModeHypervisor + case computev1alpha.RuntimeClassNetworkAttachmentHypervisorDeclared: + return networkingv1alpha.NetworkInterfaceAttachmentModeHypervisorDeclared + default: + return "" + } +} + func effectiveDesiredReplicas(deployment *computev1alpha.WorkloadDeployment) int32 { if !deployment.DeletionTimestamp.IsZero() { return 0 @@ -814,7 +833,7 @@ func (r *WorkloadDeploymentReconciler) ensureNetworkInterfaceClaim( Name: key.Name, Labels: labels, }, - Spec: desiredNetworkInterfaceClaimSpec(networkInterface), + Spec: desiredNetworkInterfaceClaimSpec(networkInterface, networkAttachmentMode(deployment)), } // The claim belongs to the instance, not the deployment: the instance going