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
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
92 changes: 73 additions & 19 deletions internal/scheduling/nova/nova_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package nova
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
Expand All @@ -14,6 +15,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 +42,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 +74,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 +116,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
}

return nil
}

Expand Down Expand Up @@ -180,6 +197,9 @@ func (api *novaClient) GetServerMigrations(ctx context.Context, id string) ([]mi

// ListProjectServers retrieves all servers for a project with detailed info.
func (api *novaClient) ListProjectServers(ctx context.Context, projectID string) ([]ServerDetail, error) {
if api.sc == nil {
return nil, errors.New("nova client not initialized - call Init first")
}
// Build URL with pagination support
initialURL := api.sc.Endpoint + "servers/detail?all_tenants=true&tenant_id=" + projectID
var nextURL = &initialURL
Expand All @@ -203,22 +223,31 @@ 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 - use json.RawMessage because Nova returns
// either a map (for image-booted VMs) or empty string "" (for volume-booted VMs)
Image json.RawMessage `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 +261,28 @@ 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 {
// Parse image field - Nova returns either a map or empty string ""
var imageMap map[string]any
if len(s.Image) > 0 && s.Image[0] == '{' {
// Intentionally ignore parse errors - imageMap will remain nil for volume-booted VMs
json.Unmarshal(s.Image, &imageMap) //nolint:errcheck // error expected for non-JSON values
}
// 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: imageMap,
AttachedVolumes: vols,
}
osType = api.osTypeProber.Get(ctx, proberServer)
}

result = append(result, ServerDetail{
ID: s.ID,
Name: s.Name,
Expand All @@ -244,6 +295,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
Loading
Loading