Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ func main() {
switch os.Args[1] {
case "e2e-nova":
novaChecksConfig := conf.GetConfigOrDie[nova.ChecksConfig]()
if len(os.Args) >= 3 {
if err := json.Unmarshal([]byte(os.Args[2]), &novaChecksConfig); err != nil {
slog.Error("invalid json override for e2e-nova", "err", err)
os.Exit(1)
}
}
nova.RunChecks(ctx, client, novaChecksConfig)
return
case "e2e-cinder":
Expand Down
9 changes: 9 additions & 0 deletions helm/bundles/cortex-nova/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ cortex-scheduling-controllers:
allocationGracePeriod: "15m"
# URL of the nova external scheduler API for placement decisions
schedulerURL: "http://localhost:8080/scheduler/nova/external"
# Keystone credentials used to resolve domain IDs to domain names for the
# domain_name scheduler hint consumed by filter_external_customer.
# Must match the credentials used by the commitments syncer.
keystoneSecretRef:
name: cortex-nova-openstack-keystone
namespace: default
# ssoSecretRef:
# name: cortex-nova-openstack-sso
# namespace: default
committedResourceController:
# Back-off interval while CommittedResource placement is pending or failed (base for exponential backoff)
requeueIntervalRetry: "1m"
Expand Down
85 changes: 84 additions & 1 deletion internal/scheduling/nova/e2e_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,36 @@ const (
nRandomRequestsToSend = 50
)

// ChecksConfig holds configuration for nova e2e checks.
type ChecksConfig struct {
// Secret ref to keystone credentials stored in a k8s secret.
KeystoneSecretRef corev1.SecretReference `json:"keystoneSecretRef"`
// Secret ref to SSO credentials stored in a k8s secret, if applicable.
SSOSecretRef *corev1.SecretReference `json:"ssoSecretRef"`
// DomainNameHintCheck holds optional configuration for the domain_name hint check.
// When nil the check is skipped. Provide DomainName, EligibleHosts, and FlavorName
// to exercise the filter_external_customer path for CR reservation scheduling.
DomainNameHintCheck *DomainNameHintConfig `json:"domainNameHintCheck,omitempty"`
}

// DomainNameHintConfig holds parameters for CheckDomainNameHintRouting.
type DomainNameHintConfig struct {
// DomainName is the OpenStack domain name passed as the domain_name scheduler hint.
// Use a real domain name from the target environment so filter_external_customer
// can apply its prefix-matching logic.
DomainName string `json:"domainName"`
// EligibleHosts is the list of compute hostnames offered to the scheduler.
// Include at least one host with CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE and one without
// to give the filter something to act on.
EligibleHosts []string `json:"eligibleHosts"`
// FlavorName is the Nova flavor name to use in the request (e.g. "g_k_c1_m2_v2").
FlavorName string `json:"flavorName"`
// FlavorExtraSpecs are the extra specs for the flavor. Required by most pipeline
// filters to determine hypervisor type, capabilities, and traits.
// Example: {"capabilities:hypervisor_type": "CH", "quota:hw_version": "2101"}
FlavorExtraSpecs map[string]string `json:"flavorExtraSpecs,omitempty"`
// Pipeline is the scheduler pipeline to target. Empty means default.
Pipeline string `json:"pipeline,omitempty"`
}

// Data necessary to generate a somewhat valid nova scheduler request.
Expand Down Expand Up @@ -356,6 +381,64 @@ func checkNovaSchedulerReturnsValidHosts(
return resp.Hosts
}

// CheckDomainNameHintRouting sends a synthetic CR reservation scheduling request
// with _nova_check_type=reserve_for_committed_resource and domain_name=config.DomainName
// to the nova external scheduler and asserts HTTP 200. Confirms the hint flows through
// the pipeline and filter_external_customer evaluates it. Inspect the manager logs to
// see which hosts were kept or dropped.
func CheckDomainNameHintRouting(ctx context.Context, config ChecksConfig) {
cfg := config.DomainNameHintCheck
if cfg == nil {
slog.Info("domain_name hint check skipped: DomainNameHintCheck not configured")
return
}
if cfg.FlavorName == "" || len(cfg.EligibleHosts) == 0 {
slog.Info("domain_name hint check skipped: FlavorName or EligibleHosts not set")
return
}

hosts := make([]api.ExternalSchedulerHost, len(cfg.EligibleHosts))
weights := make(map[string]float64, len(cfg.EligibleHosts))
for i, h := range cfg.EligibleHosts {
hosts[i] = api.ExternalSchedulerHost{ComputeHost: h}
weights[h] = 0.0
}

req := api.ExternalSchedulerRequest{
Pipeline: cfg.Pipeline,
Hosts: hosts,
Weights: weights,
Spec: api.NovaObject[api.NovaSpec]{
Data: api.NovaSpec{
InstanceUUID: "e2e-domain-hint-check",
Flavor: api.NovaObject[api.NovaFlavor]{
Data: api.NovaFlavor{
Name: cfg.FlavorName,
ExtraSpecs: cfg.FlavorExtraSpecs,
},
},
SchedulerHints: map[string]any{
"_nova_check_type": string(api.ReserveForCommittedResourceIntent),
"domain_name": cfg.DomainName,
},
},
},
}

slog.Info("domain_name hint check: sending CR reservation scheduling request",
"domainName", cfg.DomainName,
"eligibleHosts", cfg.EligibleHosts,
"flavorName", cfg.FlavorName,
)

hosts2 := checkNovaSchedulerReturnsValidHosts(ctx, req)
slog.Info("domain_name hint check passed",
"domainName", cfg.DomainName,
"hostsReturned", len(hosts2),
"hosts", hosts2,
)
}

// Run all checks.
func RunChecks(ctx context.Context, client client.Client, config ChecksConfig) {
datacenter := prepare(ctx, client, config)
Expand All @@ -370,10 +453,10 @@ func RunChecks(ctx context.Context, client client.Client, config ChecksConfig) {
requestsWithNoHostsReturned++
}
}
// Print a summary.
slog.Info(
"summary",
"requestsWithHostsReturned", requestsWithHostsReturned,
"requestsWithNoHostsReturned", requestsWithNoHostsReturned,
)
CheckDomainNameHintRouting(ctx, config)
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ func TestFilterExternalCustomerStep_Run(t *testing.T) {
Traits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"},
},
},
&hv1.Hypervisor{
ObjectMeta: v1.ObjectMeta{
Name: "host-custom-trait",
},
Spec: hv1.HypervisorSpec{
CustomTraits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"},
},
// Status.Traits intentionally empty — trait comes solely from Spec.CustomTraits.
},
}

