diff --git a/cmd/net-crd-installer/utils/util_test.go b/cmd/net-crd-installer/utils/util_test.go index 6f684f3b..9c52e343 100644 --- a/cmd/net-crd-installer/utils/util_test.go +++ b/cmd/net-crd-installer/utils/util_test.go @@ -53,11 +53,24 @@ func runTest(t *testing.T, crdPath string) { wantError bool }{ { + // Hub mode installs every CRD found in config/crd/bases (see CollectCRDs + // in util.go: no explicit hub allow-list). This deliberately includes CRDs + // whose reconciler runs in a sibling pod on the same hub cluster — e.g. + // FrontDoorProfile and FrontDoorCustomDomain are managed by the + // hub-afd-controller-manager binary (see cmd/hub-afd-controller-manager and + // charts/hub-afd-controller-manager). The AFD/ATM controller-identity split + // required by SFI-NS253 is enforced at the pod/ServiceAccount layer via + // separate ClusterRoles per chart, NOT by withholding CRDs from the hub. + // Keep this list in sync with the set of *.yaml files in config/crd/bases + // that carry group "networking.fleet.azure.com". name: "hub mode excludes MultiClusterService CRD", mode: "hub", wantedCRDNames: []string{ "endpointsliceexports.networking.fleet.azure.com", "endpointsliceimports.networking.fleet.azure.com", + "frontdoorbackends.networking.fleet.azure.com", + "frontdoorcustomdomains.networking.fleet.azure.com", + "frontdoorprofiles.networking.fleet.azure.com", "internalserviceexports.networking.fleet.azure.com", "internalserviceimports.networking.fleet.azure.com", "serviceexports.networking.fleet.azure.com", diff --git a/pkg/common/azurefrontdoor/client.go b/pkg/common/azurefrontdoor/client.go new file mode 100644 index 00000000..e1b4c4d5 --- /dev/null +++ b/pkg/common/azurefrontdoor/client.go @@ -0,0 +1,233 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package azurefrontdoor provides Azure client construction for the Front Door +// (AFD) controllers. +// +// Authentication (breadcrumb D6): +// - Uses Azure AD Workload Identity (federated token) via +// azidentity.NewWorkloadIdentityCredential. This is deliberately chosen over +// managed-identity-with-mounted-azure.json (the ATM controller's approach) +// because Workload Identity is the AKS-supported path forward for new +// controllers, integrates cleanly with the projected ServiceAccount token +// mounted by the azure-workload-identity mutating webhook, and does not +// require the controller pod to read a cloud provider config file. +// - Reads AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, +// AZURE_SUBSCRIPTION_ID from the environment. The first three are set +// automatically by the workload-identity webhook when the pod's +// ServiceAccount is annotated appropriately; AZURE_SUBSCRIPTION_ID is +// supplied via chart values. +// +// Scope (breadcrumb D7): +// - Intentionally does not share code with pkg/common/azuretrafficmanager. +// The two controllers have distinct SDKs (armcdn vs. armtrafficmanager), +// distinct identities per Proposal 001 §7, and distinct release timelines; +// a shared abstraction would couple them without simplifying anything. +// +// Identity-sharing note (POC bridge, Proposal 001 §7 gap): +// - This package's Config maps 1:1 to the AFD-scoped Workload-Identity +// federated subject. Proposal 001 §7 requires that this subject be +// DISTINCT from the ATM controller's subject so ATM-only tenants do not +// inherit AFD write permissions. Because a Kubernetes pod projects +// exactly one WI federated token, achieving that isolation requires the +// AFD controllers to run in a separate pod. The current POC hosts them +// inside cmd/hub-net-controller-manager under --enable-frontdoor-feature +// as a temporary bridge, which shares one subject across both controllers. +// Migrating to a sibling binary (cmd/hub-afd-controller-manager) + sibling +// chart (charts/hub-afd-controller-manager) is a hard GA prerequisite; +// see docs/first-party/003-pre-implementation-checklist.md §2.4. +package azurefrontdoor + +import ( + "errors" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + + // armcdn is the Azure SDK for Front Door Standard/Premium. Pinned to v2 + // (2024-02-01 API) so tests can use the SDK-provided armcdn/v2/fake + // package — v1.x does not ship a fake subpackage. The imported name + // stays `armcdn` (no explicit alias needed) so call sites are unchanged + // across the version bump. + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + + // armfrontdoor is the classic Front Door SDK; we do NOT use its classic + // FrontDoors/FrontendEndpoints APIs (superseded by armcdn's AFD types), + // but its PoliciesClient is the ONLY SDK-supported way to read/write + // Microsoft.Network/frontdoorwebapplicationfirewallpolicies — the WAF + // policy type that FrontDoorProfile.spec.wafPolicy references. Pinned to + // v1.4.0 because it ships an armfrontdoor/fake package (PoliciesServer) + // mirroring the armcdn/v2/fake pattern; earlier v1.x releases don't. + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/frontdoor/armfrontdoor" +) + +// Environment variable names read by LoadConfigFromEnv. AZURE_CLIENT_ID, +// AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE are populated automatically +// on AKS clusters with the azure-workload-identity mutating webhook enabled. +const ( + EnvAzureClientID = "AZURE_CLIENT_ID" + EnvAzureTenantID = "AZURE_TENANT_ID" + EnvAzureFederatedTokenFile = "AZURE_FEDERATED_TOKEN_FILE" + + // EnvAzureSubscriptionID identifies the subscription that owns the AFD + // profiles this controller manages. Not part of Workload Identity itself; + // supplied via chart values in production. + EnvAzureSubscriptionID = "AZURE_SUBSCRIPTION_ID" +) + +// Config holds the resolved identity + target parameters for the AFD clients. +type Config struct { + TenantID string + ClientID string + FederatedTokenFile string + SubscriptionID string + // Cloud selects the Azure cloud (AzurePublic, AzureGovernment, AzureChina). + // Zero value = AzurePublic. + Cloud azcloud.Configuration +} + +// LoadConfigFromEnv reads Config values from the standard Workload Identity +// environment variables plus AZURE_SUBSCRIPTION_ID. It returns an error listing +// every missing variable so misconfiguration surfaces in a single log line. +func LoadConfigFromEnv() (*Config, error) { + c := &Config{ + TenantID: os.Getenv(EnvAzureTenantID), + ClientID: os.Getenv(EnvAzureClientID), + FederatedTokenFile: os.Getenv(EnvAzureFederatedTokenFile), + SubscriptionID: os.Getenv(EnvAzureSubscriptionID), + } + if err := c.Validate(); err != nil { + return nil, err + } + return c, nil +} + +// Validate returns an aggregated error identifying every missing required field. +func (c *Config) Validate() error { + var missing []string + if c.TenantID == "" { + missing = append(missing, EnvAzureTenantID) + } + if c.ClientID == "" { + missing = append(missing, EnvAzureClientID) + } + if c.FederatedTokenFile == "" { + missing = append(missing, EnvAzureFederatedTokenFile) + } + if c.SubscriptionID == "" { + missing = append(missing, EnvAzureSubscriptionID) + } + if len(missing) > 0 { + return fmt.Errorf("azurefrontdoor: missing required environment variables: %v", missing) + } + return nil +} + +// NewCredential constructs a token credential using Azure AD Workload Identity. +func NewCredential(c *Config) (azcore.TokenCredential, error) { + if c == nil { + return nil, errors.New("azurefrontdoor: Config is nil") + } + if err := c.Validate(); err != nil { + return nil, err + } + cred, err := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ + ClientID: c.ClientID, + TenantID: c.TenantID, + TokenFilePath: c.FederatedTokenFile, + ClientOptions: azcore.ClientOptions{Cloud: c.Cloud}, + }) + if err != nil { + return nil, fmt.Errorf("azurefrontdoor: create workload identity credential: %w", err) + } + return cred, nil +} + +// Clients bundles the AFD sub-clients used by the Phase 2 POC controllers. +// Kept small on purpose; additional clients (routes, origins, secrets) can be +// added as later phases need them. +// +// The bundle intentionally mixes two SDK modules: +// - armcdn/v2: Microsoft.Cdn/profiles/* (the AFD profile itself, endpoints, +// custom domains, security-policy attaches). +// - armfrontdoor: Microsoft.Network/frontdoorwebapplicationfirewallpolicies +// (the WAF policy resource referenced by +// FrontDoorProfile.spec.wafPolicy.resourceID). Kept as a distinct client +// because it lives under a different ARM resource provider and the +// armcdn SDK deliberately does not expose it (WAF policies pre-date the +// AFD Standard/Premium API surface). +type Clients struct { + Profiles *armcdn.ProfilesClient + AFDEndpoints *armcdn.AFDEndpointsClient + CustomDomains *armcdn.AFDCustomDomainsClient + SecurityPolicies *armcdn.SecurityPoliciesClient + // OriginGroups + Origins are the FrontDoorBackend reconciler's write + // surface: it programs one OriginGroup per FrontDoorBackend and one + // Origin per qualifying InternalServiceExport (see + // docs/first-party/002-afd-implementation-plan.md §6). Both live under + // Microsoft.Cdn/profiles//originGroups[//origins/] + // so they share the armcdn factory with the profile itself. + OriginGroups *armcdn.AFDOriginGroupsClient + Origins *armcdn.AFDOriginsClient + // WAFPolicies reads and writes classic AFD WAF policies. Read is used + // unconditionally by the profile reconciler to resolve + // spec.wafPolicy.resourceID; write is currently unused by the + // controllers (all first-party services reference a centrally-managed + // policy — see the FrontDoorWAFPolicyRef type doc) but is exposed here + // so a future inline-WAF creation path can use it without another + // change to this bundle. + WAFPolicies *armfrontdoor.PoliciesClient +} + +// NewClients builds the AFD sub-clients using the given credential and target +// subscription. armOpts may be nil; callers wanting retry/telemetry tuning +// should pass a shared *arm.ClientOptions. +// +// Both underlying SDKs (armcdn/v2, armfrontdoor) share the same credential +// and arm.ClientOptions so retry/telemetry policy is applied uniformly across +// AFD and WAF calls. +func NewClients(cred azcore.TokenCredential, subscriptionID string, armOpts *arm.ClientOptions) (*Clients, error) { + if cred == nil { + return nil, errors.New("azurefrontdoor: credential is nil") + } + if subscriptionID == "" { + return nil, errors.New("azurefrontdoor: subscriptionID is empty") + } + cdnFactory, err := armcdn.NewClientFactory(subscriptionID, cred, armOpts) + if err != nil { + return nil, fmt.Errorf("azurefrontdoor: create armcdn client factory: %w", err) + } + fdFactory, err := armfrontdoor.NewClientFactory(subscriptionID, cred, armOpts) + if err != nil { + return nil, fmt.Errorf("azurefrontdoor: create armfrontdoor client factory: %w", err) + } + return &Clients{ + Profiles: cdnFactory.NewProfilesClient(), + AFDEndpoints: cdnFactory.NewAFDEndpointsClient(), + CustomDomains: cdnFactory.NewAFDCustomDomainsClient(), + SecurityPolicies: cdnFactory.NewSecurityPoliciesClient(), + OriginGroups: cdnFactory.NewAFDOriginGroupsClient(), + Origins: cdnFactory.NewAFDOriginsClient(), + WAFPolicies: fdFactory.NewPoliciesClient(), + }, nil +} + +// DefaultARMClientOptions returns arm.ClientOptions suitable for controller +// use. Kept as a seam for retry/logging tuning that later phases are expected +// to add without touching call sites. +func DefaultARMClientOptions() *arm.ClientOptions { + return &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Retry: policy.RetryOptions{ + MaxRetries: 3, + }, + }, + } +} diff --git a/pkg/common/azurefrontdoor/client_test.go b/pkg/common/azurefrontdoor/client_test.go new file mode 100644 index 00000000..38191730 --- /dev/null +++ b/pkg/common/azurefrontdoor/client_test.go @@ -0,0 +1,86 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package azurefrontdoor + +import ( + "strings" + "testing" +) + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + wantMissing []string + }{ + { + name: "all fields set", + config: Config{ + TenantID: "tenant", + ClientID: "client", + FederatedTokenFile: "/var/run/secrets/tokens/azure", + SubscriptionID: "sub", + }, + wantErr: false, + }, + { + name: "all fields empty", + config: Config{}, + wantErr: true, + wantMissing: []string{EnvAzureTenantID, EnvAzureClientID, EnvAzureFederatedTokenFile, EnvAzureSubscriptionID}, + }, + { + name: "missing subscription", + config: Config{ + TenantID: "tenant", + ClientID: "client", + FederatedTokenFile: "/tok", + }, + wantErr: true, + wantMissing: []string{EnvAzureSubscriptionID}, + }, + { + name: "missing token file", + config: Config{ + TenantID: "tenant", + ClientID: "client", + SubscriptionID: "sub", + }, + wantErr: true, + wantMissing: []string{EnvAzureFederatedTokenFile}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.Validate() + if (err != nil) != tc.wantErr { + t.Fatalf("Validate() error = %v, wantErr = %v", err, tc.wantErr) + } + if err == nil { + return + } + for _, m := range tc.wantMissing { + if !strings.Contains(err.Error(), m) { + t.Errorf("Validate() error %q missing expected variable %q", err.Error(), m) + } + } + }) + } +} + +func TestNewCredentialNilConfig(t *testing.T) { + if _, err := NewCredential(nil); err == nil { + t.Fatal("expected error for nil config, got nil") + } +} + +func TestNewClientsValidation(t *testing.T) { + if _, err := NewClients(nil, "sub", nil); err == nil { + t.Error("expected error for nil credential, got nil") + } +} diff --git a/pkg/common/objectmeta/objectmeta.go b/pkg/common/objectmeta/objectmeta.go index 6f053eb7..697d360c 100644 --- a/pkg/common/objectmeta/objectmeta.go +++ b/pkg/common/objectmeta/objectmeta.go @@ -35,6 +35,23 @@ const ( // to make sure that the controller can react to backend deletions if necessary. TrafficManagerBackendFinalizer = fleetNetworkingPrefix + "traffic-manager-backend-cleanup" + // FrontDoorProfileFinalizer is a finalizer added by the FrontDoorProfile controller to + // FrontDoorProfile resources so the controller can delete the underlying Azure Front Door + // profile before the Kubernetes object is removed. + FrontDoorProfileFinalizer = fleetNetworkingPrefix + "frontdoor-profile-cleanup" + + // FrontDoorBackendFinalizer is a finalizer added by the FrontDoorBackend + // controller to every FrontDoorBackend CR so the reconciler can + // guarantee the corresponding Azure Front Door OriginGroup (and its + // child Origins, which cascade with the OriginGroup) is deleted before + // the CR is removed from etcd. Naming mirrors FrontDoorProfileFinalizer. + FrontDoorBackendFinalizer = fleetNetworkingPrefix + "frontdoor-backend-cleanup" + + // FrontDoorCustomDomainFinalizer is a finalizer added by the FrontDoorCustomDomain + // controller to FrontDoorCustomDomain resources so the controller can unbind and delete the + // underlying Azure Front Door custom domain before the Kubernetes object is removed. + FrontDoorCustomDomainFinalizer = fleetNetworkingPrefix + "frontdoor-custom-domain-cleanup" + // MetricsFinalizer is the finalizer added by the controller to clean up all metrics. MetricsFinalizer = fleetNetworkingPrefix + "metrics-cleanup" ) @@ -59,6 +76,33 @@ const ( // ServiceExportAnnotationWeight is an annotation that marks the weight of the ServiceExport. ServiceExportAnnotationWeight = fleetNetworkingPrefix + "weight" + // ServiceExportAnnotationExportMode is an annotation on a ServiceExport that + // selects which fleet-networking control plane consumes the export: + // - ExportModeValueTrafficManager (default when the annotation is absent): + // the export flows to the Traffic Manager path (today's behaviour). + // - ExportModeValueFrontDoor: the export flows to the Front Door path + // (Phase 4 work; requires the underlying Service to be an internal + // load balancer with the Azure PLS annotations set). + // + // Modeled as an annotation rather than a Spec field to avoid diverging + // from the upstream mcs-api (KEP-1645) ServiceExport shape — see + // docs/first-party/002-afd-implementation-plan.md #3.4 and the breadcrumb + // 2026-07-20-1108-afd-export-mode-mcs-parity.md for the parity rationale. + // The annotation is deliberately opt-in: absence keeps every existing + // manifest working unchanged. + ServiceExportAnnotationExportMode = fleetNetworkingPrefix + "export-mode" + + // ExportModeValueTrafficManager routes the export through the ATM + // (L4 / DNS-based) control plane. Default when the annotation is absent. + ExportModeValueTrafficManager = "L4-TrafficManager" + + // ExportModeValueFrontDoor routes the export through the AFD + // (L7 / anycast) control plane. Requires the Service to be an internal + // load balancer with Azure PLS provisioning enabled — the reconciler + // verifies this and surfaces ExportModeAnnotationServiceMismatch when + // the Service shape is incompatible. + ExportModeValueFrontDoor = "L7-FrontDoor" + // ServiceAnnotationAzureLoadBalancerInternal is an annotation that marks the Service as an internal load balancer by cloud-provider-azure. ServiceAnnotationAzureLoadBalancerInternal = "service.beta.kubernetes.io/azure-load-balancer-internal" @@ -71,6 +115,30 @@ const ( // before v1.15.10/v1.16.7/v1.17.3, the DNS label on PIP would also be deleted if the annotation is not specified. // https://cloud-provider-azure.sigs.k8s.io/topics/loadbalancer/ ServiceAnnotationAzureDNSLabelName = "service.beta.kubernetes.io/azure-dns-label-name" + + // ServiceAnnotationAzurePLSCreate opts an internal LoadBalancer Service into + // Private Link Service provisioning by cloud-provider-azure. The AFD + // L7 export mode (ExportModeValueFrontDoor) requires the referenced + // Service to have this annotation set to "true" so that the member + // serviceexport reconciler can look up the resulting PLS by name. + // Docs: https://cloud-provider-azure.sigs.k8s.io/topics/pls-integration/ + ServiceAnnotationAzurePLSCreate = "service.beta.kubernetes.io/azure-pls-create" + + // ServiceAnnotationAzurePLSName is the explicit PLS resource name the + // operator asked cloud-provider-azure to create for this Service. We + // require this annotation (instead of deriving a default from + // cloud-provider-azure internals) so the reconciler's ARM Get lookup + // is unambiguous and stable across upstream default-name changes. + // Missing this annotation on an L7-FrontDoor export surfaces + // ExportModeAnnotationServiceMismatch. + ServiceAnnotationAzurePLSName = "service.beta.kubernetes.io/azure-pls-name" + + // ServiceAnnotationAzurePLSResourceGroup optionally overrides the + // resource group that hosts the PLS. When absent, the reconciler + // falls back to the same resource-group resolution used for PIPs + // (ServiceAnnotationLoadBalancerResourceGroup, then the controller's + // default ResourceGroupName), keeping ATM and AFD paths symmetric. + ServiceAnnotationAzurePLSResourceGroup = "service.beta.kubernetes.io/azure-pls-resource-group" ) // Azure Resource Tags @@ -103,3 +171,32 @@ func ExtractWeightFromServiceExport(svcExport *fleetnetv1beta1.ServiceExport) (i } return int64(weight), nil } + +// ExtractExportModeFromServiceExport returns the effective export mode for a +// ServiceExport. Absence of the annotation returns the default +// (ExportModeValueTrafficManager) with no error, preserving today's +// behaviour for every existing manifest. Any value other than the two +// documented enum members is a hard rejection — silent fallback would let a +// typo (e.g. "L4-Trafficmanager") mask the intent, so we require the caller +// to surface the error as a status condition. +// +// Returned value is always non-empty on nil error; callers may compare +// directly against ExportModeValueTrafficManager / ExportModeValueFrontDoor. +func ExtractExportModeFromServiceExport(svcExport *fleetnetv1beta1.ServiceExport) (string, error) { + raw, found := svcExport.Annotations[ServiceExportAnnotationExportMode] + if !found { + return ExportModeValueTrafficManager, nil + } + switch raw { + case ExportModeValueTrafficManager, ExportModeValueFrontDoor: + return raw, nil + default: + // Empty string is deliberately treated as invalid rather than as + // "default" so operators immediately notice a mis-templated + // annotation (e.g. a Helm value that resolved to ""). + err := fmt.Errorf("the export-mode annotation %q is not one of %q, %q", + raw, ExportModeValueTrafficManager, ExportModeValueFrontDoor) + klog.ErrorS(err, "Invalid export-mode annotation", "serviceExport", klog.KObj(svcExport)) + return "", err + } +} diff --git a/pkg/common/objectmeta/objectmeta_test.go b/pkg/common/objectmeta/objectmeta_test.go index 10fdbff2..0d086510 100644 --- a/pkg/common/objectmeta/objectmeta_test.go +++ b/pkg/common/objectmeta/objectmeta_test.go @@ -114,3 +114,108 @@ func TestExtractWeightFromServiceExport(t *testing.T) { }) } } + +func TestExtractExportModeFromServiceExport(t *testing.T) { + testCases := []struct { + name string + svcExport *fleetnetv1beta1.ServiceExport + wantMode string + wantError bool + }{ + { + // Missing annotation is the common case for existing manifests; + // it must resolve to the default so nothing breaks silently. + name: "annotation absent -> default L4-TrafficManager", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{Name: "no-anno"}, + }, + wantMode: ExportModeValueTrafficManager, + }, + { + name: "explicit L4-TrafficManager -> accepted", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "explicit-tm", + Annotations: map[string]string{ + ServiceExportAnnotationExportMode: ExportModeValueTrafficManager, + }, + }, + }, + wantMode: ExportModeValueTrafficManager, + }, + { + name: "explicit L7-FrontDoor -> accepted", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "explicit-afd", + Annotations: map[string]string{ + ServiceExportAnnotationExportMode: ExportModeValueFrontDoor, + }, + }, + }, + wantMode: ExportModeValueFrontDoor, + }, + { + // Empty string is treated as invalid rather than defaulted so a + // mis-templated Helm value that resolves to "" surfaces loudly + // instead of silently reverting to TrafficManager. + name: "empty string -> invalid", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty", + Annotations: map[string]string{ + ServiceExportAnnotationExportMode: "", + }, + }, + }, + wantError: true, + }, + { + // Case-sensitive by design: enum values are case-sensitive in + // the CRD spec, and case-folding here would let ambiguous values + // pass through to Azure APIs that themselves are case-sensitive. + name: "wrong case -> invalid", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "wrong-case", + Annotations: map[string]string{ + ServiceExportAnnotationExportMode: "l7-frontdoor", + }, + }, + }, + wantError: true, + }, + { + name: "typo -> invalid", + svcExport: &fleetnetv1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "typo", + Annotations: map[string]string{ + ServiceExportAnnotationExportMode: "L4-Trafficmanager", + }, + }, + }, + wantError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotMode, err := ExtractExportModeFromServiceExport(tc.svcExport) + if (err != nil) != tc.wantError { + t.Fatalf("ExtractExportModeFromServiceExport() error = %v, want error? %v", err, tc.wantError) + } + if tc.wantError { + // On error, mode is documented to be "" so callers cannot + // accidentally use an unvalidated value. + if gotMode != "" { + t.Errorf("ExtractExportModeFromServiceExport() on error returned mode = %q, want empty", gotMode) + } + return + } + if gotMode != tc.wantMode { + t.Errorf("ExtractExportModeFromServiceExport() mode = %q, want %q", gotMode, tc.wantMode) + } + }) + } +}