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