diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddb49d00..8f94c487d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 2026-07-03 — [#1009](https://github.com/cobaltcore-dev/cortex/pull/1009) + +### cortex v0.2.2 (sha-f4d7df89) + +Non-breaking changes: +- Include host information in error logs when multicluster connection fails ([#997](https://github.com/cobaltcore-dev/cortex/pull/997)) +- Fix Perses config for local development ([#1007](https://github.com/cobaltcore-dev/cortex/pull/1007)) +- Update `github.com/sapcc/go-bits` ([#1005](https://github.com/cobaltcore-dev/cortex/pull/1005)) +- Update kube-prometheus-stack Docker tag to v87.4.0 ([#998](https://github.com/cobaltcore-dev/cortex/pull/998)) + +### cortex-shim v0.1.5 (sha-c98ddb05) + +Includes updated image sha-c98ddb05. + +### cortex-nova v0.0.79 + +Includes updated charts cortex v0.2.2, cortex-postgres v0.6.8. + +### cortex-cinder v0.0.79 + +Includes updated charts cortex v0.2.2, cortex-postgres v0.6.8. + +### cortex-manila v0.0.79 + +Includes updated charts cortex v0.2.2, cortex-postgres v0.6.8. + +### cortex-crds v0.0.79 + +Includes updated chart cortex v0.2.2. + +### cortex-ironcore v0.0.79 + +Includes updated chart cortex v0.2.2. + +### cortex-pods v0.0.79 + +Includes updated chart cortex v0.2.2. + +### cortex-placement-shim v0.1.5 + +Includes updated chart cortex-shim v0.1.5. + ## 2026-07-01 — [#1001](https://github.com/cobaltcore-dev/cortex/pull/1001) ### cortex v0.2.1 (sha-44b8aab7) diff --git a/api/v1alpha1/datasource_types.go b/api/v1alpha1/datasource_types.go index f9963a35c..fff321c48 100644 --- a/api/v1alpha1/datasource_types.go +++ b/api/v1alpha1/datasource_types.go @@ -52,7 +52,6 @@ const ( NovaDatasourceTypeFlavors NovaDatasourceType = "flavors" NovaDatasourceTypeMigrations NovaDatasourceType = "migrations" NovaDatasourceTypeAggregates NovaDatasourceType = "aggregates" - NovaDatasourceTypeImages NovaDatasourceType = "images" ) type NovaDatasource struct { diff --git a/docs/reservations/failover-reservations.md b/docs/reservations/failover-reservations.md index c2af22a8e..cd642d46c 100644 --- a/docs/reservations/failover-reservations.md +++ b/docs/reservations/failover-reservations.md @@ -26,15 +26,36 @@ The controller has two reconciliation modes: ```mermaid flowchart TD P1[List Hypervisors from K8s] + P1b["Build active-VM set from
Hypervisor CRD Status.Instances"] P2["List VMs from Postgres
(vm_source.go)"] - P3["Remove Invalid VMs from reservations
(e.g., vm:host mapping wrong or vm deleted)"] + P3["Remove Invalid VMs from reservations
(e.g., vm:host mapping wrong or vm deleted)
with postgres data-loss safeguard"] P4["Remove Non-eligible VMs from reservations
(via eligibility rules, reservation_eligibility.go)"] P5[Delete Empty Reservations] P6["Create/Assign Reservations
(reservation_scheduling.go)"] - P1 --> P2 --> P3 --> P4 --> P5 --> P6 + P1 --> P1b --> P2 --> P3 --> P4 --> P5 --> P6 ``` +#### Postgres Data-Loss Safeguard + +Before removing a VM from a failover reservation because it is missing from the +postgres-derived VM source, the controller cross-checks the Hypervisor CRD +`Status.Instances`. If the VM is still reported as active on any hypervisor, its +allocation is preserved in the reservation. + +This safeguard prevents a postgres data loss or restore event from cascading into +the mass deletion of all failover reservations. Without it, a wiped or partially +restored Nova database would make every VM appear "deleted," causing the +controller to empty and then garbage-collect all reservations -- leaving the +entire fleet without failover coverage until postgres recovers and the +reservations are rebuilt. + +The active-VM set (`vmsOnHypervisor`) is built once per reconciliation cycle by +iterating over all Hypervisor CRD `Status.Instances` entries that are marked +active. During the "Remove Invalid VMs" step, if a VM UUID is absent from the +postgres VM list but present in this set, the allocation is kept and a log +message is emitted. + ### Watch-based Reconciliation diff --git a/go.mod b/go.mod index 97ef930e1..eff248f05 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/ironcore-dev/ironcore v0.4.2 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/sapcc/go-bits v0.0.0-20260626143732-3999ce9f8fdc + github.com/sapcc/go-bits v0.0.0-20260703101005-27edea94477c go.xyrillian.de/gg v1.11.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 diff --git a/go.sum b/go.sum index 8941058a5..111d07a4f 100644 --- a/go.sum +++ b/go.sum @@ -204,8 +204,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sapcc/go-api-declarations v1.24.0 h1:sGBvOMVSM1olJlyvNoQSk7NX5uatXHKkztGDBPnTWMs= github.com/sapcc/go-api-declarations v1.24.0/go.mod h1:ZWRTijvgF8o8aHg5stgg7u4DF6jFrd0X97le/uGlZsA= -github.com/sapcc/go-bits v0.0.0-20260626143732-3999ce9f8fdc h1:oflzbbZ1C2tc0EMCwf5tETd6nlqA5TMnckihJqz7joU= -github.com/sapcc/go-bits v0.0.0-20260626143732-3999ce9f8fdc/go.mod h1:WIr0qdewi9zEDh4egX9c7zB4e6JwrzTS3AA02y0EVxk= +github.com/sapcc/go-bits v0.0.0-20260703101005-27edea94477c h1:jqVSQNGIZ+V7mP+bjKoz/OEVy5qWoGvaYCfwXfohEWw= +github.com/sapcc/go-bits v0.0.0-20260703101005-27edea94477c/go.mod h1:uchHF8FNxncTCoQYDjGSO86AuxSzgIbN5mQzM95RYOA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= diff --git a/helm/bundles/cortex-cinder/Chart.yaml b/helm/bundles/cortex-cinder/Chart.yaml index dfaa6cdce..bec946fca 100644 --- a/helm/bundles/cortex-cinder/Chart.yaml +++ b/helm/bundles/cortex-cinder/Chart.yaml @@ -5,23 +5,23 @@ apiVersion: v2 name: cortex-cinder description: A Helm chart deploying Cortex for Cinder. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.7 + version: 0.6.8 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-crds/Chart.yaml b/helm/bundles/cortex-crds/Chart.yaml index 8f6b4cda1..b0402277a 100644 --- a/helm/bundles/cortex-crds/Chart.yaml +++ b/helm/bundles/cortex-crds/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-crds description: A Helm chart deploying Cortex CRDs. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/bundles/cortex-ironcore/Chart.yaml b/helm/bundles/cortex-ironcore/Chart.yaml index 762ee557c..0b91af43e 100644 --- a/helm/bundles/cortex-ironcore/Chart.yaml +++ b/helm/bundles/cortex-ironcore/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-ironcore description: A Helm chart deploying Cortex for IronCore. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/bundles/cortex-manila/Chart.yaml b/helm/bundles/cortex-manila/Chart.yaml index 76be70c05..b1f69a0d2 100644 --- a/helm/bundles/cortex-manila/Chart.yaml +++ b/helm/bundles/cortex-manila/Chart.yaml @@ -5,23 +5,23 @@ apiVersion: v2 name: cortex-manila description: A Helm chart deploying Cortex for Manila. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.7 + version: 0.6.8 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-nova/Chart.yaml b/helm/bundles/cortex-nova/Chart.yaml index b803122b4..ebd6c4de4 100644 --- a/helm/bundles/cortex-nova/Chart.yaml +++ b/helm/bundles/cortex-nova/Chart.yaml @@ -5,23 +5,23 @@ apiVersion: v2 name: cortex-nova description: A Helm chart deploying Cortex for Nova. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.7 + version: 0.6.8 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-nova/templates/datasources.yaml b/helm/bundles/cortex-nova/templates/datasources.yaml index 582effac2..f9160602f 100644 --- a/helm/bundles/cortex-nova/templates/datasources.yaml +++ b/helm/bundles/cortex-nova/templates/datasources.yaml @@ -337,30 +337,6 @@ spec: --- apiVersion: cortex.cloud/v1alpha1 kind: Datasource -metadata: - name: nova-images -spec: - schedulingDomain: nova - databaseSecretRef: - name: cortex-nova-postgres - namespace: {{ .Release.Namespace }} - {{- if .Values.openstack.sso.enabled }} - ssoSecretRef: - name: cortex-nova-openstack-sso - namespace: {{ .Release.Namespace }} - {{- end }} - type: openstack - openstack: - syncInterval: 3600s - secretRef: - name: cortex-nova-openstack-keystone - namespace: {{ .Release.Namespace }} - type: nova - nova: - type: images ---- -apiVersion: cortex.cloud/v1alpha1 -kind: Datasource metadata: name: limes-project-commitments spec: diff --git a/helm/bundles/cortex-placement-shim/Chart.yaml b/helm/bundles/cortex-placement-shim/Chart.yaml index 95ecb8cec..07e09aab7 100644 --- a/helm/bundles/cortex-placement-shim/Chart.yaml +++ b/helm/bundles/cortex-placement-shim/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-placement-shim description: A Helm chart deploying the Cortex placement shim. type: application -version: 0.1.4 +version: 0.1.5 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-shim - name: cortex-shim repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.4 + version: 0.1.5 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case # of issues. See: https://github.com/sapcc/helm-charts/pkgs/container/helm-charts%2Fowner-info diff --git a/helm/bundles/cortex-pods/Chart.yaml b/helm/bundles/cortex-pods/Chart.yaml index 6a773d921..6bbcf92ce 100644 --- a/helm/bundles/cortex-pods/Chart.yaml +++ b/helm/bundles/cortex-pods/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-pods description: A Helm chart deploying Cortex for Pods. type: application -version: 0.0.78 +version: 0.0.79 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.2.1 + version: 0.2.2 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/dev/cortex-prometheus-operator/Chart.yaml b/helm/dev/cortex-prometheus-operator/Chart.yaml index 59859b611..7e2d4dbba 100644 --- a/helm/dev/cortex-prometheus-operator/Chart.yaml +++ b/helm/dev/cortex-prometheus-operator/Chart.yaml @@ -10,4 +10,4 @@ dependencies: # CRDs of the prometheus operator, such as PrometheusRule, ServiceMonitor, etc. - name: kube-prometheus-stack repository: oci://ghcr.io/prometheus-community/charts - version: 87.3.0 + version: 87.10.1 diff --git a/helm/library/cortex-postgres/Chart.yaml b/helm/library/cortex-postgres/Chart.yaml index 207c80572..b64823e29 100644 --- a/helm/library/cortex-postgres/Chart.yaml +++ b/helm/library/cortex-postgres/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: cortex-postgres description: Postgres setup for Cortex. type: application -version: 0.6.7 +version: 0.6.8 appVersion: "sha-af707446" diff --git a/helm/library/cortex-shim/Chart.yaml b/helm/library/cortex-shim/Chart.yaml index f221a68b6..0e6b0ddfb 100644 --- a/helm/library/cortex-shim/Chart.yaml +++ b/helm/library/cortex-shim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: cortex-shim description: A Helm chart to distribute cortex shims. type: application -version: 0.1.4 -appVersion: "sha-44b8aab7" +version: 0.1.5 +appVersion: "sha-c8295850" icon: "https://example.com/icon.png" dependencies: [] diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index 5d1b9bdef..b746c98f3 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: cortex description: A Helm chart to distribute cortex. type: application -version: 0.2.1 -appVersion: "sha-44b8aab7" +version: 0.2.2 +appVersion: "sha-8d888397" icon: "https://example.com/icon.png" dependencies: [] diff --git a/internal/knowledge/datasources/plugins/openstack/controller_test.go b/internal/knowledge/datasources/plugins/openstack/controller_test.go index 586238d54..899e83237 100644 --- a/internal/knowledge/datasources/plugins/openstack/controller_test.go +++ b/internal/knowledge/datasources/plugins/openstack/controller_test.go @@ -104,7 +104,6 @@ func TestNovaDatasourceTypeConstants(t *testing.T) { {v1alpha1.NovaDatasourceTypeFlavors, "flavors"}, {v1alpha1.NovaDatasourceTypeMigrations, "migrations"}, {v1alpha1.NovaDatasourceTypeAggregates, "aggregates"}, - {v1alpha1.NovaDatasourceTypeImages, "images"}, } for _, test := range tests { diff --git a/internal/knowledge/datasources/plugins/openstack/nova/nova_api.go b/internal/knowledge/datasources/plugins/openstack/nova/nova_api.go index 906a46a1e..b7e25c2ba 100644 --- a/internal/knowledge/datasources/plugins/openstack/nova/nova_api.go +++ b/internal/knowledge/datasources/plugins/openstack/nova/nova_api.go @@ -10,18 +10,15 @@ import ( "log/slog" "net/http" "net/url" - "strings" "time" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources" "github.com/cobaltcore-dev/cortex/pkg/keystone" "github.com/gophercloud/gophercloud/v2" - "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/aggregates" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" - glanceimages "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/prometheus/client_golang/prometheus" "github.com/sapcc/go-bits/liquidapi" @@ -43,8 +40,6 @@ type NovaAPI interface { GetAllMigrations(ctx context.Context) ([]Migration, error) // Get all aggregates. GetAllAggregates(ctx context.Context) ([]Aggregate, error) - // Get all Glance images with pre-computed os_type. - GetAllImages(ctx context.Context) ([]Image, error) } // API for OpenStack Nova. @@ -57,8 +52,6 @@ type novaAPI struct { conf v1alpha1.NovaDatasource // Authenticated OpenStack compute service client. sc *gophercloud.ServiceClient - // Authenticated Glance image service client (only used for NovaDatasourceTypeImages). - glance *gophercloud.ServiceClient // OS type prober for determining VM operating system type (only for NovaDatasourceTypeServers). osTypeProber *liquidapi.OSTypeProber } @@ -90,16 +83,6 @@ func (api *novaAPI) Init(ctx context.Context) error { // Since 2.61, the extra_specs are returned in the flavor details. Microversion: "2.61", } - // Initialize the Glance client only when this datasource is used for images. - if api.conf.Type == v1alpha1.NovaDatasourceTypeImages { - glanceClient, err := openstack.NewImageV2(provider, gophercloud.EndpointOpts{ - Availability: gophercloud.Availability(sameAsKeystone), - }) - if err != nil { - return fmt.Errorf("failed to create Glance client: %w", err) - } - api.glance = glanceClient - } // Initialize the OS type prober only for the servers datasource. if api.conf.Type == v1alpha1.NovaDatasourceTypeServers { eo := gophercloud.EndpointOpts{Availability: gophercloud.Availability(sameAsKeystone)} @@ -510,75 +493,6 @@ func (api *novaAPI) GetAllAggregates(ctx context.Context) ([]Aggregate, error) { return aggregates, nil } -// GetAllImages fetches all Glance images and returns them with pre-computed os_type. -// See deriveOSType for the derivation logic. -func (api *novaAPI) GetAllImages(ctx context.Context) ([]Image, error) { - if api.glance == nil { - return nil, fmt.Errorf("glance client not initialized: datasource type must be %q", v1alpha1.NovaDatasourceTypeImages) - } - - label := Image{}.TableName() - slog.Info("fetching nova data", "label", label) - if api.mon.RequestTimer != nil { - hist := api.mon.RequestTimer.WithLabelValues(label) - timer := prometheus.NewTimer(hist) - defer timer.ObserveDuration() - } - - var result []Image - opts := glanceimages.ListOpts{Limit: 1000} - err := glanceimages.List(api.glance, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { - imgs, err := glanceimages.ExtractImages(page) - if err != nil { - return false, err - } - for _, img := range imgs { - result = append(result, Image{ - ID: img.ID, - OSType: deriveOSType(img.Properties, img.Tags), - }) - } - return true, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to list Glance images: %w", err) - } - slog.Info("fetched", "label", label, "count", len(result)) - return result, nil -} - -// deriveOSType computes os_type from image properties and tags. -// Mirrors the logic of OSTypeProber.findFromImage in github.com/sapcc/go-bits/liquidapi, -// with two intentional simplifications: -// 1. No regex validation on vmware_ostype — Nova validates that field at VM boot time, -// so any value stored in Glance is already valid. -// 2. Volume-booted VMs are not yet supported — os_type will be "unknown" for them. -// Supporting them would require per-VM Cinder calls (volume_image_metadata.vmware_ostype) -// either at server sync time or via a dedicated datasource. -func deriveOSType(properties map[string]any, tags []string) string { - if v, ok := properties["vmware_ostype"]; ok { - if s, ok := v.(string); ok && s != "" { - return s - } - } - var osType string - for _, tag := range tags { - if after, ok := strings.CutPrefix(tag, "ostype:"); ok { - if osType == "" { - osType = after - } else { - // multiple ostype: tags → ambiguous, fall through to unknown - osType = "" - break - } - } - } - if osType != "" { - return osType - } - return "unknown" -} - // initOSTypeProber safely creates an OSTypeProber, returning nil on any error or panic. func initOSTypeProber(provider *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (prober *liquidapi.OSTypeProber) { defer func() { diff --git a/internal/knowledge/datasources/plugins/openstack/nova/nova_api_test.go b/internal/knowledge/datasources/plugins/openstack/nova/nova_api_test.go index 63f83c176..49f0af4b4 100644 --- a/internal/knowledge/datasources/plugins/openstack/nova/nova_api_test.go +++ b/internal/knowledge/datasources/plugins/openstack/nova/nova_api_test.go @@ -538,66 +538,6 @@ func TestNovaAPI_GetAllHypervisors_DeduplicatesHypervisors(t *testing.T) { } } -func TestDeriveOSType(t *testing.T) { - tests := []struct { - name string - properties map[string]any - tags []string - want string - }{ - { - name: "vmware_ostype property wins", - properties: map[string]any{"vmware_ostype": "windows8Server64Guest"}, - tags: []string{"ostype:linux"}, - want: "windows8Server64Guest", - }, - { - name: "vmware_ostype empty string falls through to tags", - properties: map[string]any{"vmware_ostype": ""}, - tags: []string{"ostype:debian"}, - want: "debian", - }, - { - name: "vmware_ostype not a string falls through", - properties: map[string]any{"vmware_ostype": 42}, - tags: []string{"ostype:centos"}, - want: "centos", - }, - { - name: "single ostype tag", - properties: map[string]any{}, - tags: []string{"ostype:ubuntu", "env:prod"}, - want: "ubuntu", - }, - { - name: "multiple ostype tags: ambiguous, returns unknown", - properties: map[string]any{}, - tags: []string{"ostype:ubuntu", "ostype:debian"}, - want: "unknown", - }, - { - name: "no properties, no tags", - properties: map[string]any{}, - tags: nil, - want: "unknown", - }, - { - name: "tags without ostype prefix", - properties: map[string]any{}, - tags: []string{"env:prod", "region:eu"}, - want: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := deriveOSType(tt.properties, tt.tags); got != tt.want { - t.Errorf("deriveOSType() = %q, want %q", got, tt.want) - } - }) - } -} - func TestNovaAPI_GetAllMigrations_DeduplicatesMigrations(t *testing.T) { tests := []struct { name string diff --git a/internal/knowledge/datasources/plugins/openstack/nova/nova_sync.go b/internal/knowledge/datasources/plugins/openstack/nova/nova_sync.go index ec0533f84..a47449c27 100644 --- a/internal/knowledge/datasources/plugins/openstack/nova/nova_sync.go +++ b/internal/knowledge/datasources/plugins/openstack/nova/nova_sync.go @@ -46,8 +46,6 @@ func (s *NovaSyncer) Init(ctx context.Context) error { tables = append(tables, s.DB.AddTable(Migration{})) case v1alpha1.NovaDatasourceTypeAggregates: tables = append(tables, s.DB.AddTable(Aggregate{})) - case v1alpha1.NovaDatasourceTypeImages: - tables = append(tables, s.DB.AddTable(Image{})) } return s.DB.CreateTable(tables...) } @@ -70,8 +68,6 @@ func (s *NovaSyncer) Sync(ctx context.Context) (int64, error) { nResults, err = s.SyncAllMigrations(ctx) case v1alpha1.NovaDatasourceTypeAggregates: nResults, err = s.SyncAllAggregates(ctx) - case v1alpha1.NovaDatasourceTypeImages: - nResults, err = s.SyncAllImages(ctx) } return nResults, err } @@ -202,26 +198,6 @@ func (s *NovaSyncer) SyncAllMigrations(ctx context.Context) (int64, error) { return int64(len(allMigrations)), nil } -// Sync all Glance images into the database with pre-computed os_type. -func (s *NovaSyncer) SyncAllImages(ctx context.Context) (int64, error) { - allImages, err := s.API.GetAllImages(ctx) - if err != nil { - return 0, err - } - err = db.ReplaceAll(s.DB, allImages...) - if err != nil { - return 0, err - } - label := Image{}.TableName() - if s.Mon.ObjectsGauge != nil { - s.Mon.ObjectsGauge.WithLabelValues(label).Set(float64(len(allImages))) - } - if s.Mon.RequestProcessedCounter != nil { - s.Mon.RequestProcessedCounter.WithLabelValues(label).Inc() - } - return int64(len(allImages)), nil -} - // Sync the OpenStack aggregates into the database. func (s *NovaSyncer) SyncAllAggregates(ctx context.Context) (int64, error) { allAggregates, err := s.API.GetAllAggregates(ctx) diff --git a/internal/knowledge/datasources/plugins/openstack/nova/nova_sync_test.go b/internal/knowledge/datasources/plugins/openstack/nova/nova_sync_test.go index fdd826171..9761b9a93 100644 --- a/internal/knowledge/datasources/plugins/openstack/nova/nova_sync_test.go +++ b/internal/knowledge/datasources/plugins/openstack/nova/nova_sync_test.go @@ -55,10 +55,6 @@ func (m *mockNovaAPI) GetAllAggregates(ctx context.Context) ([]Aggregate, error) return []Aggregate{{Name: "aggregate1"}}, nil } -func (m *mockNovaAPI) GetAllImages(ctx context.Context) ([]Image, error) { - return []Image{{ID: "img-1", OSType: "windows8Server64Guest"}}, nil -} - func TestNovaSyncer_Init(t *testing.T) { dbEnv := testlibDB.SetupDBEnv(t) testDB := db.DB{DbMap: dbEnv.DbMap} @@ -271,35 +267,3 @@ func TestNovaSyncer_SyncAggregates(t *testing.T) { t.Fatalf("expected 1 aggregate, got %d", n) } } - -func TestNovaSyncer_SyncImages(t *testing.T) { - dbEnv := testlibDB.SetupDBEnv(t) - testDB := db.DB{DbMap: dbEnv.DbMap} - defer dbEnv.Close() - mon := datasources.Monitor{} - syncer := &NovaSyncer{ - DB: testDB, - Mon: mon, - Conf: v1alpha1.NovaDatasource{Type: v1alpha1.NovaDatasourceTypeImages}, - API: &mockNovaAPI{}, - } - - ctx := t.Context() - if err := syncer.Init(ctx); err != nil { - t.Fatalf("failed to init images syncer: %v", err) - } - n, err := syncer.Sync(ctx) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if n != 1 { - t.Fatalf("expected 1 image, got %d", n) - } - var images []Image - if _, err := testDB.Select(&images, "SELECT * FROM "+Image{}.TableName()); err != nil { - t.Fatalf("select images: %v", err) - } - if len(images) != 1 || images[0].ID != "img-1" || images[0].OSType != "windows8Server64Guest" { - t.Errorf("unexpected images in DB: %+v", images) - } -} diff --git a/internal/knowledge/datasources/plugins/openstack/nova/nova_types.go b/internal/knowledge/datasources/plugins/openstack/nova/nova_types.go index 17c422194..544b74572 100644 --- a/internal/knowledge/datasources/plugins/openstack/nova/nova_types.go +++ b/internal/knowledge/datasources/plugins/openstack/nova/nova_types.go @@ -512,17 +512,3 @@ func (Aggregate) TableName() string { return "openstack_aggregates_v2" } // Index for the openstack model. func (Aggregate) Indexes() map[string][]string { return nil } - -// Image stores pre-computed os_type for a Glance image UUID. -// Populated by the NovaDatasourceTypeImages syncer from the Glance API. -// Used by the CR usage API to include os_type in VM subresources without live API calls. -type Image struct { - ID string `json:"id" db:"id,primarykey"` - OSType string `json:"os_type" db:"os_type"` -} - -// Table in which the openstack model is stored. -func (Image) TableName() string { return "openstack_images" } - -// Index for the openstack model. -func (Image) Indexes() map[string][]string { return nil } diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index fd71f5a05..1e69b22cb 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -150,6 +150,16 @@ func (c *Controller) computeVMUsage( return result } + // Pre-populate all (flavorGroup, AZ) pairs with fresh=true and zero resources. + // This ensures that groups with no running VMs still get fresh=true so that + // RunningInstances/RunningResources are correctly zeroed out in the CRD. + azs := availabilityZones(hvs) + for groupName := range flavorGroups { + for _, az := range azs { + result[vmUsageKey{group: groupName, az: az}] = vmUsage{fresh: true} + } + } + flavorToGroup := make(map[string]string) flavorMemBytes := make(map[string]int64) flavorVCPUs := make(map[string]int64) diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index fe026416b..c33d98be4 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -678,6 +678,135 @@ func TestSumCommittedCapacity(t *testing.T) { // TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored verifies that placeable-probe // slot counting subtracts per-host reservation blocks in addition to hv.Status.Allocation. +// mockVMSource is a test implementation of VMSource that returns a fixed list of VMs. +type mockVMSource struct { + vms []reservations.VM + err error +} + +func (m *mockVMSource) ListVMs(_ context.Context) ([]reservations.VM, error) { + return m.vms, m.err +} + +func (m *mockVMSource) ListVMsByProject(_ context.Context, _ string) ([]reservations.VM, error) { + return m.vms, m.err +} + +func (m *mockVMSource) ListVMsOnHypervisors(_ context.Context, _ *hv1.HypervisorList, _ bool) ([]reservations.VM, error) { + return m.vms, m.err +} + +func (m *mockVMSource) GetVM(_ context.Context, _ string) (*reservations.VM, error) { + return nil, nil +} + +func (m *mockVMSource) IsServerActive(_ context.Context, _ string) (bool, error) { + return false, nil +} + +func (m *mockVMSource) GetDeletedVMInfo(_ context.Context, _ string) (*reservations.DeletedVMInfo, error) { + return nil, nil +} + +// TestComputeVMUsage_ZerosOutWhenAllVMsRemoved verifies that after a successful +// ListVMsOnHypervisors call that returns no VMs for a (flavorGroup, AZ) pair, +// the result map entry has fresh=true with zero instances/resources, ensuring +// RunningInstances and RunningResources are zeroed out in the CRD. +func TestComputeVMUsage_ZerosOutWhenAllVMsRemoved(t *testing.T) { + const ( + groupName = "hana-v2" + az = "qa-de-1a" + memMB = 4096 + memBytes = int64(memMB) * 1024 * 1024 + ) + + scheme := newTestScheme(t) + hv := newHypervisor("host-1", az, memBytes, "vm1") + knowledge := newFlavorGroupKnowledge(t, groupName, memMB) + + // Pre-create CRD with non-zero RunningInstances to simulate prior state. + crdName := crdNameFor(groupName, az) + existing := &v1alpha1.FlavorGroupCapacity{ + ObjectMeta: metav1.ObjectMeta{Name: crdName}, + Spec: v1alpha1.FlavorGroupCapacitySpec{ + FlavorGroup: groupName, + AvailabilityZone: az, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(knowledge, hv, existing). + WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}, &v1alpha1.Knowledge{}). + Build() + + // Set RunningInstances to non-zero via a status patch. + patch := client.MergeFrom(existing.DeepCopy()) + existing.Status.RunningInstances = 5 + existing.Status.RunningResources = map[string]resource.Quantity{ + string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(memBytes*5, resource.BinarySI), + } + if err := fakeClient.Status().Patch(context.Background(), existing, patch); err != nil { + t.Fatalf("failed to patch CRD status: %v", err) + } + + // Scheduler returns host-1 so probes succeed. + schedulerServer := newMockSchedulerServer(t, []string{"host-1"}) + defer schedulerServer.Close() + + // VMSource returns empty list (all VMs removed). + vmSource := &mockVMSource{vms: []reservations.VM{}} + + ctrl := NewController(fakeClient, Config{ + SchedulerURL: schedulerServer.URL, + TotalPipeline: "kvm-report-capacity", + PlaceablePipeline: "kvm-general-purpose", + }, vmSource) + + smallFlavor := compute.FlavorInGroup{Name: groupName + "-small", MemoryMB: memMB, VCPUs: 2} + groupData := compute.FlavorGroupFeature{ + Name: groupName, + SmallestFlavor: smallFlavor, + Flavors: []compute.FlavorInGroup{smallFlavor}, + } + hvByName := map[string]hv1.Hypervisor{"host-1": *hv} + groups := map[string]compute.FlavorGroupFeature{groupName: groupData} + + // Compute VM usage — should return fresh=true with zero instances. + usageByKey := ctrl.computeVMUsage(context.Background(), groups, []hv1.Hypervisor{*hv}) + key := vmUsageKey{group: groupName, az: az} + usage, exists := usageByKey[key] + if !exists { + t.Fatalf("expected usage entry for key %v, got none", key) + } + if !usage.fresh { + t.Errorf("usage.fresh = false, want true (successful call with no VMs)") + } + if usage.instances != 0 { + t.Errorf("usage.instances = %d, want 0", usage.instances) + } + + // Now run reconcileAZ to verify the CRD gets zeroed out. + if err := ctrl.reconcileAZ(context.Background(), az, groups, hvByName, map[string]int64{}, usageByKey); err != nil { + t.Fatalf("reconcileAZ failed: %v", err) + } + + var crd v1alpha1.FlavorGroupCapacity + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: crdName}, &crd); err != nil { + t.Fatalf("failed to get CRD: %v", err) + } + if crd.Status.RunningInstances != 0 { + t.Errorf("RunningInstances = %d, want 0 (all VMs removed)", crd.Status.RunningInstances) + } + if crd.Status.RunningResources != nil { + for k, v := range crd.Status.RunningResources { + if !v.IsZero() { + t.Errorf("RunningResources[%s] = %s, want 0", k, v.String()) + } + } + } +} + func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { const memMB = 4096 const memBytes = int64(memMB) * 1024 * 1024 diff --git a/pkg/multicluster/client.go b/pkg/multicluster/client.go index 6af557598..fcaecaf93 100644 --- a/pkg/multicluster/client.go +++ b/pkg/multicluster/client.go @@ -345,7 +345,8 @@ func (c *Client) Get(ctx context.Context, key client.ObjectKey, obj client.Objec } if !apierrors.IsNotFound(err) { log.Error(err, "error checking for duplicate resource in cluster", - "gvk", gvk, "namespace", key.Namespace, "name", key.Name) + "gvk", gvk, "namespace", key.Namespace, "name", key.Name, + "host", cl.GetConfig().Host) } continue } @@ -357,7 +358,8 @@ func (c *Client) Get(ctx context.Context, key client.ObjectKey, obj client.Objec } if !apierrors.IsNotFound(err) { log.Error(err, "error getting resource from cluster", "gvk", gvk, - "namespace", key.Namespace, "name", key.Name) + "namespace", key.Namespace, "name", key.Name, + "host", cl.GetConfig().Host) } } if !found { @@ -392,7 +394,8 @@ func (c *Client) List(ctx context.Context, list client.ObjectList, opts ...clien for _, cl := range clusters { listCopy := list.DeepCopyObject().(client.ObjectList) if err := cl.GetClient().List(ctx, listCopy, opts...); err != nil { - log.Error(err, "error listing resources from cluster", "gvk", gvk) + log.Error(err, "error listing resources from cluster", + "gvk", gvk, "host", cl.GetConfig().Host) continue } items, err := meta.ExtractList(listCopy) @@ -632,7 +635,7 @@ func (c *subResourceClient) Get(ctx context.Context, obj, subResource client.Obj if !apierrors.IsNotFound(err) { log.Error(err, "error checking for duplicate sub-resource in cluster", "gvk", gvk, "namespace", obj.GetNamespace(), "name", obj.GetName(), - "subresource", c.subResource) + "subresource", c.subResource, "host", cl.GetConfig().Host) } continue } @@ -646,7 +649,7 @@ func (c *subResourceClient) Get(ctx context.Context, obj, subResource client.Obj if !apierrors.IsNotFound(err) { log.Error(err, "error getting sub-resource from cluster", "gvk", gvk, "namespace", obj.GetNamespace(), "name", obj.GetName(), - "subresource", c.subResource) + "subresource", c.subResource, "host", cl.GetConfig().Host) } } if !found { diff --git a/tools/perses/values.yaml b/tools/perses/values.yaml index ec489e2f4..30e68380f 100644 --- a/tools/perses/values.yaml +++ b/tools/perses/values.yaml @@ -4,12 +4,24 @@ sidecar: enabled: true +volumes: + - name: perses-plugins + emptyDir: {} + +volumeMounts: + - name: perses-plugins + mountPath: /etc/perses/plugins + config: provisioning: folders: - /etc/perses/provisioning - /etc/perses/datasources interval: 1m + plugin: + path: /etc/perses/plugins + archive_paths: + - /etc/perses/plugins-archive datasources: - kind: GlobalDatasource