diff --git a/internal/shim/placement/handle_allocation_candidates_e2e.go b/internal/shim/placement/handle_allocation_candidates_e2e.go index e90193224..c2dd4aacd 100644 --- a/internal/shim/placement/handle_allocation_candidates_e2e.go +++ b/internal/shim/placement/handle_allocation_candidates_e2e.go @@ -47,6 +47,15 @@ func e2eTestAllocationCandidates(ctx context.Context, _ client.Client) error { const testRC = "CUSTOM_CORTEX_E2E_CAND_RC" const apiVersion = "placement 1.26" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/allocation_candidates?resources=VCPU:1") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete leftover test resources from a prior run. log.Info("Pre-cleanup: deleting leftover test resources") for _, cleanup := range []struct { @@ -296,5 +305,5 @@ func e2eTestAllocationCandidates(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "allocation_candidates", run: e2eTestAllocationCandidates}) + e2eTests = append(e2eTests, e2eTest{name: "allocation_candidates", run: e2eWrapWithModes(e2eTestAllocationCandidates)}) } diff --git a/internal/shim/placement/handle_allocations_e2e.go b/internal/shim/placement/handle_allocations_e2e.go index 7f09a507b..27887ca7f 100644 --- a/internal/shim/placement/handle_allocations_e2e.go +++ b/internal/shim/placement/handle_allocations_e2e.go @@ -56,6 +56,15 @@ func e2eTestAllocations(ctx context.Context, _ client.Client) error { const userID = "e2e50000-0000-0000-0000-000000000001" const apiVersion = "placement 1.28" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/allocations/"+consumerUUID1) + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete allocations, resource provider, and resource class. log.Info("Pre-cleanup: deleting leftover test resources") for _, cleanup := range []struct { @@ -476,5 +485,5 @@ func e2eTestAllocations(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "allocations", run: e2eTestAllocations}) + e2eTests = append(e2eTests, e2eTest{name: "allocations", run: e2eWrapWithModes(e2eTestAllocations)}) } diff --git a/internal/shim/placement/handle_reshaper_e2e.go b/internal/shim/placement/handle_reshaper_e2e.go index f43809de4..29f84be34 100644 --- a/internal/shim/placement/handle_reshaper_e2e.go +++ b/internal/shim/placement/handle_reshaper_e2e.go @@ -57,6 +57,15 @@ func e2eTestReshaper(ctx context.Context, _ client.Client) error { const userID = "e2e50000-0000-0000-0000-000000000001" const apiVersion = "placement 1.30" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/allocations/"+consumerUUID) + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete allocation, both RPs, and custom resource class. log.Info("Pre-cleanup: deleting leftover test resources") for _, cleanup := range []struct { @@ -571,5 +580,5 @@ func e2eTestReshaper(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "reshaper", run: e2eTestReshaper}) + e2eTests = append(e2eTests, e2eTest{name: "reshaper", run: e2eWrapWithModes(e2eTestReshaper)}) } diff --git a/internal/shim/placement/handle_resource_classes_e2e.go b/internal/shim/placement/handle_resource_classes_e2e.go index 360e1ef80..e848ee034 100644 --- a/internal/shim/placement/handle_resource_classes_e2e.go +++ b/internal/shim/placement/handle_resource_classes_e2e.go @@ -42,6 +42,15 @@ func e2eTestResourceClasses(ctx context.Context, _ client.Client) error { const testRC = "CUSTOM_CORTEX_E2E_RC" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/resource_classes") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete any leftover test resource class from a prior run. log.Info("Pre-cleanup: deleting leftover test resource class", "class", testRC) req, err := http.NewRequestWithContext(ctx, @@ -226,5 +235,5 @@ func e2eTestResourceClasses(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_classes", run: e2eTestResourceClasses}) + e2eTests = append(e2eTests, e2eTest{name: "resource_classes", run: e2eWrapWithModes(e2eTestResourceClasses)}) } diff --git a/internal/shim/placement/handle_resource_provider_aggregates_e2e.go b/internal/shim/placement/handle_resource_provider_aggregates_e2e.go index b673c75f6..7eb6ba089 100644 --- a/internal/shim/placement/handle_resource_provider_aggregates_e2e.go +++ b/internal/shim/placement/handle_resource_provider_aggregates_e2e.go @@ -48,6 +48,15 @@ func e2eTestResourceProviderAggregates(ctx context.Context, _ client.Client) err const testAggUUID1 = "e2e30000-0000-0000-0000-000000000001" const testAggUUID2 = "e2e30000-0000-0000-0000-000000000002" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/resource_providers/"+testRPUUID+"/aggregates") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete any leftover test resource provider from a prior run. log.Info("Pre-cleanup: deleting leftover test resource provider", "uuid", testRPUUID) req, err := http.NewRequestWithContext(ctx, @@ -346,5 +355,5 @@ func e2eTestResourceProviderAggregates(ctx context.Context, _ client.Client) err } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_provider_aggregates", run: e2eTestResourceProviderAggregates}) + e2eTests = append(e2eTests, e2eTest{name: "resource_provider_aggregates", run: e2eWrapWithModes(e2eTestResourceProviderAggregates)}) } diff --git a/internal/shim/placement/handle_resource_provider_allocations_e2e.go b/internal/shim/placement/handle_resource_provider_allocations_e2e.go index a63c8cb4f..aea84ec06 100644 --- a/internal/shim/placement/handle_resource_provider_allocations_e2e.go +++ b/internal/shim/placement/handle_resource_provider_allocations_e2e.go @@ -44,6 +44,15 @@ func e2eTestResourceProviderAllocations(ctx context.Context, _ client.Client) er const testRPUUID = "e2e10000-0000-0000-0000-000000000006" const testRPName = "cortex-e2e-test-rp-alloc-view" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/resource_providers/"+testRPUUID+"/allocations") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete any leftover test resource provider from a prior run. log.Info("Pre-cleanup: deleting leftover test resource provider", "uuid", testRPUUID) req, err := http.NewRequestWithContext(ctx, @@ -227,5 +236,5 @@ func e2eTestResourceProviderAllocations(ctx context.Context, _ client.Client) er } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_provider_allocations", run: e2eTestResourceProviderAllocations}) + e2eTests = append(e2eTests, e2eTest{name: "resource_provider_allocations", run: e2eWrapWithModes(e2eTestResourceProviderAllocations)}) } diff --git a/internal/shim/placement/handle_resource_provider_inventories_e2e.go b/internal/shim/placement/handle_resource_provider_inventories_e2e.go index 354460e81..1462ca87b 100644 --- a/internal/shim/placement/handle_resource_provider_inventories_e2e.go +++ b/internal/shim/placement/handle_resource_provider_inventories_e2e.go @@ -53,6 +53,15 @@ func e2eTestResourceProviderInventories(ctx context.Context, _ client.Client) er const testRC = "CUSTOM_CORTEX_E2E_INV_RC" const apiVersion = "placement 1.26" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/resource_providers/"+testRPUUID+"/inventories") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete the resource provider (cascades inventories), then // the custom resource class. Ignore 404/409. log.Info("Pre-cleanup: deleting leftover test resources") @@ -488,5 +497,5 @@ func e2eTestResourceProviderInventories(ctx context.Context, _ client.Client) er } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_provider_inventories", run: e2eTestResourceProviderInventories}) + e2eTests = append(e2eTests, e2eTest{name: "resource_provider_inventories", run: e2eWrapWithModes(e2eTestResourceProviderInventories)}) } diff --git a/internal/shim/placement/handle_resource_provider_traits.go b/internal/shim/placement/handle_resource_provider_traits.go index 16978a593..b23ac8e59 100644 --- a/internal/shim/placement/handle_resource_provider_traits.go +++ b/internal/shim/placement/handle_resource_provider_traits.go @@ -4,7 +4,6 @@ package placement import ( - "fmt" "net/http" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" @@ -33,7 +32,7 @@ func (s *Shim) HandleListResourceProviderTraits(w http.ResponseWriter, r *http.R if !ok { return } - switch s.config.Features.ResourceProviderTraits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviderTraits) { case FeatureModePassthrough: s.forward(w, r) case FeatureModeHybrid: @@ -92,13 +91,13 @@ func (s *Shim) HandleUpdateResourceProviderTraits(w http.ResponseWriter, r *http if _, ok := requiredUUIDPathParam(w, r, "uuid"); !ok { return } - switch s.config.Features.ResourceProviderTraits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviderTraits) { case FeatureModePassthrough: s.forward(w, r) case FeatureModeHybrid: s.forward(w, r) case FeatureModeCRD: - http.Error(w, fmt.Sprintf("%s mode is not yet implemented for resource provider trait writes", s.config.Features.ResourceProviderTraits), http.StatusNotImplemented) + http.Error(w, "crd mode is not yet implemented for resource provider trait writes", http.StatusNotImplemented) default: http.Error(w, "unknown feature mode", http.StatusInternalServerError) } @@ -117,13 +116,13 @@ func (s *Shim) HandleDeleteResourceProviderTraits(w http.ResponseWriter, r *http if _, ok := requiredUUIDPathParam(w, r, "uuid"); !ok { return } - switch s.config.Features.ResourceProviderTraits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviderTraits) { case FeatureModePassthrough: s.forward(w, r) case FeatureModeHybrid: s.forward(w, r) case FeatureModeCRD: - http.Error(w, fmt.Sprintf("%s mode is not yet implemented for resource provider trait writes", s.config.Features.ResourceProviderTraits), http.StatusNotImplemented) + http.Error(w, "crd mode is not yet implemented for resource provider trait writes", http.StatusNotImplemented) default: http.Error(w, "unknown feature mode", http.StatusInternalServerError) } diff --git a/internal/shim/placement/handle_resource_provider_traits_e2e.go b/internal/shim/placement/handle_resource_provider_traits_e2e.go index c697ca7ff..4acd665b0 100644 --- a/internal/shim/placement/handle_resource_provider_traits_e2e.go +++ b/internal/shim/placement/handle_resource_provider_traits_e2e.go @@ -42,6 +42,15 @@ func e2eTestResourceProviderTraits(ctx context.Context, _ client.Client) error { } log.Info("Successfully created openstack client for resource provider traits e2e test") + // Resource provider trait writes (PUT/DELETE) are not yet implemented in + // crd mode, and the test RP created via POST won't exist as a Hypervisor + // CRD either, so skip the entire test in crd mode. + rpTraitsMode := e2eCurrentMode(ctx) + if rpTraitsMode == FeatureModeCRD { + log.Info("Skipping resource provider traits e2e test because mode is crd (writes not implemented)") + return nil + } + const testRPUUID = "e2e10000-0000-0000-0000-000000000003" const testRPName = "cortex-e2e-test-rp-traits" const testTrait = "CUSTOM_CORTEX_E2E_RP_TRAIT" @@ -382,5 +391,5 @@ func e2eTestResourceProviderTraits(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_provider_traits", run: e2eTestResourceProviderTraits}) + e2eTests = append(e2eTests, e2eTest{name: "resource_provider_traits", run: e2eWrapWithModes(e2eTestResourceProviderTraits)}) } diff --git a/internal/shim/placement/handle_resource_provider_usages_e2e.go b/internal/shim/placement/handle_resource_provider_usages_e2e.go index c548162ba..05965b23c 100644 --- a/internal/shim/placement/handle_resource_provider_usages_e2e.go +++ b/internal/shim/placement/handle_resource_provider_usages_e2e.go @@ -44,6 +44,15 @@ func e2eTestResourceProviderUsages(ctx context.Context, _ client.Client) error { const testRPUUID = "e2e10000-0000-0000-0000-000000000005" const testRPName = "cortex-e2e-test-rp-usages" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/resource_providers/"+testRPUUID+"/usages") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Pre-cleanup: delete any leftover test resource provider from a prior run. log.Info("Pre-cleanup: deleting leftover test resource provider", "uuid", testRPUUID) req, err := http.NewRequestWithContext(ctx, @@ -227,5 +236,5 @@ func e2eTestResourceProviderUsages(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_provider_usages", run: e2eTestResourceProviderUsages}) + e2eTests = append(e2eTests, e2eTest{name: "resource_provider_usages", run: e2eWrapWithModes(e2eTestResourceProviderUsages)}) } diff --git a/internal/shim/placement/handle_resource_providers.go b/internal/shim/placement/handle_resource_providers.go index 7d4dd1fff..04955fd72 100644 --- a/internal/shim/placement/handle_resource_providers.go +++ b/internal/shim/placement/handle_resource_providers.go @@ -115,7 +115,8 @@ func (s *Shim) HandleCreateResourceProvider(w http.ResponseWriter, r *http.Reque ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.ResourceProviders.orDefault() { + mode := s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviders) + switch mode { case FeatureModePassthrough: s.forward(w, r) return @@ -184,7 +185,7 @@ func (s *Shim) HandleCreateResourceProvider(w http.ResponseWriter, r *http.Reque } // No conflict — forward to upstream placement (hybrid) or reject (crd). - if s.config.Features.ResourceProviders.orDefault() == FeatureModeCRD { + if mode == FeatureModeCRD { log.Info("crd mode: non-kvm resource provider create not supported", "name", req.Name) http.Error(w, "resource provider not found", http.StatusNotFound) return @@ -209,7 +210,8 @@ func (s *Shim) HandleShowResourceProvider(w http.ResponseWriter, r *http.Request ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.ResourceProviders.orDefault() { + mode := s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviders) + switch mode { case FeatureModePassthrough: s.forward(w, r) return @@ -229,7 +231,7 @@ func (s *Shim) HandleShowResourceProvider(w http.ResponseWriter, r *http.Request var hvs hv1.HypervisorList err := s.List(ctx, &hvs, client.MatchingFields{idxHypervisorOpenStackId: uuid}) if apierrors.IsNotFound(err) || len(hvs.Items) == 0 { - if s.config.Features.ResourceProviders.orDefault() == FeatureModeCRD { + if mode == FeatureModeCRD { log.Info("resource provider not found in kubernetes (crd mode)", "uuid", uuid) http.Error(w, "resource provider not found", http.StatusNotFound) return @@ -278,7 +280,8 @@ func (s *Shim) HandleUpdateResourceProvider(w http.ResponseWriter, r *http.Reque ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.ResourceProviders.orDefault() { + mode := s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviders) + switch mode { case FeatureModePassthrough: s.forward(w, r) return @@ -315,7 +318,7 @@ func (s *Shim) HandleUpdateResourceProvider(w http.ResponseWriter, r *http.Reque var hvs hv1.HypervisorList err = s.List(ctx, &hvs, client.MatchingFields{idxHypervisorOpenStackId: uuid}) if apierrors.IsNotFound(err) || len(hvs.Items) == 0 { - if s.config.Features.ResourceProviders.orDefault() == FeatureModeCRD { + if mode == FeatureModeCRD { log.Info("resource provider not found in kubernetes (crd mode)", "uuid", uuid) http.Error(w, "resource provider not found", http.StatusNotFound) return @@ -373,7 +376,8 @@ func (s *Shim) HandleDeleteResourceProvider(w http.ResponseWriter, r *http.Reque ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.ResourceProviders.orDefault() { + mode := s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviders) + switch mode { case FeatureModePassthrough: s.forward(w, r) return @@ -393,7 +397,7 @@ func (s *Shim) HandleDeleteResourceProvider(w http.ResponseWriter, r *http.Reque var hvs hv1.HypervisorList err := s.List(ctx, &hvs, client.MatchingFields{idxHypervisorOpenStackId: uuid}) if apierrors.IsNotFound(err) || len(hvs.Items) == 0 { - if s.config.Features.ResourceProviders.orDefault() == FeatureModeCRD { + if mode == FeatureModeCRD { log.Info("resource provider not found in kubernetes (crd mode)", "uuid", uuid) http.Error(w, "resource provider not found", http.StatusNotFound) return @@ -448,7 +452,7 @@ type listResourceProvidersResponse struct { // // See: https://docs.openstack.org/api-ref/placement/#list-resource-providers func (s *Shim) HandleListResourceProviders(w http.ResponseWriter, r *http.Request) { - switch s.config.Features.ResourceProviders.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.ResourceProviders) { case FeatureModePassthrough: s.forward(w, r) case FeatureModeHybrid: diff --git a/internal/shim/placement/handle_resource_providers_e2e.go b/internal/shim/placement/handle_resource_providers_e2e.go index 90b850369..2faca2fc2 100644 --- a/internal/shim/placement/handle_resource_providers_e2e.go +++ b/internal/shim/placement/handle_resource_providers_e2e.go @@ -58,14 +58,24 @@ func e2eTestResourceProviders(ctx context.Context, cl client.Client) error { // ==================== Phase 1: VMware path ==================== - log.Info("=== VMware path: passthrough resource provider tests ===") - if err := e2eVMwareResourceProviders(ctx, sc); err != nil { - return fmt.Errorf("VMware path: %w", err) + // The VMware path creates synthetic test RPs against upstream placement. + // In crd mode there is no upstream, so skip it. + mode := e2eCurrentMode(ctx) + if mode == "" { + mode = config.Features.ResourceProviders.orDefault() + } + if mode != FeatureModeCRD { + log.Info("=== VMware path: passthrough resource provider tests ===") + if err := e2eVMwareResourceProviders(ctx, sc); err != nil { + return fmt.Errorf("VMware path: %w", err) + } + } else { + log.Info("Skipping VMware path because mode is crd (no upstream placement)") } // ==================== Phase 2: KVM path ==================== - if config.Features.ResourceProviders.orDefault() == FeatureModePassthrough { + if mode == FeatureModePassthrough { log.Info("Skipping KVM resource provider e2e tests because resourceProviders mode is passthrough") } else { log.Info("=== KVM path: hypervisor-backed resource provider tests ===") @@ -506,5 +516,5 @@ func e2eKVMResourceProviders(ctx context.Context, sc *gophercloud.ServiceClient, } func init() { - e2eTests = append(e2eTests, e2eTest{name: "resource_providers", run: e2eTestResourceProviders}) + e2eTests = append(e2eTests, e2eTest{name: "resource_providers", run: e2eWrapWithModes(e2eTestResourceProviders)}) } diff --git a/internal/shim/placement/handle_root.go b/internal/shim/placement/handle_root.go index acad69dcb..9f9b510e7 100644 --- a/internal/shim/placement/handle_root.go +++ b/internal/shim/placement/handle_root.go @@ -50,7 +50,7 @@ func (s *Shim) HandleGetRoot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.Root.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.Root) { case FeatureModePassthrough: log.Info("forwarding GET / to upstream placement") s.forward(w, r) diff --git a/internal/shim/placement/handle_root_e2e.go b/internal/shim/placement/handle_root_e2e.go index 2e558705a..e4a785606 100644 --- a/internal/shim/placement/handle_root_e2e.go +++ b/internal/shim/placement/handle_root_e2e.go @@ -51,5 +51,5 @@ func e2eTestGetRoot(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "root", run: e2eTestGetRoot}) + e2eTests = append(e2eTests, e2eTest{name: "root", run: e2eWrapWithModes(e2eTestGetRoot)}) } diff --git a/internal/shim/placement/handle_traits.go b/internal/shim/placement/handle_traits.go index b509b2599..429b87cfc 100644 --- a/internal/shim/placement/handle_traits.go +++ b/internal/shim/placement/handle_traits.go @@ -62,7 +62,7 @@ func (s *Shim) HandleListTraits(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.Traits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.Traits) { case FeatureModePassthrough, FeatureModeHybrid: s.forward(w, r) return @@ -132,7 +132,7 @@ func (s *Shim) HandleShowTrait(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.Traits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.Traits) { case FeatureModePassthrough, FeatureModeHybrid: s.forward(w, r) return @@ -174,7 +174,7 @@ func (s *Shim) HandleUpdateTrait(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.Traits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.Traits) { case FeatureModePassthrough, FeatureModeHybrid: s.forward(w, r) return @@ -298,7 +298,7 @@ func (s *Shim) HandleDeleteTrait(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logf.FromContext(ctx) - switch s.config.Features.Traits.orDefault() { + switch s.featureModeFromConfOrHeader(r, s.config.Features.Traits) { case FeatureModePassthrough, FeatureModeHybrid: s.forward(w, r) return diff --git a/internal/shim/placement/handle_traits_e2e.go b/internal/shim/placement/handle_traits_e2e.go index 8a904b935..4a5831f72 100644 --- a/internal/shim/placement/handle_traits_e2e.go +++ b/internal/shim/placement/handle_traits_e2e.go @@ -83,7 +83,11 @@ func e2eTestTraits(ctx context.Context, _ client.Client) error { // When traits are served locally (hybrid or crd mode) the static list may // be empty. Only require at least one trait when forwarding to upstream // placement, which always has standard traits. - if config.Features.Traits.orDefault() == FeatureModePassthrough && len(listResp.Traits) == 0 { + traitsMode := e2eCurrentMode(ctx) + if traitsMode == "" { + traitsMode = config.Features.Traits.orDefault() + } + if traitsMode == FeatureModePassthrough && len(listResp.Traits) == 0 { return errors.New("GET /traits: expected at least one trait, got 0") } log.Info("Successfully retrieved traits", "count", len(listResp.Traits)) @@ -133,8 +137,13 @@ func e2eTestTraits(ctx context.Context, _ client.Client) error { // ==================== Phase 2: CRUD tests (feature-gated) ==================== - if config.Features.Traits.orDefault() == FeatureModePassthrough { - log.Info("Skipping trait CRUD e2e tests because traits mode is passthrough") + // CRUD tests require traits ConfigMaps which are only created when the + // configured traits mode is hybrid or crd. The override header changes + // handler routing but cannot create ConfigMaps that don't exist. + configuredTraitsMode := config.Features.Traits.orDefault() + if traitsMode == FeatureModePassthrough || configuredTraitsMode == FeatureModePassthrough { + log.Info("Skipping trait CRUD e2e tests", + "overrideMode", traitsMode, "configuredMode", configuredTraitsMode) return nil } @@ -287,47 +296,53 @@ func e2eTestTraits(ctx context.Context, _ client.Client) error { } log.Info("Verified test trait was deleted", "trait", testTrait) - // Test PUT /traits/{name} with bad prefix → 400. - log.Info("Testing PUT /traits/{name} with non-CUSTOM_ prefix") - req, err = http.NewRequestWithContext(ctx, - http.MethodPut, sc.Endpoint+"/traits/HW_CORTEX_E2E_BAD", http.NoBody) - if err != nil { - return fmt.Errorf("failed to create bad-prefix PUT request: %w", err) - } - req.Header.Set("X-Auth-Token", sc.TokenID) - req.Header.Set("OpenStack-API-Version", "placement 1.6") - resp, err = sc.HTTPClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send bad-prefix PUT request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - return fmt.Errorf("PUT /traits/HW_CORTEX_E2E_BAD: expected 400, got %d", resp.StatusCode) - } - log.Info("Correctly received 400 for PUT with non-CUSTOM_ prefix") + // Bad-prefix validation is only enforced by the shim in crd mode. + // In hybrid mode, writes forward to upstream which has different behavior. + if traitsMode == FeatureModeCRD { + // Test PUT /traits/{name} with bad prefix → 400. + log.Info("Testing PUT /traits/{name} with non-CUSTOM_ prefix") + req, err = http.NewRequestWithContext(ctx, + http.MethodPut, sc.Endpoint+"/traits/HW_CORTEX_E2E_BAD", http.NoBody) + if err != nil { + return fmt.Errorf("failed to create bad-prefix PUT request: %w", err) + } + req.Header.Set("X-Auth-Token", sc.TokenID) + req.Header.Set("OpenStack-API-Version", "placement 1.6") + resp, err = sc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send bad-prefix PUT request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + return fmt.Errorf("PUT /traits/HW_CORTEX_E2E_BAD: expected 400, got %d", resp.StatusCode) + } + log.Info("Correctly received 400 for PUT with non-CUSTOM_ prefix") - // Test DELETE /traits/{name} with bad prefix → 400. - log.Info("Testing DELETE /traits/{name} with non-CUSTOM_ prefix") - req, err = http.NewRequestWithContext(ctx, - http.MethodDelete, sc.Endpoint+"/traits/HW_CORTEX_E2E_BAD", http.NoBody) - if err != nil { - return fmt.Errorf("failed to create bad-prefix DELETE request: %w", err) - } - req.Header.Set("X-Auth-Token", sc.TokenID) - req.Header.Set("OpenStack-API-Version", "placement 1.6") - resp, err = sc.HTTPClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send bad-prefix DELETE request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - return fmt.Errorf("DELETE /traits/HW_CORTEX_E2E_BAD: expected 400, got %d", resp.StatusCode) + // Test DELETE /traits/{name} with bad prefix → 400. + log.Info("Testing DELETE /traits/{name} with non-CUSTOM_ prefix") + req, err = http.NewRequestWithContext(ctx, + http.MethodDelete, sc.Endpoint+"/traits/HW_CORTEX_E2E_BAD", http.NoBody) + if err != nil { + return fmt.Errorf("failed to create bad-prefix DELETE request: %w", err) + } + req.Header.Set("X-Auth-Token", sc.TokenID) + req.Header.Set("OpenStack-API-Version", "placement 1.6") + resp, err = sc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send bad-prefix DELETE request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + return fmt.Errorf("DELETE /traits/HW_CORTEX_E2E_BAD: expected 400, got %d", resp.StatusCode) + } + log.Info("Correctly received 400 for DELETE with non-CUSTOM_ prefix") + } else { + log.Info("Skipping bad-prefix validation tests (only enforced in crd mode)") } - log.Info("Correctly received 400 for DELETE with non-CUSTOM_ prefix") return nil } func init() { - e2eTests = append(e2eTests, e2eTest{name: "traits", run: e2eTestTraits}) + e2eTests = append(e2eTests, e2eTest{name: "traits", run: e2eWrapWithModes(e2eTestTraits)}) } diff --git a/internal/shim/placement/handle_usages_e2e.go b/internal/shim/placement/handle_usages_e2e.go index c7ac8c965..66f5c40a9 100644 --- a/internal/shim/placement/handle_usages_e2e.go +++ b/internal/shim/placement/handle_usages_e2e.go @@ -39,6 +39,15 @@ func e2eTestUsages(ctx context.Context, _ client.Client) error { const apiVersion = "placement 1.9" + // Probe: for non-passthrough modes, verify endpoint returns 501. + unimplemented, err := e2eProbeUnimplemented(ctx, sc, sc.Endpoint+"/usages?project_id=test") + if err != nil { + return fmt.Errorf("probe: %w", err) + } + if unimplemented { + return nil + } + // Get the list of projects from the identity service, so that we can test // the /usages endpoint with a valid project id. log.Info("Getting list of projects from identity service for usages e2e test") @@ -113,5 +122,5 @@ func e2eTestUsages(ctx context.Context, _ client.Client) error { } func init() { - e2eTests = append(e2eTests, e2eTest{name: "usages", run: e2eTestUsages}) + e2eTests = append(e2eTests, e2eTest{name: "usages", run: e2eWrapWithModes(e2eTestUsages)}) } diff --git a/internal/shim/placement/shim.go b/internal/shim/placement/shim.go index 273273983..b46546b63 100644 --- a/internal/shim/placement/shim.go +++ b/internal/shim/placement/shim.go @@ -52,6 +52,18 @@ type requestIDContextKey struct{} // header value through the request lifecycle for tracing. var requestIDKey = requestIDContextKey{} +// featureModeOverrideContextKey is a separate type for the per-request feature +// mode override injected via the X-Cortex-Feature-Mode header. +type featureModeOverrideContextKey struct{} + +// featureModeOverrideKey is the context key used to propagate the feature mode +// override from the middleware to handlers. +var featureModeOverrideKey = featureModeOverrideContextKey{} + +// headerFeatureModeOverride is the HTTP header that allows e2e tests to +// override the configured feature mode on a per-request basis. +const headerFeatureModeOverride = "X-Cortex-Feature-Mode" + // FeatureMode controls how an endpoint group interacts with upstream // placement and the hypervisor CRD. type FeatureMode string @@ -90,16 +102,38 @@ func (m FeatureMode) valid() bool { // dispatchPassthroughOnly forwards in passthrough mode, returns 501 for // hybrid/crd, and 500 for unknown modes. func (s *Shim) dispatchPassthroughOnly(w http.ResponseWriter, r *http.Request, mode FeatureMode) { - switch mode.orDefault() { + resolved := s.featureModeFromConfOrHeader(r, mode) + switch resolved { case FeatureModePassthrough: s.forward(w, r) case FeatureModeHybrid, FeatureModeCRD: - http.Error(w, fmt.Sprintf("%s mode is not yet implemented for this endpoint", mode), http.StatusNotImplemented) + http.Error(w, fmt.Sprintf("%s mode is not yet implemented for this endpoint", resolved), http.StatusNotImplemented) default: http.Error(w, "unknown feature mode", http.StatusInternalServerError) } } +// featureModeFromConfOrHeader returns the effective feature mode for the +// current request. If a valid override is present in the request context +// (injected by wrapHandler from the X-Cortex-Feature-Mode header), the +// override takes precedence — unless it would escalate from passthrough into +// a mode that requires backing config (Versioning, Traits) that was not +// validated at startup. In that case the override is ignored and the +// configured default is returned. +func (s *Shim) featureModeFromConfOrHeader(r *http.Request, configured FeatureMode) FeatureMode { + override, ok := r.Context().Value(featureModeOverrideKey).(FeatureMode) + if !ok { + return configured.orDefault() + } + resolved := override.orDefault() + if resolved == FeatureModeHybrid || resolved == FeatureModeCRD { + if s.config.Versioning == nil && s.config.Traits == nil { + return configured.orDefault() + } + } + return resolved +} + // featuresConfig controls the feature mode for each endpoint group. // Every field defaults to passthrough (zero value) when omitted. type featuresConfig struct { @@ -472,13 +506,10 @@ func (s *Shim) SetupWithManager(ctx context.Context, mgr ctrl.Manager) (err erro Buckets: prometheus.DefBuckets, }, []string{"method", "pattern", "responsecode"}) - traitsMode := s.config.Features.Traits.orDefault() - if traitsMode == FeatureModeHybrid || traitsMode == FeatureModeCRD { - s.resourceLocker = resourcelock.NewResourceLocker( - s.Client, - os.Getenv("POD_NAMESPACE"), - ) - } + s.resourceLocker = resourcelock.NewResourceLocker( + s.Client, + os.Getenv("POD_NAMESPACE"), + ) // Check that the provided client is a multicluster client, since we need // that to watch for hypervisors across clusters. diff --git a/internal/shim/placement/shim_e2e.go b/internal/shim/placement/shim_e2e.go index e7f9e30f3..d839751a5 100644 --- a/internal/shim/placement/shim_e2e.go +++ b/internal/shim/placement/shim_e2e.go @@ -66,7 +66,7 @@ func makeE2EServiceClient(ctx context.Context, rc e2eRootConfig) (*gophercloud.S log.Info("No SSO config provided, using plain transport for placement API") transport = &http.Transport{} } - provider.HTTPClient.Transport = transport + provider.HTTPClient.Transport = &e2eModeTransport{base: transport} if err := openstack.Authenticate(ctx, provider, authOpts); err != nil { log.Error(err, "Failed to authenticate with keystone") return nil, fmt.Errorf("failed to authenticate with keystone: %w", err) @@ -88,6 +88,105 @@ type e2eTest struct { // e2eTests is populated by init() functions in the handle_*_e2e.go files. var e2eTests []e2eTest +// e2eAllModes is the list of feature modes exercised by e2e tests when +// AllowModeOverride is enabled. +var e2eAllModes = []FeatureMode{ + FeatureModePassthrough, + FeatureModeHybrid, + FeatureModeCRD, +} + +// setFeatureModeHeader sets the X-Cortex-Feature-Mode override header on the +// request so the shim dispatches to the specified mode regardless of its +// configured mode. +func setFeatureModeHeader(req *http.Request, mode FeatureMode) { + if mode != "" { + req.Header.Set(headerFeatureModeOverride, string(mode)) + } +} + +// e2eModeContextKey is used to pass the current test mode through context. +type e2eModeContextKey struct{} + +// e2eCurrentMode retrieves the feature mode from context (set by +// e2eWrapWithModes). Returns empty string if not set. +func e2eCurrentMode(ctx context.Context) FeatureMode { + if m, ok := ctx.Value(e2eModeContextKey{}).(FeatureMode); ok { + return m + } + return "" +} + +// e2eWrapWithModes returns a test function that iterates over all feature +// modes. For each mode it injects the mode into context (retrievable via +// e2eCurrentMode) so that the e2eModeTransport sets the override header on +// every outgoing request. +func e2eWrapWithModes(fn func(ctx context.Context, cl client.Client) error) func(ctx context.Context, cl client.Client) error { + return func(ctx context.Context, cl client.Client) error { + log := logf.FromContext(ctx) + for _, mode := range e2eAllModes { + modeLog := log.WithName(string(mode)) + modeCtx := context.WithValue(ctx, e2eModeContextKey{}, mode) + modeCtx = logf.IntoContext(modeCtx, modeLog) + modeLog.Info("Starting mode") + if err := fn(modeCtx, cl); err != nil { + return fmt.Errorf("mode %s: %w", mode, err) + } + modeLog.Info("Mode passed") + } + return nil + } +} + +// e2eProbeUnimplemented sends a single GET request with the mode override +// header to verify the endpoint returns 501 Not Implemented. Returns true if +// the endpoint is unimplemented for this mode (test should skip). Returns +// false if the endpoint returned a success status (test should continue). +// Returns an error for unexpected status codes (4xx/5xx other than 501). +func e2eProbeUnimplemented(ctx context.Context, sc *gophercloud.ServiceClient, probeURL string) (bool, error) { + log := logf.FromContext(ctx) + mode := e2eCurrentMode(ctx) + if mode == "" || mode == FeatureModePassthrough { + return false, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, http.NoBody) + if err != nil { + return false, err + } + req.Header.Set("X-Auth-Token", sc.TokenID) + req.Header.Set("OpenStack-API-Version", "placement 1.6") + setFeatureModeHeader(req, mode) + resp, err := sc.HTTPClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotImplemented { + log.Info("Endpoint correctly returns 501 for unimplemented mode", "mode", mode) + return true, nil + } + if resp.StatusCode >= http.StatusBadRequest { + return false, fmt.Errorf("probe %s in mode %s returned unexpected status %d", probeURL, mode, resp.StatusCode) + } + return false, nil +} + +// e2eModeTransport wraps an http.RoundTripper to automatically inject the +// X-Cortex-Feature-Mode header based on the mode stored in the request's +// context (via e2eModeContextKey). This avoids manually calling +// setFeatureModeHeader on every request in every e2e test. +type e2eModeTransport struct { + base http.RoundTripper +} + +func (t *e2eModeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if mode := e2eCurrentMode(req.Context()); mode != "" { + req = req.Clone(req.Context()) + req.Header.Set(headerFeatureModeOverride, string(mode)) + } + return t.base.RoundTrip(req) +} + // RunE2E executes end-to-end tests for all placement shim handlers. // It stops on the first failure and returns the error. func RunE2E(ctx context.Context, cl client.Client) error { diff --git a/internal/shim/placement/shim_io.go b/internal/shim/placement/shim_io.go index 98d5ba0bc..792f8edc3 100644 --- a/internal/shim/placement/shim_io.go +++ b/internal/shim/placement/shim_io.go @@ -113,6 +113,14 @@ func (s *Shim) wrapHandler(pattern string, next http.HandlerFunc) http.HandlerFu log = log.WithValues("requestID", reqID) ctx = context.WithValue(ctx, requestIDKey, reqID) } + + // Read the feature mode override header and store in context. + if raw := r.Header.Get(headerFeatureModeOverride); raw != "" { + if fm := FeatureMode(raw); fm.valid() && fm != "" { + ctx = context.WithValue(ctx, featureModeOverrideKey, fm) + } + } + ctx = logf.IntoContext(ctx, log) r = r.WithContext(ctx) diff --git a/internal/shim/placement/shim_test.go b/internal/shim/placement/shim_test.go index 503b94c72..ffc31e954 100644 --- a/internal/shim/placement/shim_test.go +++ b/internal/shim/placement/shim_test.go @@ -561,3 +561,137 @@ func TestWrapHandlerWithAuth(t *testing.T) { } }) } + +func TestFeatureModeFromConfOrHeader(t *testing.T) { + s := &Shim{config: config{ + Traits: &traitsConfig{ConfigMapName: "test"}, + }} + + t.Run("returns configured mode when no override", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + got := s.featureModeFromConfOrHeader(req, FeatureModeHybrid) + if got != FeatureModeHybrid { + t.Fatalf("got %q, want %q", got, FeatureModeHybrid) + } + }) + + t.Run("defaults empty configured mode to passthrough", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + got := s.featureModeFromConfOrHeader(req, "") + if got != FeatureModePassthrough { + t.Fatalf("got %q, want %q", got, FeatureModePassthrough) + } + }) + + t.Run("returns override when present in context and backing config exists", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + ctx := context.WithValue(req.Context(), featureModeOverrideKey, FeatureModeCRD) + req = req.WithContext(ctx) + got := s.featureModeFromConfOrHeader(req, FeatureModePassthrough) + if got != FeatureModeCRD { + t.Fatalf("got %q, want %q", got, FeatureModeCRD) + } + }) + + t.Run("override to hybrid/crd ignored when no backing config", func(t *testing.T) { + bare := &Shim{} + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + ctx := context.WithValue(req.Context(), featureModeOverrideKey, FeatureModeCRD) + req = req.WithContext(ctx) + got := bare.featureModeFromConfOrHeader(req, FeatureModePassthrough) + if got != FeatureModePassthrough { + t.Fatalf("got %q, want %q (override should be rejected without backing config)", got, FeatureModePassthrough) + } + }) + + t.Run("override to passthrough always allowed", func(t *testing.T) { + bare := &Shim{} + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + ctx := context.WithValue(req.Context(), featureModeOverrideKey, FeatureModePassthrough) + req = req.WithContext(ctx) + got := bare.featureModeFromConfOrHeader(req, FeatureModeHybrid) + if got != FeatureModePassthrough { + t.Fatalf("got %q, want %q", got, FeatureModePassthrough) + } + }) + + t.Run("override defaults empty to passthrough", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + ctx := context.WithValue(req.Context(), featureModeOverrideKey, FeatureMode("")) + req = req.WithContext(ctx) + got := s.featureModeFromConfOrHeader(req, FeatureModeHybrid) + if got != FeatureModePassthrough { + t.Fatalf("got %q, want %q", got, FeatureModePassthrough) + } + }) +} + +func TestWrapHandlerFeatureModeOverride(t *testing.T) { + t.Run("valid header injects override into context", func(t *testing.T) { + var gotMode FeatureMode + down, up := newTestTimers() + s := &Shim{ + config: config{PlacementURL: "http://unused"}, + maxBodyLogSize: 4096, + downstreamRequestTimer: down, + upstreamRequestTimer: up, + } + wrapped := s.wrapHandler("/test", func(w http.ResponseWriter, r *http.Request) { + if override, ok := r.Context().Value(featureModeOverrideKey).(FeatureMode); ok { + gotMode = override + } + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req.Header.Set(headerFeatureModeOverride, string(FeatureModeCRD)) + w := httptest.NewRecorder() + wrapped(w, req) + if gotMode != FeatureModeCRD { + t.Fatalf("context override = %q, want %q", gotMode, FeatureModeCRD) + } + }) + + t.Run("invalid header value is ignored", func(t *testing.T) { + var gotOverride bool + down, up := newTestTimers() + s := &Shim{ + config: config{PlacementURL: "http://unused"}, + maxBodyLogSize: 4096, + downstreamRequestTimer: down, + upstreamRequestTimer: up, + } + wrapped := s.wrapHandler("/test", func(w http.ResponseWriter, r *http.Request) { + _, gotOverride = r.Context().Value(featureModeOverrideKey).(FeatureMode) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req.Header.Set(headerFeatureModeOverride, "bogus") + w := httptest.NewRecorder() + wrapped(w, req) + if gotOverride { + t.Fatal("override should not be set for invalid mode value") + } + }) + + t.Run("empty header value is ignored", func(t *testing.T) { + var gotOverride bool + down, up := newTestTimers() + s := &Shim{ + config: config{PlacementURL: "http://unused"}, + maxBodyLogSize: 4096, + downstreamRequestTimer: down, + upstreamRequestTimer: up, + } + wrapped := s.wrapHandler("/test", func(w http.ResponseWriter, r *http.Request) { + _, gotOverride = r.Context().Value(featureModeOverrideKey).(FeatureMode) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req.Header.Set(headerFeatureModeOverride, "") + w := httptest.NewRecorder() + wrapped(w, req) + if gotOverride { + t.Fatal("override should not be set for empty header") + } + }) +}