tests := []struct {
Expand Down Expand Up @@ -392,6 +401,50 @@ func TestFilterExternalCustomerStep_Run(t *testing.T) {
expectedHosts: []string{"host1", "host3"},
filteredHosts: []string{},
},
{
name: "ReserveForCommittedResourceIntent with external customer domain - filter applies",
opts: FilterExternalCustomerStepOpts{
CustomerDomainNamePrefixes: []string{"ext-"},
},
request: api.ExternalSchedulerRequest{
Spec: api.NovaObject[api.NovaSpec]{
Data: api.NovaSpec{
SchedulerHints: map[string]any{
"_nova_check_type": string(api.ReserveForCommittedResourceIntent),
"domain_name": "ext-customer1",
},
},
},
Hosts: []api.ExternalSchedulerHost{
{ComputeHost: "host1"},
{ComputeHost: "host3"},
{ComputeHost: "host4"},
},
},
expectedHosts: []string{"host1"},
filteredHosts: []string{"host3", "host4"},
},
{
name: "Trait from Spec.CustomTraits (not Status.Traits) grants host inclusion",
opts: FilterExternalCustomerStepOpts{
CustomerDomainNamePrefixes: []string{"ext-"},
},
request: api.ExternalSchedulerRequest{
Spec: api.NovaObject[api.NovaSpec]{
Data: api.NovaSpec{
SchedulerHints: map[string]any{
"domain_name": "ext-customer1",
},
},
},
Hosts: []api.ExternalSchedulerHost{
{ComputeHost: "host-custom-trait"},
{ComputeHost: "host4"},
},
},
expectedHosts: []string{"host-custom-trait"},
filteredHosts: []string{"host4"},
},
}

