diff --git a/cmd/shim/main.go b/cmd/shim/main.go index a088040b0..b7e48efa8 100644 --- a/cmd/shim/main.go +++ b/cmd/shim/main.go @@ -13,8 +13,10 @@ import ( "path/filepath" "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/shim/placement" "github.com/cobaltcore-dev/cortex/pkg/conf" "github.com/cobaltcore-dev/cortex/pkg/monitoring" + "github.com/cobaltcore-dev/cortex/pkg/multicluster" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/sapcc/go-bits/httpext" "k8s.io/apimachinery/pkg/runtime" @@ -22,6 +24,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -51,6 +54,7 @@ func main() { restConfig := ctrl.GetConfigOrDie() var metricsAddr string + var apiBindAddr string var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string // The shim does not require leader election, but this flag is provided to @@ -59,9 +63,11 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var enablePlacementShim bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&apiBindAddr, "api-bind-address", ":8080", "The address the shim API server binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ @@ -77,6 +83,8 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.BoolVar(&enablePlacementShim, "placement-shim", false, + "If set, the placement API shim handlers are registered on the API server.") opts := zap.Options{ Development: true, } @@ -90,6 +98,13 @@ func main() { os.Exit(1) } + // Check that the metrics and API bind addresses don't overlap. + if metricsAddr != "0" && metricsAddr == apiBindAddr { + err := errors.New("metrics-bind-address and api-bind-address must not be the same") + setupLog.Error(err, "invalid configuration", "metrics-bind-address", metricsAddr, "api-bind-address", apiBindAddr) + os.Exit(1) + } + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) // if the enable-http2 flag is false (the default), http/2 should be disabled @@ -195,7 +210,26 @@ func main() { os.Exit(1) } - // TODO: Initialize multicluster client here. + homeCluster, err := cluster.New(restConfig, func(o *cluster.Options) { o.Scheme = scheme }) + if err != nil { + setupLog.Error(err, "unable to create home cluster") + os.Exit(1) + } + if err := mgr.Add(homeCluster); err != nil { + setupLog.Error(err, "unable to add home cluster") + os.Exit(1) + } + multiclusterClient := &multicluster.Client{ + HomeCluster: homeCluster, + HomeRestConfig: restConfig, + HomeScheme: scheme, + ResourceRouters: multicluster.DefaultResourceRouters, + } + multiclusterClientConfig := conf.GetConfigOrDie[multicluster.ClientConfig]() + if err := multiclusterClient.InitFromConf(ctx, mgr, multiclusterClientConfig); err != nil { + setupLog.Error(err, "unable to initialize multicluster client") + os.Exit(1) + } // Our custom monitoring registry can add prometheus labels to all metrics. // This is useful to distinguish metrics from different deployments. @@ -204,6 +238,16 @@ func main() { // API endpoint. mux := http.NewServeMux() + var placementShim *placement.Shim + if enablePlacementShim { + placementShim = &placement.Shim{Client: multiclusterClient} + setupLog.Info("Adding placement shim to manager") + if err := placementShim.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to set up placement shim") + os.Exit(1) + } + placementShim.RegisterRoutes(mux) + } // +kubebuilder:scaffold:builder diff --git a/helm/bundles/cortex-placement-shim/values.yaml b/helm/bundles/cortex-placement-shim/values.yaml index 6dd793653..2facf6848 100644 --- a/helm/bundles/cortex-placement-shim/values.yaml +++ b/helm/bundles/cortex-placement-shim/values.yaml @@ -20,8 +20,29 @@ alerts: cortex-shim: namePrefix: cortex-placement + deployment: + container: + extraArgs: ["--placement-shim=true"] conf: + apiservers: + home: + gvks: + - kvm.cloud.sap/v1/Hypervisor + - kvm.cloud.sap/v1/HypervisorList monitoring: labels: github_org: cobaltcore-dev github_repo: cortex + # Uncomment and set the following values to enable SSO for the placement + # shim. The shim will use the provided SSO credentials to talk to openstack + # over ingress. + # sso: + # cert: | + # -----BEGIN CERTIFICATE----- + # Your certificate here + # -----END CERTIFICATE----- + # certKey: | + # -----BEGIN PRIVATE KEY----- + # Your private key here + # -----END PRIVATE KEY----- + # selfSigned: "false" diff --git a/helm/library/cortex-shim/templates/deployment.yaml b/helm/library/cortex-shim/templates/deployment.yaml index b38eb3c02..7d658e87c 100644 --- a/helm/library/cortex-shim/templates/deployment.yaml +++ b/helm/library/cortex-shim/templates/deployment.yaml @@ -1,6 +1,3 @@ -# This file is safe from kubebuilder edit --plugins=helm/v1-alpha -# If you want to re-generate, add the --force flag. - {{- if .Values.deployment.enable }} apiVersion: apps/v1 kind: Deployment @@ -32,6 +29,9 @@ spec: {{- range .Values.deployment.container.args }} - {{ . }} {{- end }} + {{- range .Values.deployment.container.extraArgs }} + - {{ . }} + {{- end }} ports: - name: api containerPort: 8080 diff --git a/helm/library/cortex-shim/values.yaml b/helm/library/cortex-shim/values.yaml index 63574fbe4..91eaba11f 100644 --- a/helm/library/cortex-shim/values.yaml +++ b/helm/library/cortex-shim/values.yaml @@ -5,9 +5,11 @@ deployment: image: repository: ghcr.io/cobaltcore-dev/cortex-shim args: + - "--api-bind-address=:8080" - "--metrics-bind-address=:2112" - "--health-probe-bind-address=:8081" - "--metrics-secure=false" + extraArgs: [] resources: limits: cpu: 500m @@ -53,9 +55,6 @@ rbac: prometheus: enable: true -global: - conf: {} - # Use this to unambiguate multiple cortex deployments in the same cluster. namePrefix: cortex conf: {} # No config for now that's needed by all the shims. diff --git a/internal/shim/placement/handle_allocation_candidates.go b/internal/shim/placement/handle_allocation_candidates.go new file mode 100644 index 000000000..f80b9aa0f --- /dev/null +++ b/internal/shim/placement/handle_allocation_candidates.go @@ -0,0 +1,42 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListAllocationCandidates handles GET /allocation_candidates requests. +// +// Returns a collection of allocation requests and resource provider summaries +// that can satisfy a given set of resource and trait requirements. This is the +// primary endpoint used by Nova's scheduler to find suitable hosts for +// instance placement. +// +// The resources query parameter specifies required capacity as a comma- +// separated list (e.g. VCPU:4,MEMORY_MB:2048,DISK_GB:64). The required +// parameter filters by traits, supporting forbidden traits via ! prefix +// (since 1.22) and the in: syntax for any-of semantics (since 1.39). +// The member_of parameter filters by aggregate membership with support for +// forbidden aggregates via ! prefix (since 1.32). +// +// Since microversion 1.25, granular request groups are supported via numbered +// suffixes (resourcesN, requiredN, member_ofN) to express requirements that +// may be satisfied by different providers. The group_policy parameter (1.26+) +// controls whether groups must each be satisfied by a single provider or may +// span multiple. The in_tree parameter (1.31+) constrains results to a +// specific provider tree. +// +// Each returned allocation request is directly usable as the body for +// PUT /allocations/{consumer_uuid}. The provider_summaries section includes +// inventory capacity and usage for informed decision-making. Available since +// microversion 1.10. +func (s *Shim) HandleListAllocationCandidates(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_allocation_candidates_test.go b/internal/shim/placement/handle_allocation_candidates_test.go new file mode 100644 index 000000000..de75a96af --- /dev/null +++ b/internal/shim/placement/handle_allocation_candidates_test.go @@ -0,0 +1,21 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListAllocationCandidates(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"allocation_requests":[]}`, &gotPath) + w := serveHandler(t, "GET", "/allocation_candidates", s.HandleListAllocationCandidates, "/allocation_candidates") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/allocation_candidates" { + t.Fatalf("upstream path = %q, want /allocation_candidates", gotPath) + } +} diff --git a/internal/shim/placement/handle_allocations.go b/internal/shim/placement/handle_allocations.go new file mode 100644 index 000000000..ee365d109 --- /dev/null +++ b/internal/shim/placement/handle_allocations.go @@ -0,0 +1,95 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleManageAllocations handles POST /allocations requests. +// +// Atomically creates, updates, or deletes allocations for multiple consumers +// in a single request. This is the primary mechanism for operations that must +// modify allocations across several consumers atomically, such as live +// migrations and move operations where resources are transferred from one +// consumer to another. Available since microversion 1.13. +// +// The request body is keyed by consumer UUID, each containing an allocations +// dictionary (keyed by resource provider UUID), along with project_id and +// user_id. Since microversion 1.28, consumer_generation enables consumer- +// level concurrency control. Since microversion 1.38, a consumer_type field +// (e.g. INSTANCE, MIGRATION) is supported. Returns 204 No Content on +// success, or 409 Conflict if inventory is insufficient or a concurrent +// update is detected (error code: placement.concurrent_update). +func (s *Shim) HandleManageAllocations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleListAllocations handles GET /allocations/{consumer_uuid} requests. +// +// Returns all allocation records for the consumer identified by +// {consumer_uuid}, across all resource providers. The response contains an +// allocations dictionary keyed by resource provider UUID. If the consumer has +// no allocations, an empty dictionary is returned. +// +// The response has grown across microversions: project_id and user_id were +// added at 1.12, consumer_generation at 1.28, and consumer_type at 1.38. +// The consumer_generation and consumer_type fields are absent when the +// consumer has no allocations. +func (s *Shim) HandleListAllocations(w http.ResponseWriter, r *http.Request) { + consumerUUID, ok := requiredUUIDPathParam(w, r, "consumer_uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "consumer_uuid", consumerUUID) + s.forward(w, r) +} + +// HandleUpdateAllocations handles PUT /allocations/{consumer_uuid} requests. +// +// Creates or replaces all allocation records for a single consumer. If +// allocations already exist for this consumer, they are entirely replaced +// by the new set. The request format changed at microversion 1.12 from an +// array-based layout to an object keyed by resource provider UUID. +// Microversion 1.28 added consumer_generation for concurrency control, +// and 1.38 introduced consumer_type. +// +// Returns 204 No Content on success. Returns 409 Conflict if there is +// insufficient inventory or if a concurrent update was detected. +func (s *Shim) HandleUpdateAllocations(w http.ResponseWriter, r *http.Request) { + consumerUUID, ok := requiredUUIDPathParam(w, r, "consumer_uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "consumer_uuid", consumerUUID) + s.forward(w, r) +} + +// HandleDeleteAllocations handles DELETE /allocations/{consumer_uuid} requests. +// +// Removes all allocation records for the consumer across all resource +// providers. Returns 204 No Content on success, or 404 Not Found if the +// consumer has no existing allocations. +func (s *Shim) HandleDeleteAllocations(w http.ResponseWriter, r *http.Request) { + consumerUUID, ok := requiredUUIDPathParam(w, r, "consumer_uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "consumer_uuid", consumerUUID) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_allocations_test.go b/internal/shim/placement/handle_allocations_test.go new file mode 100644 index 000000000..c42cf86e0 --- /dev/null +++ b/internal/shim/placement/handle_allocations_test.go @@ -0,0 +1,78 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleManageAllocations(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusNoContent, "", &gotPath) + w := serveHandler(t, "POST", "/allocations", s.HandleManageAllocations, "/allocations") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + if gotPath != "/allocations" { + t.Fatalf("upstream path = %q, want /allocations", gotPath) + } +} + +func TestHandleListAllocations(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/allocations/{consumer_uuid}", + s.HandleListAllocations, "/allocations/"+validUUID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/allocations/{consumer_uuid}", + s.HandleListAllocations, "/allocations/bad") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateAllocations(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "PUT", "/allocations/{consumer_uuid}", + s.HandleUpdateAllocations, "/allocations/"+validUUID) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/allocations/{consumer_uuid}", + s.HandleUpdateAllocations, "/allocations/bad") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleDeleteAllocations(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/allocations/{consumer_uuid}", + s.HandleDeleteAllocations, "/allocations/"+validUUID) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "DELETE", "/allocations/{consumer_uuid}", + s.HandleDeleteAllocations, "/allocations/bad") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_reshaper.go b/internal/shim/placement/handle_reshaper.go new file mode 100644 index 000000000..f08af7f9a --- /dev/null +++ b/internal/shim/placement/handle_reshaper.go @@ -0,0 +1,32 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandlePostReshaper handles POST /reshaper requests. +// +// Atomically migrates resource provider inventories and associated allocations +// in a single transaction. This endpoint is used when a provider tree needs to +// be restructured — for example, moving inventory from a root provider into +// newly created child providers — without leaving allocations in an +// inconsistent state during the transition. +// +// The request body contains the complete set of inventories (keyed by +// resource provider UUID) and allocations (keyed by consumer UUID) that +// should exist after the operation. The Placement service validates all +// inputs atomically and applies them in a single database transaction. +// Returns 204 No Content on success. Returns 409 Conflict if any referenced +// resource provider does not exist or if inventory/allocation constraints +// would be violated. Available since microversion 1.30. +func (s *Shim) HandlePostReshaper(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_reshaper_test.go b/internal/shim/placement/handle_reshaper_test.go new file mode 100644 index 000000000..e00eff2e2 --- /dev/null +++ b/internal/shim/placement/handle_reshaper_test.go @@ -0,0 +1,21 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandlePostReshaper(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusNoContent, "", &gotPath) + w := serveHandler(t, "POST", "/reshaper", s.HandlePostReshaper, "/reshaper") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + if gotPath != "/reshaper" { + t.Fatalf("upstream path = %q, want /reshaper", gotPath) + } +} diff --git a/internal/shim/placement/handle_resource_classes.go b/internal/shim/placement/handle_resource_classes.go new file mode 100644 index 000000000..407071e26 --- /dev/null +++ b/internal/shim/placement/handle_resource_classes.go @@ -0,0 +1,91 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceClasses handles GET /resource_classes requests. +// +// Returns the complete list of all resource classes, including both standard +// classes (e.g. VCPU, MEMORY_MB, DISK_GB, PCI_DEVICE, SRIOV_NET_VF) and +// deployer-defined custom classes prefixed with CUSTOM_. Resource classes +// categorize the types of resources that resource providers can offer as +// inventory. Available since microversion 1.2. +func (s *Shim) HandleListResourceClasses(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleCreateResourceClass handles POST /resource_classes requests. +// +// Creates a new custom resource class. The name must be prefixed with CUSTOM_ +// to distinguish it from standard resource classes. Returns 201 Created with +// a Location header on success. Returns 400 Bad Request if the CUSTOM_ prefix +// is missing, and 409 Conflict if a class with the same name already exists. +// Available since microversion 1.2. +func (s *Shim) HandleCreateResourceClass(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleShowResourceClass handles GET /resource_classes/{name} requests. +// +// Returns a representation of a single resource class identified by name. +// This can be used to verify the existence of a resource class. Returns 404 +// if the class does not exist. Available since microversion 1.2. +func (s *Shim) HandleShowResourceClass(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} + +// HandleUpdateResourceClass handles PUT /resource_classes/{name} requests. +// +// Behavior differs by microversion. Since microversion 1.7, this endpoint +// creates or validates the existence of a single resource class: it returns +// 201 Created for a new class or 204 No Content if the class already exists. +// The name must carry the CUSTOM_ prefix. In earlier versions (1.2-1.6), the +// endpoint allowed renaming a class via a request body, but this usage is +// discouraged. Returns 400 Bad Request if the CUSTOM_ prefix is missing. +func (s *Shim) HandleUpdateResourceClass(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} + +// HandleDeleteResourceClass handles DELETE /resource_classes/{name} requests. +// +// Deletes a custom resource class. Only custom classes (prefixed with CUSTOM_) +// may be deleted; attempting to delete a standard class returns 400 Bad +// Request. Returns 409 Conflict if any resource provider has inventory of this +// class, and 404 if the class does not exist. Returns 204 No Content on +// success. Available since microversion 1.2. +func (s *Shim) HandleDeleteResourceClass(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_classes_test.go b/internal/shim/placement/handle_resource_classes_test.go new file mode 100644 index 000000000..80ffdf40e --- /dev/null +++ b/internal/shim/placement/handle_resource_classes_test.go @@ -0,0 +1,57 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceClasses(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"resource_classes":[]}`, &gotPath) + w := serveHandler(t, "GET", "/resource_classes", s.HandleListResourceClasses, "/resource_classes") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/resource_classes" { + t.Fatalf("upstream path = %q, want /resource_classes", gotPath) + } +} + +func TestHandleCreateResourceClass(t *testing.T) { + s := newTestShim(t, http.StatusCreated, "{}", nil) + w := serveHandler(t, "POST", "/resource_classes", s.HandleCreateResourceClass, "/resource_classes") + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", w.Code, http.StatusCreated) + } +} + +func TestHandleShowResourceClass(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, "{}", &gotPath) + w := serveHandler(t, "GET", "/resource_classes/{name}", s.HandleShowResourceClass, "/resource_classes/VCPU") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/resource_classes/VCPU" { + t.Fatalf("upstream path = %q, want /resource_classes/VCPU", gotPath) + } +} + +func TestHandleUpdateResourceClass(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "PUT", "/resource_classes/{name}", s.HandleUpdateResourceClass, "/resource_classes/CUSTOM_FOO") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } +} + +func TestHandleDeleteResourceClass(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/resource_classes/{name}", s.HandleDeleteResourceClass, "/resource_classes/CUSTOM_BAR") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } +} diff --git a/internal/shim/placement/handle_resource_provider_aggregates.go b/internal/shim/placement/handle_resource_provider_aggregates.go new file mode 100644 index 000000000..c270f6730 --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_aggregates.go @@ -0,0 +1,55 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviderAggregates handles +// GET /resource_providers/{uuid}/aggregates requests. +// +// Returns the list of aggregate UUIDs associated with the resource provider. +// Aggregates model relationships among providers such as shared storage, +// affinity/anti-affinity groups, and availability zones. Returns an empty +// list if the provider has no aggregate associations. Available since +// microversion 1.1. +// +// The response format changed at microversion 1.19: earlier versions return +// only a flat array of UUIDs, while 1.19+ returns an object that also +// includes the resource_provider_generation for concurrency tracking. Returns +// 404 if the provider does not exist. +func (s *Shim) HandleListResourceProviderAggregates(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleUpdateResourceProviderAggregates handles +// PUT /resource_providers/{uuid}/aggregates requests. +// +// Replaces the complete set of aggregate associations for a resource provider. +// Any aggregate UUIDs that do not yet exist are created automatically. The +// request format changed at microversion 1.19: earlier versions accept a +// plain array of UUIDs, while 1.19+ expects an object containing an +// aggregates array and a resource_provider_generation for optimistic +// concurrency control. Returns 409 Conflict if the generation does not match +// (1.19+). Returns 200 with the updated aggregate list on success. +func (s *Shim) HandleUpdateResourceProviderAggregates(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_provider_aggregates_test.go b/internal/shim/placement/handle_resource_provider_aggregates_test.go new file mode 100644 index 000000000..f55b09fed --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_aggregates_test.go @@ -0,0 +1,51 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviderAggregates(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/aggregates", + s.HandleListResourceProviderAggregates, + "/resource_providers/"+validUUID+"/aggregates") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/aggregates", + s.HandleListResourceProviderAggregates, + "/resource_providers/not-a-uuid/aggregates") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateResourceProviderAggregates(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/aggregates", + s.HandleUpdateResourceProviderAggregates, + "/resource_providers/"+validUUID+"/aggregates") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/aggregates", + s.HandleUpdateResourceProviderAggregates, + "/resource_providers/not-a-uuid/aggregates") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_resource_provider_allocations.go b/internal/shim/placement/handle_resource_provider_allocations.go new file mode 100644 index 000000000..e36bbebd9 --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_allocations.go @@ -0,0 +1,29 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviderAllocations handles +// GET /resource_providers/{uuid}/allocations requests. +// +// Returns all allocations made against the resource provider identified by +// {uuid}, keyed by consumer UUID. This provides a provider-centric view of +// consumption, complementing the consumer-centric GET /allocations/{consumer} +// endpoint. The response includes the resource_provider_generation. Returns +// 404 if the provider does not exist. +func (s *Shim) HandleListResourceProviderAllocations(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_provider_allocations_test.go b/internal/shim/placement/handle_resource_provider_allocations_test.go new file mode 100644 index 000000000..98834afab --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_allocations_test.go @@ -0,0 +1,30 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviderAllocations(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/allocations", + s.HandleListResourceProviderAllocations, + "/resource_providers/"+validUUID+"/allocations") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/allocations", + s.HandleListResourceProviderAllocations, + "/resource_providers/not-a-uuid/allocations") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_resource_provider_inventories.go b/internal/shim/placement/handle_resource_provider_inventories.go new file mode 100644 index 000000000..20d1c52dc --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_inventories.go @@ -0,0 +1,142 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviderInventories handles +// GET /resource_providers/{uuid}/inventories requests. +// +// Returns all inventory records for the resource provider identified by +// {uuid}. The response contains an inventories dictionary keyed by resource +// class, with each entry describing capacity constraints: total, reserved, +// min_unit, max_unit, step_size, and allocation_ratio. Also returns the +// resource_provider_generation, which is needed for subsequent update or +// delete operations. Returns 404 if the provider does not exist. +func (s *Shim) HandleListResourceProviderInventories(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleUpdateResourceProviderInventories handles +// PUT /resource_providers/{uuid}/inventories requests. +// +// Atomically replaces the entire set of inventory records for a provider. +// The request must include the resource_provider_generation for optimistic +// concurrency control — if the generation does not match, the request fails +// with 409 Conflict. The inventories field is a dictionary keyed by resource +// class, each specifying at minimum a total value. Omitted inventory classes +// are deleted. Returns 409 Conflict if allocations exceed the new capacity +// or if a concurrent update has occurred. +func (s *Shim) HandleUpdateResourceProviderInventories(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleDeleteResourceProviderInventories handles +// DELETE /resource_providers/{uuid}/inventories requests. +// +// Deletes all inventory records for a resource provider. This operation is +// not safe for concurrent use; the recommended alternative for concurrent +// environments is PUT with an empty inventories dictionary. Returns 409 +// Conflict if allocations exist against any of the provider's inventories. +// Returns 404 if the provider does not exist. Available since microversion +// 1.5. +func (s *Shim) HandleDeleteResourceProviderInventories(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleShowResourceProviderInventory handles +// GET /resource_providers/{uuid}/inventories/{resource_class} requests. +// +// Returns a single inventory record for one resource class on the specified +// provider. The response includes total, reserved, min_unit, max_unit, +// step_size, allocation_ratio, and the resource_provider_generation. Returns +// 404 if the provider or inventory for that class does not exist. +func (s *Shim) HandleShowResourceProviderInventory(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + resourceClass, ok := requiredPathParam(w, r, "resource_class") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "uuid", uuid, "resource_class", resourceClass) + s.forward(w, r) +} + +// HandleUpdateResourceProviderInventory handles +// PUT /resource_providers/{uuid}/inventories/{resource_class} requests. +// +// Creates or replaces the inventory record for a single resource class on +// the provider. The request must include resource_provider_generation for +// concurrency control and a total value. Optional fields control allocation +// constraints (allocation_ratio, min_unit, max_unit, step_size, reserved). +// Since microversion 1.26, the reserved value must not exceed total. Returns +// 409 Conflict on generation mismatch or if allocations would be violated. +func (s *Shim) HandleUpdateResourceProviderInventory(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + resourceClass, ok := requiredPathParam(w, r, "resource_class") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "uuid", uuid, "resource_class", resourceClass) + s.forward(w, r) +} + +// HandleDeleteResourceProviderInventory handles +// DELETE /resource_providers/{uuid}/inventories/{resource_class} requests. +// +// Deletes the inventory record for a specific resource class on the provider. +// Returns 409 Conflict if allocations exist against this provider and resource +// class combination, or if a concurrent update has occurred. Returns 404 if +// the provider or inventory does not exist. Returns 204 No Content on success. +func (s *Shim) HandleDeleteResourceProviderInventory(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + resourceClass, ok := requiredPathParam(w, r, "resource_class") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, + "uuid", uuid, "resource_class", resourceClass) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_provider_inventories_test.go b/internal/shim/placement/handle_resource_provider_inventories_test.go new file mode 100644 index 000000000..054e48e32 --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_inventories_test.go @@ -0,0 +1,139 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviderInventories(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/inventories", + s.HandleListResourceProviderInventories, + "/resource_providers/"+validUUID+"/inventories") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/inventories", + s.HandleListResourceProviderInventories, + "/resource_providers/not-a-uuid/inventories") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateResourceProviderInventories(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/inventories", + s.HandleUpdateResourceProviderInventories, + "/resource_providers/"+validUUID+"/inventories") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/inventories", + s.HandleUpdateResourceProviderInventories, + "/resource_providers/not-a-uuid/inventories") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleDeleteResourceProviderInventories(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/inventories", + s.HandleDeleteResourceProviderInventories, + "/resource_providers/"+validUUID+"/inventories") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/inventories", + s.HandleDeleteResourceProviderInventories, + "/resource_providers/not-a-uuid/inventories") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleShowResourceProviderInventory(t *testing.T) { + t.Run("valid", func(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, "{}", &gotPath) + path := "/resource_providers/" + validUUID + "/inventories/VCPU" + w := serveHandler(t, "GET", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleShowResourceProviderInventory, path) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != path { + t.Fatalf("upstream path = %q, want %q", gotPath, path) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleShowResourceProviderInventory, + "/resource_providers/not-a-uuid/inventories/VCPU") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateResourceProviderInventory(t *testing.T) { + t.Run("valid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleUpdateResourceProviderInventory, + "/resource_providers/"+validUUID+"/inventories/VCPU") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleUpdateResourceProviderInventory, + "/resource_providers/not-a-uuid/inventories/VCPU") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleDeleteResourceProviderInventory(t *testing.T) { + t.Run("valid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleDeleteResourceProviderInventory, + "/resource_providers/"+validUUID+"/inventories/VCPU") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/inventories/{resource_class}", + s.HandleDeleteResourceProviderInventory, + "/resource_providers/not-a-uuid/inventories/VCPU") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_resource_provider_traits.go b/internal/shim/placement/handle_resource_provider_traits.go new file mode 100644 index 000000000..75250a76e --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_traits.go @@ -0,0 +1,69 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviderTraits handles +// GET /resource_providers/{uuid}/traits requests. +// +// Returns the list of traits associated with the resource provider identified +// by {uuid}. The response includes an array of trait name strings and the +// resource_provider_generation for concurrency tracking. Returns 404 if the +// provider does not exist. +func (s *Shim) HandleListResourceProviderTraits(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleUpdateResourceProviderTraits handles +// PUT /resource_providers/{uuid}/traits requests. +// +// Replaces the complete set of trait associations for a resource provider. +// The request body must include a traits array and the +// resource_provider_generation for optimistic concurrency control. All +// previously associated traits are removed and replaced by the specified set. +// Returns 400 Bad Request if any of the specified traits are invalid (i.e. +// not returned by GET /traits). Returns 409 Conflict if the generation does +// not match. +func (s *Shim) HandleUpdateResourceProviderTraits(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleDeleteResourceProviderTraits handles +// DELETE /resource_providers/{uuid}/traits requests. +// +// Removes all trait associations from a resource provider. Because this +// endpoint does not accept a resource_provider_generation, it is not safe +// for concurrent use. In environments where multiple clients manage traits +// for the same provider, prefer PUT with an empty traits list instead. +// Returns 404 if the provider does not exist. Returns 409 Conflict on +// concurrent modification. Returns 204 No Content on success. +func (s *Shim) HandleDeleteResourceProviderTraits(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_provider_traits_test.go b/internal/shim/placement/handle_resource_provider_traits_test.go new file mode 100644 index 000000000..809f0503f --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_traits_test.go @@ -0,0 +1,72 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviderTraits(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/traits", + s.HandleListResourceProviderTraits, + "/resource_providers/"+validUUID+"/traits") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/traits", + s.HandleListResourceProviderTraits, + "/resource_providers/not-a-uuid/traits") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateResourceProviderTraits(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/traits", + s.HandleUpdateResourceProviderTraits, + "/resource_providers/"+validUUID+"/traits") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}/traits", + s.HandleUpdateResourceProviderTraits, + "/resource_providers/not-a-uuid/traits") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleDeleteResourceProviderTraits(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/traits", + s.HandleDeleteResourceProviderTraits, + "/resource_providers/"+validUUID+"/traits") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}/traits", + s.HandleDeleteResourceProviderTraits, + "/resource_providers/not-a-uuid/traits") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_resource_provider_usages.go b/internal/shim/placement/handle_resource_provider_usages.go new file mode 100644 index 000000000..c13d0ae65 --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_usages.go @@ -0,0 +1,29 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviderUsages handles +// GET /resource_providers/{uuid}/usages requests. +// +// Returns aggregated resource consumption for the resource provider identified +// by {uuid}. The response contains a usages dictionary keyed by resource class +// with integer usage amounts, along with the resource_provider_generation. +// Unlike the provider allocations endpoint, this does not break down usage by +// individual consumer. Returns 404 if the provider does not exist. +func (s *Shim) HandleListResourceProviderUsages(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_provider_usages_test.go b/internal/shim/placement/handle_resource_provider_usages_test.go new file mode 100644 index 000000000..76541a993 --- /dev/null +++ b/internal/shim/placement/handle_resource_provider_usages_test.go @@ -0,0 +1,30 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviderUsages(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/usages", + s.HandleListResourceProviderUsages, + "/resource_providers/"+validUUID+"/usages") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}/usages", + s.HandleListResourceProviderUsages, + "/resource_providers/not-a-uuid/usages") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_resource_providers.go b/internal/shim/placement/handle_resource_providers.go new file mode 100644 index 000000000..b7a21018f --- /dev/null +++ b/internal/shim/placement/handle_resource_providers.go @@ -0,0 +1,103 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListResourceProviders handles GET /resource_providers requests. +// +// Returns a filtered list of resource providers. Resource providers are +// entities that provide consumable inventory of one or more classes of +// resources (e.g. a compute node providing VCPU, MEMORY_MB, DISK_GB). +// +// Supports numerous filter parameters including name, uuid, member_of +// (aggregate membership), resources (capacity filtering), in_tree (provider +// tree membership), and required (trait filtering). Multiple filters are +// combined with boolean AND logic. Many of these filters were added in later +// microversions: resources filtering at 1.3, tree queries at 1.14, trait +// requirements at 1.18, forbidden traits at 1.22, forbidden aggregates at +// 1.32, and the in: syntax for required at 1.39. +func (s *Shim) HandleListResourceProviders(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleCreateResourceProvider handles POST /resource_providers requests. +// +// Creates a new resource provider. The request must include a name and may +// optionally specify a UUID and a parent_provider_uuid (since 1.14) to place +// the provider in a hierarchical tree. If no UUID is supplied, one is +// generated. Before microversion 1.37, the parent of a resource provider +// could not be changed after creation. +// +// The response changed at microversion 1.20: earlier versions return only +// an HTTP 201 with a Location header, while 1.20+ returns the full resource +// provider object in the body. Returns 409 Conflict if a provider with the +// same name or UUID already exists. +func (s *Shim) HandleCreateResourceProvider(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleShowResourceProvider handles GET /resource_providers/{uuid} requests. +// +// Returns a single resource provider identified by its UUID. The response +// includes the provider's name, generation (used for concurrency control in +// subsequent updates), and links. Starting at microversion 1.14, the response +// also includes parent_provider_uuid and root_provider_uuid to describe the +// provider's position in a hierarchical tree. Returns 404 if the provider +// does not exist. +func (s *Shim) HandleShowResourceProvider(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleUpdateResourceProvider handles PUT /resource_providers/{uuid} requests. +// +// Updates a resource provider's name and, starting at microversion 1.14, its +// parent_provider_uuid. Since microversion 1.37, the parent may be changed to +// any existing provider UUID that would not create a loop in the tree, or set +// to null to make the provider a root. Returns 409 Conflict if another +// provider already has the requested name. +func (s *Shim) HandleUpdateResourceProvider(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} + +// HandleDeleteResourceProvider handles DELETE /resource_providers/{uuid} requests. +// +// Deletes a resource provider and disassociates all its aggregates and +// inventories. The operation fails with 409 Conflict if there are any +// allocations against the provider's inventories or if the provider has +// child providers in a tree hierarchy. Returns 204 No Content on success. +func (s *Shim) HandleDeleteResourceProvider(w http.ResponseWriter, r *http.Request) { + uuid, ok := requiredUUIDPathParam(w, r, "uuid") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "uuid", uuid) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_resource_providers_test.go b/internal/shim/placement/handle_resource_providers_test.go new file mode 100644 index 000000000..520a32c0b --- /dev/null +++ b/internal/shim/placement/handle_resource_providers_test.go @@ -0,0 +1,86 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListResourceProviders(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"resource_providers":[]}`, &gotPath) + w := serveHandler(t, "GET", "/resource_providers", s.HandleListResourceProviders, "/resource_providers") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/resource_providers" { + t.Fatalf("upstream path = %q, want /resource_providers", gotPath) + } +} + +func TestHandleCreateResourceProvider(t *testing.T) { + s := newTestShim(t, http.StatusCreated, "{}", nil) + w := serveHandler(t, "POST", "/resource_providers", s.HandleCreateResourceProvider, "/resource_providers") + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", w.Code, http.StatusCreated) + } +} + +func TestHandleShowResourceProvider(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}", s.HandleShowResourceProvider, + "/resource_providers/"+validUUID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "GET", "/resource_providers/{uuid}", s.HandleShowResourceProvider, + "/resource_providers/not-a-uuid") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleUpdateResourceProvider(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}", s.HandleUpdateResourceProvider, + "/resource_providers/"+validUUID) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "PUT", "/resource_providers/{uuid}", s.HandleUpdateResourceProvider, + "/resource_providers/not-a-uuid") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestHandleDeleteResourceProvider(t *testing.T) { + t.Run("valid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}", s.HandleDeleteResourceProvider, + "/resource_providers/"+validUUID) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + }) + t.Run("invalid uuid", func(t *testing.T) { + s := newTestShim(t, http.StatusOK, "{}", nil) + w := serveHandler(t, "DELETE", "/resource_providers/{uuid}", s.HandleDeleteResourceProvider, + "/resource_providers/not-a-uuid") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/internal/shim/placement/handle_root.go b/internal/shim/placement/handle_root.go new file mode 100644 index 000000000..10821bf42 --- /dev/null +++ b/internal/shim/placement/handle_root.go @@ -0,0 +1,25 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleGetRoot handles GET / requests. +// +// Returns information about all known major versions of the Placement API, +// including the minimum and maximum supported microversions for each version. +// Currently only one major version (v1.0) exists. Each version entry includes +// its status (e.g. CURRENT), links for discovery, and the microversion range +// supported by the running service. Clients use this endpoint to discover API +// capabilities and negotiate microversions before making further requests. +func (s *Shim) HandleGetRoot(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_root_test.go b/internal/shim/placement/handle_root_test.go new file mode 100644 index 000000000..e342f6a68 --- /dev/null +++ b/internal/shim/placement/handle_root_test.go @@ -0,0 +1,21 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleGetRoot(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"versions":[]}`, &gotPath) + w := serveHandler(t, "GET", "/{$}", s.HandleGetRoot, "/") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/" { + t.Fatalf("upstream path = %q, want %q", gotPath, "/") + } +} diff --git a/internal/shim/placement/handle_traits.go b/internal/shim/placement/handle_traits.go new file mode 100644 index 000000000..7cb645552 --- /dev/null +++ b/internal/shim/placement/handle_traits.go @@ -0,0 +1,77 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListTraits handles GET /traits requests. +// +// Returns a list of valid trait strings. Traits describe qualitative aspects +// of a resource provider (e.g. HW_CPU_X86_AVX2, STORAGE_DISK_SSD). The list +// includes both standard traits from the os-traits library and custom traits +// prefixed with CUSTOM_. +// +// Supports optional query parameters: name allows filtering by prefix +// (startswith:CUSTOM) or by an explicit list (in:TRAIT1,TRAIT2), and +// associated filters to only traits that are or are not associated with at +// least one resource provider. +func (s *Shim) HandleListTraits(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} + +// HandleShowTrait handles GET /traits/{name} requests. +// +// Checks whether a trait with the given name exists. Returns 204 No Content +// (with no response body) if the trait is found, or 404 Not Found otherwise. +func (s *Shim) HandleShowTrait(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} + +// HandleUpdateTrait handles PUT /traits/{name} requests. +// +// Creates a new custom trait. Only traits prefixed with CUSTOM_ may be +// created; standard traits are read-only. Returns 201 Created if the trait +// is newly inserted, or 204 No Content if it already exists. Returns 400 +// Bad Request if the name does not carry the CUSTOM_ prefix. +func (s *Shim) HandleUpdateTrait(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} + +// HandleDeleteTrait handles DELETE /traits/{name} requests. +// +// Deletes a custom trait. Standard traits (those without the CUSTOM_ prefix) +// cannot be deleted and will return 400 Bad Request. Returns 409 Conflict if +// the trait is still associated with any resource provider. Returns 404 if +// the trait does not exist. Returns 204 No Content on success. +func (s *Shim) HandleDeleteTrait(w http.ResponseWriter, r *http.Request) { + name, ok := requiredPathParam(w, r, "name") + if !ok { + return + } + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path, "name", name) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_traits_test.go b/internal/shim/placement/handle_traits_test.go new file mode 100644 index 000000000..09d5a8586 --- /dev/null +++ b/internal/shim/placement/handle_traits_test.go @@ -0,0 +1,49 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListTraits(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"traits":[]}`, &gotPath) + w := serveHandler(t, "GET", "/traits", s.HandleListTraits, "/traits") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/traits" { + t.Fatalf("upstream path = %q, want /traits", gotPath) + } +} + +func TestHandleShowTrait(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusNoContent, "", &gotPath) + w := serveHandler(t, "GET", "/traits/{name}", s.HandleShowTrait, "/traits/HW_CPU_X86_AVX2") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } + if gotPath != "/traits/HW_CPU_X86_AVX2" { + t.Fatalf("upstream path = %q, want /traits/HW_CPU_X86_AVX2", gotPath) + } +} + +func TestHandleUpdateTrait(t *testing.T) { + s := newTestShim(t, http.StatusCreated, "", nil) + w := serveHandler(t, "PUT", "/traits/{name}", s.HandleUpdateTrait, "/traits/CUSTOM_TRAIT") + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", w.Code, http.StatusCreated) + } +} + +func TestHandleDeleteTrait(t *testing.T) { + s := newTestShim(t, http.StatusNoContent, "", nil) + w := serveHandler(t, "DELETE", "/traits/{name}", s.HandleDeleteTrait, "/traits/CUSTOM_TRAIT") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", w.Code, http.StatusNoContent) + } +} diff --git a/internal/shim/placement/handle_usages.go b/internal/shim/placement/handle_usages.go new file mode 100644 index 000000000..2e3308c1e --- /dev/null +++ b/internal/shim/placement/handle_usages.go @@ -0,0 +1,29 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleListUsages handles GET /usages requests. +// +// Returns a report of aggregated resource usage for a given project, and +// optionally a specific user within that project. The project_id query +// parameter is required; user_id is optional. +// +// The response format changed at microversion 1.38: earlier versions return +// a flat dictionary of resource class to usage totals, while 1.38+ groups +// usages by consumer_type (e.g. INSTANCE, MIGRATION, all, unknown), with +// each group containing resource totals and a consumer_count. Since +// microversion 1.38, an optional consumer_type query parameter allows +// filtering the results. Available since microversion 1.9. +func (s *Shim) HandleListUsages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logf.FromContext(ctx) + log.Info("placement request", "method", r.Method, "path", r.URL.Path) + s.forward(w, r) +} diff --git a/internal/shim/placement/handle_usages_test.go b/internal/shim/placement/handle_usages_test.go new file mode 100644 index 000000000..46d91681b --- /dev/null +++ b/internal/shim/placement/handle_usages_test.go @@ -0,0 +1,21 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "testing" +) + +func TestHandleListUsages(t *testing.T) { + var gotPath string + s := newTestShim(t, http.StatusOK, `{"usages":{}}`, &gotPath) + w := serveHandler(t, "GET", "/usages", s.HandleListUsages, "/usages") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if gotPath != "/usages" { + t.Fatalf("upstream path = %q, want /usages", gotPath) + } +} diff --git a/internal/shim/placement/shim.go b/internal/shim/placement/shim.go new file mode 100644 index 000000000..f0c5284b4 --- /dev/null +++ b/internal/shim/placement/shim.go @@ -0,0 +1,289 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/cobaltcore-dev/cortex/pkg/conf" + "github.com/cobaltcore-dev/cortex/pkg/multicluster" + "github.com/cobaltcore-dev/cortex/pkg/sso" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +var ( + // setupLog is a controller-runtime logger used for setup and route + // registration. Individual handlers should use their own loggers derived + // from the request context. + setupLog = ctrl.Log.WithName("placement-shim") +) + +// config holds configuration for the placement shim. +type config struct { + // SSO is an optional reference to a Kubernetes secret containing + // credentials to talk to openstack over ingress via single-sign-on. + SSO *sso.SSOConfig `json:"sso,omitempty"` + // PlacementURL is the URL of the OpenStack Placement API the shim + // should forward requests to. + PlacementURL string `json:"placementURL,omitempty"` +} + +// validate checks the config for required fields and returns an error if the +// config is invalid. +func (c *config) validate() error { + if c.PlacementURL == "" { + return errors.New("placement URL is required") + } + return nil +} + +// Shim is the placement API shim. It holds a controller-runtime client for +// making Kubernetes API calls and exposes HTTP handlers that mirror the +// OpenStack Placement API surface. +type Shim struct { + client.Client + config config + // HTTP client that can talk to openstack placement, if needed, over + // ingress with single-sign-on. + httpClient *http.Client +} + +// Start is called after the manager has started and the cache is running. +// It can be used to perform any initialization that requires the cache to be +// running. +func (s *Shim) Start(ctx context.Context) (err error) { + setupLog.Info("Starting placement shim") + // Build the transport with optional SSO TLS credentials. + var transport *http.Transport + if s.config.SSO != nil { + setupLog.Info("SSO config provided, creating transport for placement API") + transport, err = sso.NewTransport(*s.config.SSO) + if err != nil { + setupLog.Error(err, "Failed to create transport from SSO config") + return err + } + } else { + setupLog.Info("No SSO config provided, using plain transport for placement API") + transport = &http.Transport{} + } + // All proxy traffic goes to one placement API host, so raise the + // per-host idle connection limit from the default of 2. + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 100 + // Guard against a hung upstream or slow TLS negotiation. + transport.DialContext = (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext + transport.TLSHandshakeTimeout = 10 * time.Second + transport.ResponseHeaderTimeout = 60 * time.Second + transport.ExpectContinueTimeout = 1 * time.Second + transport.IdleConnTimeout = 90 * time.Second + s.httpClient = &http.Client{Transport: transport, Timeout: 60 * time.Second} + // Try establish a connection to the placement API to fail fast if the + // configuration is invalid. Directly call the root endpoint for that. + setupLog.Info("Testing connection to placement API", "url", s.config.PlacementURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.config.PlacementURL, http.NoBody) + if err != nil { + setupLog.Error(err, "Failed to create HTTP request to placement API") + return err + } + resp, err := s.httpClient.Do(req) + if err != nil { + setupLog.Error(err, "Failed to connect to placement API") + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + err := errors.New("unexpected response from placement API") + setupLog.Error(err, "Failed to call placement API", "status", resp.Status) + return err + } + setupLog.Info("Successfully connected to placement API") + return nil +} + +// Reconcile is not used by the shim, but must be implemented to satisfy the +// controller-runtime Reconciler interface. +func (s *Shim) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return ctrl.Result{}, nil +} + +// handleRemoteHypervisor is called by watches in remote clusters and triggers +// a reconcile on the hypervisor resource that was changed in the remote cluster. +func (s *Shim) handleRemoteHypervisor() handler.EventHandler { + handler := handler.Funcs{} + // For now, the shim doesn't need to do anything on hypervisor events. + return handler +} + +// predicateRemoteHypervisor is used to filter events from remote clusters, +// so that only events for hypervisors that should be processed by the shim. +func (s *Shim) predicateRemoteHypervisor() predicate.Predicate { + // For now, the shim doesn't need to process any hypervisor events. + return predicate.NewPredicateFuncs(func(object client.Object) bool { + return false + }) +} + +// SetupWithManager registers field indexes on the manager's cache so that +// subsequent list calls are served from the informer cache rather than +// hitting the API server. This must be called before the manager is started. +// +// Calling IndexField internally invokes GetInformer, which creates and +// registers a shared informer for the indexed type (hv1.Hypervisor) with the +// cache. The informer is started later when mgr.Start() is called. This +// means no separate controller or empty Reconcile loop is needed — the +// index registration alone is sufficient to warm the cache. +func (s *Shim) SetupWithManager(ctx context.Context, mgr ctrl.Manager) (err error) { + setupLog.Info("Setting up placement shim with manager") + s.config, err = conf.GetConfig[config]() + if err != nil { + setupLog.Error(err, "Failed to load placement shim config") + return err + } + // Validate we don't have any weird values in the config. + if err := s.config.validate(); err != nil { + return err + } + // Check that the provided client is a multicluster client, since we need + // that to watch for hypervisors across clusters. + mcl, ok := s.Client.(*multicluster.Client) + if !ok { + return errors.New("provided client must be a multicluster client") + } + bldr := multicluster.BuildController(mcl, mgr) + // The hypervisor crd may be distributed across multiple remote clusters. + bldr, err = bldr.WatchesMulticluster(&hv1.Hypervisor{}, + s.handleRemoteHypervisor(), + s.predicateRemoteHypervisor(), + ) + if err != nil { + return err + } + return bldr.Named("placement-shim").Complete(s) +} + +// forward proxies the incoming HTTP request to the upstream placement API +// and copies the response (status, headers, body) back to the client. +func (s *Shim) forward(w http.ResponseWriter, r *http.Request) { + log := logf.FromContext(r.Context()) + + if s.httpClient == nil { + log.Info("placement shim not yet initialized, rejecting request") + http.Error(w, "service not ready", http.StatusServiceUnavailable) + return + } + + // Parse the trusted base URL and resolve the request path against it + // so the upstream target is always anchored to the configured host. + upstream, err := url.Parse(s.config.PlacementURL) + if err != nil { + log.Error(err, "failed to parse placement URL", "url", s.config.PlacementURL) + http.Error(w, "failed to parse placement URL", http.StatusBadGateway) + return + } + upstream.Path, err = url.JoinPath(upstream.Path, r.URL.Path) + if err != nil { + log.Error(err, "failed to join upstream path", "path", r.URL.Path) + http.Error(w, "failed to join upstream path", http.StatusBadGateway) + return + } + upstream.RawQuery = r.URL.RawQuery + + // Create upstream request preserving method, body, and context. + upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstream.String(), r.Body) + if err != nil { + log.Error(err, "failed to create upstream request", "url", upstream.String()) + http.Error(w, "failed to create upstream request", http.StatusBadGateway) + return + } + + // Copy all incoming headers. + upstreamReq.Header = r.Header.Clone() + + resp, err := s.httpClient.Do(upstreamReq) //nolint:gosec // G704: intentional reverse proxy; host is fixed by operator config, only path varies + if err != nil { + log.Error(err, "failed to reach placement API", "url", upstream.String()) + http.Error(w, "failed to reach placement API", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers, status code, and body back to the caller. + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(w, resp.Body); err != nil { + log.Error(err, "failed to copy upstream response body") + } +} + +// RegisterRoutes binds all Placement API handlers to the given mux. The +// route patterns use the Go 1.22+ ServeMux syntax with explicit HTTP methods +// and path wildcards. The routes mirror the OpenStack Placement API surface +// as documented at https://docs.openstack.org/api-ref/placement/. +func (s *Shim) RegisterRoutes(mux *http.ServeMux) { + setupLog.Info("Registering placement API routes") + handlers := []struct { + method string + pattern string + handler http.HandlerFunc + }{ + {"GET", "/{$}", s.HandleGetRoot}, + {"GET", "/resource_providers", s.HandleListResourceProviders}, + {"POST", "/resource_providers", s.HandleCreateResourceProvider}, + {"GET", "/resource_providers/{uuid}", s.HandleShowResourceProvider}, + {"PUT", "/resource_providers/{uuid}", s.HandleUpdateResourceProvider}, + {"DELETE", "/resource_providers/{uuid}", s.HandleDeleteResourceProvider}, + {"GET", "/resource_classes", s.HandleListResourceClasses}, + {"POST", "/resource_classes", s.HandleCreateResourceClass}, + {"GET", "/resource_classes/{name}", s.HandleShowResourceClass}, + {"PUT", "/resource_classes/{name}", s.HandleUpdateResourceClass}, + {"DELETE", "/resource_classes/{name}", s.HandleDeleteResourceClass}, + {"GET", "/resource_providers/{uuid}/inventories", s.HandleListResourceProviderInventories}, + {"PUT", "/resource_providers/{uuid}/inventories", s.HandleUpdateResourceProviderInventories}, + {"DELETE", "/resource_providers/{uuid}/inventories", s.HandleDeleteResourceProviderInventories}, + {"GET", "/resource_providers/{uuid}/inventories/{resource_class}", s.HandleShowResourceProviderInventory}, + {"PUT", "/resource_providers/{uuid}/inventories/{resource_class}", s.HandleUpdateResourceProviderInventory}, + {"DELETE", "/resource_providers/{uuid}/inventories/{resource_class}", s.HandleDeleteResourceProviderInventory}, + {"GET", "/resource_providers/{uuid}/aggregates", s.HandleListResourceProviderAggregates}, + {"PUT", "/resource_providers/{uuid}/aggregates", s.HandleUpdateResourceProviderAggregates}, + {"GET", "/traits", s.HandleListTraits}, + {"GET", "/traits/{name}", s.HandleShowTrait}, + {"PUT", "/traits/{name}", s.HandleUpdateTrait}, + {"DELETE", "/traits/{name}", s.HandleDeleteTrait}, + {"GET", "/resource_providers/{uuid}/traits", s.HandleListResourceProviderTraits}, + {"PUT", "/resource_providers/{uuid}/traits", s.HandleUpdateResourceProviderTraits}, + {"DELETE", "/resource_providers/{uuid}/traits", s.HandleDeleteResourceProviderTraits}, + {"POST", "/allocations", s.HandleManageAllocations}, + {"GET", "/allocations/{consumer_uuid}", s.HandleListAllocations}, + {"PUT", "/allocations/{consumer_uuid}", s.HandleUpdateAllocations}, + {"DELETE", "/allocations/{consumer_uuid}", s.HandleDeleteAllocations}, + {"GET", "/resource_providers/{uuid}/allocations", s.HandleListResourceProviderAllocations}, + {"GET", "/usages", s.HandleListUsages}, + {"GET", "/resource_providers/{uuid}/usages", s.HandleListResourceProviderUsages}, + {"GET", "/allocation_candidates", s.HandleListAllocationCandidates}, + {"POST", "/reshaper", s.HandlePostReshaper}, + } + for _, h := range handlers { + setupLog.Info("Registering route", "method", h.method, "pattern", h.pattern) + mux.HandleFunc(h.method+" "+h.pattern, h.handler) + } + setupLog.Info("Successfully registered placement API routes") +} diff --git a/internal/shim/placement/shim_test.go b/internal/shim/placement/shim_test.go new file mode 100644 index 000000000..fc81d9b1f --- /dev/null +++ b/internal/shim/placement/shim_test.go @@ -0,0 +1,209 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const validUUID = "d9b3a520-2a3c-4f6b-8b9a-1c2d3e4f5a6b" + +// newTestShim creates a Shim backed by an upstream test server that returns +// the given status and body for every request. It records the last request +// path in *gotPath when non-nil. +func newTestShim(t *testing.T, status int, body string, gotPath *string) *Shim { + t.Helper() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if gotPath != nil { + *gotPath = r.URL.Path + } + w.WriteHeader(status) + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("failed to write response body: %v", err) + } + })) + t.Cleanup(upstream.Close) + return &Shim{ + config: config{PlacementURL: upstream.URL}, + httpClient: upstream.Client(), + } +} + +// serveHandler registers a single handler on a fresh mux and serves the +// request through it, returning the recorded response. +func serveHandler(t *testing.T, method, pattern string, handler http.HandlerFunc, reqPath string) *httptest.ResponseRecorder { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(method+" "+pattern, handler) + req := httptest.NewRequest(method, reqPath, http.NoBody) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w +} + +func TestForward(t *testing.T) { + tests := []struct { + name string + path string + query string + method string + body string + reqHeaders map[string]string + upstreamStatus int + upstreamBody string + upstreamHeader map[string]string + }{ + { + name: "GET with query string", + path: "/resource_providers", + query: "name=test", + method: "GET", + upstreamStatus: http.StatusOK, + upstreamBody: `{"resource_providers":[]}`, + upstreamHeader: map[string]string{"Content-Type": "application/json"}, + }, + { + name: "PUT with body and headers", + path: "/resource_providers/abc", + method: "PUT", + body: `{"name":"new"}`, + reqHeaders: map[string]string{"X-Custom": "val"}, + upstreamStatus: http.StatusOK, + upstreamBody: `{"uuid":"abc"}`, + }, + { + name: "upstream error", + path: "/fail", + method: "GET", + upstreamStatus: http.StatusNotFound, + upstreamBody: "not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the path and query were forwarded. + if r.URL.Path != tt.path { + t.Errorf("upstream path = %q, want %q", r.URL.Path, tt.path) + } + if r.URL.RawQuery != tt.query { + t.Errorf("upstream query = %q, want %q", r.URL.RawQuery, tt.query) + } + if r.Method != tt.method { + t.Errorf("upstream method = %q, want %q", r.Method, tt.method) + } + // Verify headers were copied. + for k, v := range tt.reqHeaders { + if got := r.Header.Get(k); got != v { + t.Errorf("upstream header %q = %q, want %q", k, got, v) + } + } + // Verify body was copied. + if tt.body != "" { + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read upstream body: %v", err) + } + if string(b) != tt.body { + t.Errorf("upstream body = %q, want %q", string(b), tt.body) + } + } + for k, v := range tt.upstreamHeader { + w.Header().Set(k, v) + } + w.WriteHeader(tt.upstreamStatus) + if _, err := w.Write([]byte(tt.upstreamBody)); err != nil { + t.Fatalf("failed to write upstream body: %v", err) + } + })) + defer upstream.Close() + + s := &Shim{ + config: config{PlacementURL: upstream.URL}, + httpClient: upstream.Client(), + } + target := tt.path + if tt.query != "" { + target += "?" + tt.query + } + var bodyReader io.Reader + if tt.body != "" { + bodyReader = strings.NewReader(tt.body) + } + req := httptest.NewRequest(tt.method, target, bodyReader) + for k, v := range tt.reqHeaders { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + s.forward(w, req) + + if w.Code != tt.upstreamStatus { + t.Fatalf("status = %d, want %d", w.Code, tt.upstreamStatus) + } + if got := w.Body.String(); got != tt.upstreamBody { + t.Fatalf("body = %q, want %q", got, tt.upstreamBody) + } + for k, v := range tt.upstreamHeader { + if got := w.Header().Get(k); got != v { + t.Errorf("response header %q = %q, want %q", k, got, v) + } + } + }) + } +} + +func TestForwardUpstreamUnreachable(t *testing.T) { + s := &Shim{ + config: config{PlacementURL: "http://127.0.0.1:1"}, + httpClient: &http.Client{}, + } + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + w := httptest.NewRecorder() + s.forward(w, req) + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadGateway) + } +} + +func TestRegisterRoutes(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + s := &Shim{ + config: config{PlacementURL: upstream.URL}, + httpClient: upstream.Client(), + } + mux := http.NewServeMux() + s.RegisterRoutes(mux) + // Verify a sample of routes are registered. Unregistered patterns + // return 404 from the default mux; registered ones reach the upstream. + routes := []struct { + method string + path string + }{ + {"GET", "/"}, + {"GET", "/resource_providers"}, + {"POST", "/resource_providers"}, + {"GET", "/traits"}, + {"GET", "/allocation_candidates"}, + {"POST", "/reshaper"}, + {"POST", "/allocations"}, + {"GET", "/usages"}, + } + for _, rt := range routes { + t.Run(rt.method+" "+rt.path, func(t *testing.T) { + req := httptest.NewRequest(rt.method, rt.path, http.NoBody) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Fatalf("route %s %s returned 404, expected it to be registered", rt.method, rt.path) + } + }) + } +} diff --git a/internal/shim/placement/validation.go b/internal/shim/placement/validation.go new file mode 100644 index 000000000..b025cbfd7 --- /dev/null +++ b/internal/shim/placement/validation.go @@ -0,0 +1,38 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" +) + +// requiredPathParam extracts a path parameter by name and verifies that it is +// non-empty. If the value is missing, it writes a 400 response and returns +// an empty string. +func requiredPathParam(w http.ResponseWriter, r *http.Request, name string) (string, bool) { + v := r.PathValue(name) + if v == "" { + http.Error(w, "missing path parameter: "+name, http.StatusBadRequest) + return "", false + } + return v, true +} + +// requiredUUIDPathParam extracts a path parameter by name and verifies that it +// is a valid UUID. If the value is missing or not a valid UUID, it writes a +// 400 response and returns an empty string. +func requiredUUIDPathParam(w http.ResponseWriter, r *http.Request, name string) (string, bool) { + v, ok := requiredPathParam(w, r, name) + if !ok { + return "", false + } + if err := uuid.Validate(v); err != nil { + http.Error(w, fmt.Sprintf("invalid UUID in path parameter %s: %s", name, v), http.StatusBadRequest) + return "", false + } + return v, true +} diff --git a/internal/shim/placement/validation_test.go b/internal/shim/placement/validation_test.go new file mode 100644 index 000000000..b0b39c27e --- /dev/null +++ b/internal/shim/placement/validation_test.go @@ -0,0 +1,89 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package placement + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequiredPathParam(t *testing.T) { + t.Run("valid param", func(t *testing.T) { + mux := http.NewServeMux() + var gotValue string + var gotOK bool + mux.HandleFunc("GET /test/{name}", func(w http.ResponseWriter, r *http.Request) { + gotValue, gotOK = requiredPathParam(w, r, "name") + }) + req := httptest.NewRequest(http.MethodGet, "/test/VCPU", http.NoBody) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if !gotOK { + t.Fatal("expected ok = true") + } + if gotValue != "VCPU" { + t.Fatalf("value = %q, want %q", gotValue, "VCPU") + } + }) + t.Run("wrong param name returns empty", func(t *testing.T) { + mux := http.NewServeMux() + var gotOK bool + mux.HandleFunc("GET /test/{name}", func(w http.ResponseWriter, r *http.Request) { + _, gotOK = requiredPathParam(w, r, "nonexistent") + }) + req := httptest.NewRequest(http.MethodGet, "/test/VCPU", http.NoBody) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if gotOK { + t.Fatal("expected ok = false for wrong param name") + } + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + }) +} + +func TestRequiredUUIDPathParam(t *testing.T) { + tests := []struct { + name string + paramValue string + wantOK bool + wantCode int + }{ + { + name: "valid uuid", + paramValue: "d9b3a520-2a3c-4f6b-8b9a-1c2d3e4f5a6b", + wantOK: true, + }, + { + name: "invalid uuid", + paramValue: "not-a-uuid", + wantOK: false, + wantCode: http.StatusBadRequest, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + var gotValue string + var gotOK bool + mux.HandleFunc("GET /test/{uuid}", func(w http.ResponseWriter, r *http.Request) { + gotValue, gotOK = requiredUUIDPathParam(w, r, "uuid") + }) + req := httptest.NewRequest(http.MethodGet, "/test/"+tt.paramValue, http.NoBody) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if tt.wantOK && gotValue != tt.paramValue { + t.Fatalf("value = %q, want %q", gotValue, tt.paramValue) + } + if !tt.wantOK && w.Code != tt.wantCode { + t.Fatalf("status = %d, want %d", w.Code, tt.wantCode) + } + }) + } +} diff --git a/pkg/multicluster/routers.go b/pkg/multicluster/routers.go index 5eb693f2d..8c41e822a 100644 --- a/pkg/multicluster/routers.go +++ b/pkg/multicluster/routers.go @@ -9,8 +9,18 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" ) +// DefaultResourceRouters defines all mappings of GroupVersionKinds to RRs +// for the multicluster client that cortex supports by default. This is used to +// route resources to the correct cluster in a multicluster setup. +var DefaultResourceRouters = map[schema.GroupVersionKind]ResourceRouter{ + {Group: "kvm.cloud.sap", Version: "v1", Kind: "Hypervisor"}: HypervisorResourceRouter{}, + {Group: "cortex.cloud", Version: "v1alpha1", Kind: "Reservation"}: ReservationsResourceRouter{}, + {Group: "cortex.cloud", Version: "v1alpha1", Kind: "History"}: HistoryResourceRouter{}, +} + // ResourceRouter determines which remote cluster a resource should be written to // by matching the resource content against the cluster's labels. type ResourceRouter interface { diff --git a/pkg/sso/sso.go b/pkg/sso/sso.go index 069533518..c5535d915 100644 --- a/pkg/sso/sso.go +++ b/pkg/sso/sso.go @@ -70,15 +70,13 @@ func (c Connector) FromSecretRef(ctx context.Context, ref corev1.SecretReference return NewHTTPClient(conf) } -// Create a new HTTP client with the given SSO configuration -// and logging for each request. -func NewHTTPClient(conf SSOConfig) (*http.Client, error) { +// NewTransport returns an *http.Transport configured with TLS client +// certificates from the given SSO config. If no certificate is provided, +// a plain *http.Transport is returned. +func NewTransport(conf SSOConfig) (*http.Transport, error) { if conf.Cert == "" { - // Disable SSO if no certificate is provided. - slog.Debug("making http requests without SSO") - return &http.Client{Transport: &requestLogger{T: &http.Transport{}}}, nil + return &http.Transport{}, nil } - // If we have a public key, we also need a private key. if conf.CertKey == "" { return nil, errors.New("missing cert key for SSO") } @@ -91,7 +89,7 @@ func NewHTTPClient(conf SSOConfig) (*http.Client, error) { } caCertPool := x509.NewCertPool() caCertPool.AddCert(cert.Leaf) - return &http.Client{Transport: &requestLogger{T: &http.Transport{ + return &http.Transport{ TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caCertPool, @@ -99,5 +97,18 @@ func NewHTTPClient(conf SSOConfig) (*http.Client, error) { //nolint:gosec InsecureSkipVerify: conf.SelfSigned, }, - }}}, nil + }, nil +} + +// Create a new HTTP client with the given SSO configuration +// and logging for each request. +func NewHTTPClient(conf SSOConfig) (*http.Client, error) { + transport, err := NewTransport(conf) + if err != nil { + return nil, err + } + if conf.Cert == "" { + slog.Debug("making http requests without SSO") + } + return &http.Client{Transport: &requestLogger{T: transport}}, nil }