Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ require (
sigs.k8s.io/controller-runtime v0.23.3
)

require (
github.com/databus23/goslo.policy v0.0.0-20250326134918-4afc2c56a903 // indirect
github.com/gofrs/uuid/v5 v5.4.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
)

require (
cel.dev/expr v0.25.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
Expand Down
10 changes: 8 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cobaltcore-dev/openstack-hypervisor-operator v1.0.1 h1:wXolWfljyQQZbxNQ2pZVIw8wFz9BKiDIvLrECsqGDT8=
github.com/cobaltcore-dev/openstack-hypervisor-operator v1.0.1/go.mod h1:b0KmJdxvRI8UXlGe8cRm5BD8Tm2WhF7zSKMSIRGyVL4=
github.com/cobaltcore-dev/openstack-hypervisor-operator v1.0.2-0.20260324155836-56b40c7ff846 h1:Hg5+F1lOUpU9dZ8gVxeohodtYC4Z1fV/iqwYoF/RuNc=
github.com/cobaltcore-dev/openstack-hypervisor-operator v1.0.2-0.20260324155836-56b40c7ff846/go.mod h1:j1SaxUTo0irugdC7aHuYDKEomIPZwCHoz+4kE8EBBGM=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
Expand All @@ -31,6 +29,8 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/databus23/goslo.policy v0.0.0-20250326134918-4afc2c56a903 h1:RiumxYxPww35QeXCGV9NTohc7eGQwlVdz+p3nNHIF28=
github.com/databus23/goslo.policy v0.0.0-20250326134918-4afc2c56a903/go.mod h1:tRj172JgwQmUmEqZZJBWzYWFStitMFTtb95NtUnmpkw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down Expand Up @@ -78,6 +78,8 @@ github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gG
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
Expand All @@ -101,10 +103,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gophercloud/gophercloud/v2 v2.11.1 h1:jCs4vLH8sJgRqrPzqVfWgl7uI6JnIIlsgeIRM0uHjxY=
github.com/gophercloud/gophercloud/v2 v2.11.1/go.mod h1:Rm0YvKQ4QYX2rY9XaDKnjRzSGwlG5ge4h6ABYnmkKQM=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI=
github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ironcore-dev/ironcore v0.2.4 h1:i/RqiMIdzaptuDR6EKSX9hbeolj7AfTuT+4v1ZC4Jeg=
Expand Down
81 changes: 62 additions & 19 deletions internal/scheduling/nova/nova_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/cobaltcore-dev/cortex/pkg/sso"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
"github.com/sapcc/go-bits/liquidapi"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
Expand All @@ -40,17 +41,20 @@ type migration struct {

// ServerDetail contains extended server information for usage reporting.
type ServerDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
TenantID string `json:"tenant_id"`
Created string `json:"created"`
AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"`
Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"`
FlavorName string // Populated from nested flavor.original_name
FlavorRAM uint64 // Populated from nested flavor.ram
FlavorVCPUs uint64 // Populated from nested flavor.vcpus
FlavorDisk uint64 // Populated from nested flavor.disk
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
TenantID string `json:"tenant_id"`
Created string `json:"created"`
AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"`
Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"`
FlavorName string // Populated from nested flavor.original_name
FlavorRAM uint64 // Populated from nested flavor.ram
FlavorVCPUs uint64 // Populated from nested flavor.vcpus
FlavorDisk uint64 // Populated from nested flavor.disk
Metadata map[string]string // Server metadata key-value pairs
Tags []string // Server tags
OSType string // OS type determined by OSTypeProber
}

type NovaClient interface {
Expand All @@ -69,6 +73,8 @@ type NovaClient interface {
type novaClient struct {
// Authenticated OpenStack service client to fetch the data.
sc *gophercloud.ServiceClient
// OS type prober for determining VM operating system type (for billing).
osTypeProber *liquidapi.OSTypeProber
}

func NewNovaClient() NovaClient {
Expand Down Expand Up @@ -109,6 +115,16 @@ func (api *novaClient) Init(ctx context.Context, client client.Client, conf Nova
// We need that to find placement resource providers for hypervisors.
Microversion: "2.53",
}

// Initialize OS type prober for determining VM operating system type.
// Uses existing provider client to access Glance (image) and Cinder (volume) APIs.
eo := gophercloud.EndpointOpts{Availability: gophercloud.Availability(authenticatedKeystone.Availability())}
api.osTypeProber, err = liquidapi.NewOSTypeProber(provider, eo)
if err != nil {
slog.Warn("failed to initialize OS type prober - os_type will be empty", "error", err)
// Non-fatal - continue without OS type probing
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return nil
}

Expand Down Expand Up @@ -203,22 +219,30 @@ func (api *novaClient) ListProjectServers(ctx context.Context, projectID string)
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

// Response structure with nested flavor
// Response structure with nested flavor, metadata, tags, image, and volumes
var list struct {
Servers []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
TenantID string `json:"tenant_id"`
Created string `json:"created"`
AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"`
Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
TenantID string `json:"tenant_id"`
Created string `json:"created"`
AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"`
Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"`
Metadata map[string]string `json:"metadata"`
Tags []string `json:"tags"`
Flavor struct {
OriginalName string `json:"original_name"`
RAM uint64 `json:"ram"`
VCPUs uint64 `json:"vcpus"`
Disk uint64 `json:"disk"`
} `json:"flavor"`
// For OS type probing
Image map[string]any `json:"image"`
AttachedVolumes []struct {
ID string `json:"id"`
DeleteOnTermination bool `json:"delete_on_termination"`
} `json:"os-extended-volumes:volumes_attached"`
} `json:"servers"`
Links []struct {
Rel string `json:"rel"`
Expand All @@ -232,6 +256,22 @@ func (api *novaClient) ListProjectServers(ctx context.Context, projectID string)

// Convert to ServerDetail
for _, s := range list.Servers {
// Probe OS type if prober is available
osType := ""
if api.osTypeProber != nil {
// Build a minimal servers.Server for the prober
vols := make([]servers.AttachedVolume, len(s.AttachedVolumes))
for i, v := range s.AttachedVolumes {
vols[i] = servers.AttachedVolume{ID: v.ID}
}
proberServer := servers.Server{
ID: s.ID,
Image: s.Image,
AttachedVolumes: vols,
}
osType = api.osTypeProber.Get(ctx, proberServer)
}

result = append(result, ServerDetail{
ID: s.ID,
Name: s.Name,
Expand All @@ -244,6 +284,9 @@ func (api *novaClient) ListProjectServers(ctx context.Context, projectID string)
FlavorRAM: s.Flavor.RAM,
FlavorVCPUs: s.Flavor.VCPUs,
FlavorDisk: s.Flavor.Disk,
Metadata: s.Metadata,
Tags: s.Tags,
OSType: osType,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1069,44 +1069,21 @@ func (env *CommitmentTestEnv) LogStateSummary() {
}

// CallChangeCommitmentsAPI calls the change commitments API endpoint with JSON.
// It uses a hybrid approach: fast polling during API execution + synchronous final pass.
// Reservation processing is fully synchronous via operationInterceptorClient hooks.
func (env *CommitmentTestEnv) CallChangeCommitmentsAPI(reqJSON string) (resp liquid.CommitmentChangeResponse, respJSON string, statusCode int) {
env.T.Helper()

// Start fast polling in background to handle reservations during API execution
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})

go func() {
ticker := time.NewTicker(5 * time.Millisecond) // Very fast - 5ms
defer ticker.Stop()
defer close(done)

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
env.processReservations()
}
}
}()

// Make HTTP request
// Make HTTP request - reservation processing happens synchronously via Create/Delete hooks
url := env.HTTPServer.URL + "/commitments/v1/change-commitments"
httpResp, err := http.Post(url, "application/json", bytes.NewReader([]byte(reqJSON))) //nolint:gosec,noctx // test server URL, not user input
if err != nil {
cancel()
<-done
env.T.Fatalf("Failed to make HTTP request: %v", err)
}
defer httpResp.Body.Close()

// Read response body
respBytes, err := io.ReadAll(httpResp.Body)
if err != nil {
cancel()
<-done
env.T.Fatalf("Failed to read response body: %v", err)
}

Expand All @@ -1116,18 +1093,11 @@ func (env *CommitmentTestEnv) CallChangeCommitmentsAPI(reqJSON string) (resp liq
// Non-200 responses (like 409 Conflict for version mismatch) use plain text via http.Error()
if httpResp.StatusCode == http.StatusOK {
if err := json.Unmarshal(respBytes, &resp); err != nil {
cancel()
<-done
env.T.Fatalf("Failed to unmarshal response: %v", err)
}
}

// Stop background polling
cancel()
<-done

// Final synchronous pass to ensure all reservations are processed
// This eliminates any race conditions
// Final pass to handle any deletions (finalizer removal)
env.processReservations()

statusCode = httpResp.StatusCode
Expand Down
88 changes: 62 additions & 26 deletions internal/scheduling/reservations/commitments/api_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ type resourceAttributes struct {
}

// buildServiceInfo constructs the ServiceInfo response with metadata for all flavor groups.
// For each flavor group that accepts commitments, three resources are registered:
// - _ram: RAM resource (unit = multiples of smallest flavor RAM, HandlesCommitments=true)
// - _cores: CPU cores resource (unit = 1, HandlesCommitments=false)
// - _instances: Instance count resource (unit = 1, HandlesCommitments=false)
func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (liquid.ServiceInfo, error) {
// Get all flavor groups from Knowledge CRDs
knowledge := &reservations.FlavorGroupKnowledgeClient{Client: api.client}
Expand All @@ -107,67 +111,99 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l
// Build resources map
resources := make(map[liquid.ResourceName]liquid.ResourceInfo)
for groupName, groupData := range flavorGroups {
resourceName := liquid.ResourceName(ResourceNameFromFlavorGroup(groupName))
// Only handle commitments for groups with a fixed RAM/core ratio
handlesCommitments := FlavorGroupAcceptsCommitments(&groupData)
if !handlesCommitments {
continue // Skip groups that don't accept commitments
}

flavorNames := make([]string, 0, len(groupData.Flavors))
for _, flavor := range groupData.Flavors {
flavorNames = append(flavorNames, flavor.Name)
}
displayName := fmt.Sprintf(
"multiples of %d MiB (usable by: %s)",
groupData.SmallestFlavor.MemoryMB,
strings.Join(flavorNames, ", "),
)

// Only handle commitments for groups with a fixed RAM/core ratio
handlesCommitments := FlavorGroupAcceptsCommitments(&groupData)
flavorListStr := strings.Join(flavorNames, ", ")

// Build attributes JSON with ratio info
// Build attributes JSON with ratio info (shared across all resource types)
attrs := resourceAttributes{
RamCoreRatio: groupData.RamCoreRatio,
RamCoreRatioMin: groupData.RamCoreRatioMin,
RamCoreRatioMax: groupData.RamCoreRatioMax,
}
attrsJSON, err := json.Marshal(attrs)
if err != nil {
logger.Error(err, "failed to marshal resource attributes", "resourceName", resourceName)
logger.Error(err, "failed to marshal resource attributes", "flavorGroup", groupName)
attrsJSON = nil
}

// Build unit from smallest flavor memory (e.g., "131072 MiB" for 128 GiB)
// Validate memory is positive to avoid panic in MultiplyBy (which panics on factor=0)
if groupData.SmallestFlavor.MemoryMB == 0 {
return liquid.ServiceInfo{}, fmt.Errorf("%w: flavor group %q has invalid smallest flavor with memoryMB=0",
errInternalServiceInfo, groupName)
}
unit, err := liquid.UnitMebibytes.MultiplyBy(groupData.SmallestFlavor.MemoryMB)

// === 1. RAM Resource ===
ramResourceName := liquid.ResourceName(ResourceNameRAM(groupName))
ramUnit, err := liquid.UnitMebibytes.MultiplyBy(groupData.SmallestFlavor.MemoryMB)
if err != nil {
// Note: This error only occurs on uint64 overflow, which is unrealistic for memory values
return liquid.ServiceInfo{}, fmt.Errorf("%w: failed to create unit for flavor group %q: %w",
errInternalServiceInfo, groupName, err)
}

resources[resourceName] = liquid.ResourceInfo{
DisplayName: displayName,
Unit: unit, // Non-standard unit: multiples of smallest flavor RAM
resources[ramResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"multiples of %d MiB (usable by: %s)",
groupData.SmallestFlavor.MemoryMB,
flavorListStr,
),
Unit: ramUnit, // Non-standard unit: multiples of smallest flavor RAM
Topology: liquid.AZAwareTopology, // Commitments are per-AZ
NeedsResourceDemand: false, // Capacity planning out of scope for now
HasCapacity: handlesCommitments, // We report capacity via /commitments/v1/report-capacity only for groups that accept commitments
HasCapacity: true, // We report capacity via /commitments/v1/report-capacity
HasQuota: false, // No quota enforcement as of now
HandlesCommitments: handlesCommitments, // Only for groups with fixed RAM/core ratio
HandlesCommitments: true, // RAM is the primary commitment resource
Attributes: attrsJSON,
}

logger.V(1).Info("registered flavor group resource",
"resourceName", resourceName,
// === 2. Cores Resource ===
coresResourceName := liquid.ResourceName(ResourceNameCores(groupName))
resources[coresResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"CPU cores (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone, // Unit = 1 (count of cores)
Topology: liquid.AZAwareTopology, // Same topology as RAM
NeedsResourceDemand: false,
HasCapacity: true, // We report capacity (as 0 for now)
HasQuota: false, // No quota enforcement
HandlesCommitments: false, // Cores are derived from RAM commitments
Attributes: attrsJSON, // Same attributes (ratio info)
}

// === 3. Instances Resource ===
instancesResourceName := liquid.ResourceName(ResourceNameInstances(groupName))
resources[instancesResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"instances (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone, // Unit = 1 (count of instances)
Topology: liquid.AZAwareTopology, // Same topology as RAM
NeedsResourceDemand: false,
HasCapacity: true, // We report capacity (as 0 for now)
HasQuota: false, // No quota enforcement
HandlesCommitments: false, // Instances are derived from RAM commitments
Attributes: attrsJSON, // Same attributes
}

logger.V(1).Info("registered flavor group resources",
"flavorGroup", groupName,
"displayName", displayName,
"ramResource", ramResourceName,
"coresResource", coresResourceName,
"instancesResource", instancesResourceName,
"smallestFlavor", groupData.SmallestFlavor.Name,
"smallestRamMB", groupData.SmallestFlavor.MemoryMB,
"handlesCommitments", handlesCommitments,
"ramCoreRatio", groupData.RamCoreRatio,
"ramCoreRatioMin", groupData.RamCoreRatioMin,
"ramCoreRatioMax", groupData.RamCoreRatioMax)
"ramCoreRatio", groupData.RamCoreRatio)
}

// Get last content changed from flavor group knowledge and treat it as version
Expand Down
Loading
Loading