for _, tt := range tests {
Expand Down
11 changes: 11 additions & 0 deletions internal/scheduling/reservations/commitments/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -67,6 +68,16 @@ type ReservationControllerConfig struct {
PipelineDefault string `json:"pipelineDefault"`
// FlavorGroupPipelines maps flavor group IDs to pipeline names; "*" acts as catch-all.
FlavorGroupPipelines map[string]string `json:"flavorGroupPipelines,omitempty"`
// KeystoneSecretRef references a Kubernetes Secret that holds OpenStack credentials
// used to resolve domain IDs to domain names for the domain_name scheduler hint.
// The secret must contain the same keys as the syncer's keystoneSecretRef.
// When empty, domain name resolution is skipped and filter_external_customer will
// not enforce domain restrictions for CR reservations.
KeystoneSecretRef corev1.SecretReference `json:"keystoneSecretRef,omitempty"`
// SSOSecretRef is an optional reference to a Secret holding SSO credentials.
// Required in environments that use SSO-based Keystone authentication.
// When nil, http.DefaultClient is used, which will fail in SSO-only environments.
SSOSecretRef *corev1.SecretReference `json:"ssoSecretRef,omitempty"`
}

// CommittedResourceControllerConfig holds tuning knobs for the CommittedResource CRD controller.
Expand Down
65 changes: 65 additions & 0 deletions internal/scheduling/reservations/commitments/domain_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright SAP SE
// SPDX-License-Identifier: Apache-2.0

package commitments

import (
"context"
"fmt"
"sync"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/domains"
)

// DomainResolver resolves OpenStack domain IDs to their human-readable names.
// Implementations must be safe for concurrent use.
type DomainResolver interface {
// ResolveDomainName returns the name of the domain with the given ID.
// Returns an error if the domain cannot be found or the lookup fails.
ResolveDomainName(ctx context.Context, domainID string) (string, error)
}

// keystoneDomainResolver resolves domain IDs via the Keystone identity API.
// Names are cached indefinitely — domain names are immutable for the lifetime
// of an OpenStack deployment, and the controller is a long-lived process.
type keystoneDomainResolver struct {
sc *gophercloud.ServiceClient
mu sync.RWMutex
cache map[string]string // domainID → name
}

// newKeystoneDomainResolver creates a resolver backed by the given Keystone service client.
// The caller is responsible for authenticating the provider before passing sc here.
func newKeystoneDomainResolver(sc *gophercloud.ServiceClient) *keystoneDomainResolver {
return &keystoneDomainResolver{
sc: sc,
cache: make(map[string]string),
}
}

// ResolveDomainName returns the domain name for domainID, fetching it from Keystone on
// first access and serving subsequent calls from the in-process cache.
func (r *keystoneDomainResolver) ResolveDomainName(ctx context.Context, domainID string) (string, error) {
r.mu.RLock()
if name, ok := r.cache[domainID]; ok {
r.mu.RUnlock()
return name, nil
}
r.mu.RUnlock()

// Upgrade to write-lock. Re-check after acquiring the write-lock to avoid a
// redundant Keystone call when two goroutines race on the same uncached ID.
r.mu.Lock()
defer r.mu.Unlock()
if name, ok := r.cache[domainID]; ok {
return name, nil
}

domain, err := domains.Get(ctx, r.sc, domainID).Extract()
if err != nil {
return "", fmt.Errorf("keystone: failed to resolve domain %q: %w", domainID, err)
}
r.cache[domainID] = domain.Name
return domain.Name, nil
}
Loading