From d50018e8539018f8fe445be89b046639d6b28307 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:23:45 +0200 Subject: [PATCH 01/21] Renovate: Update kube-prometheus-stack Docker tag to v87 (#965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | [kube-prometheus-stack](https://redirect.github.com/prometheus-operator/kube-prometheus) ([source](https://redirect.github.com/prometheus-community/helm-charts)) | major | `86.3.2` โ†’ `87.0.1` | --- ### Release Notes
prometheus-community/helm-charts (kube-prometheus-stack) ### [`v87.0.1`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.0.1) [Compare Source](https://redirect.github.com/prometheus-community/helm-charts/compare/kube-prometheus-stack-87.0.0...kube-prometheus-stack-87.0.1) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. #### What's Changed - \[kube-prometheus-stack] Update kube-prometheus-stack dependency non-major updates by [@​renovate](https://redirect.github.com/renovate)\[bot] in [#​7016](https://redirect.github.com/prometheus-community/helm-charts/pull/7016) **Full Changelog**: ### [`v87.0.0`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.0.0) [Compare Source](https://redirect.github.com/prometheus-community/helm-charts/compare/kube-prometheus-stack-86.3.2...kube-prometheus-stack-87.0.0) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. #### What's Changed - \[kube-prometheus-stack] Bump prometheus-operator to v0.92.0 by [@​DrFaust92](https://redirect.github.com/DrFaust92) in [#​7012](https://redirect.github.com/prometheus-community/helm-charts/pull/7012) **Full Changelog**:
--- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Disabled by config. Please merge this manually once you are satisfied. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- helm/dev/cortex-prometheus-operator/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/dev/cortex-prometheus-operator/Chart.yaml b/helm/dev/cortex-prometheus-operator/Chart.yaml index 604fb66ab..31c637ed3 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: 86.3.2 + version: 87.0.1 From 693ca006a3d3a47f6baf9b342c18249c3fa6b0e3 Mon Sep 17 00:00:00 2001 From: "cortex-ai-agents[bot]" <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:24:05 +0200 Subject: [PATCH 02/21] docs(reservations): document InFlightReservation, domain hints, and CRS metrics (#963) ## Summary - Document InFlightReservation CRD type (#954): temporary capacity blocks for VMs being scheduled that prevent double-booking across candidate hosts, including lifecycle, spec fields, and interaction with CR reservation blocking - Document domain name resolution via Keystone (#955): placement requests include domain_name scheduler hints resolved from DomainID to enforce host restrictions for external customer domains via filter_external_customer - Document CRS evaluation subsystem (#847): post-placement classification of outcomes (no_cr, cr_exhausted, slot_exhausted, slot_blocked, slot_missed, slot_used) and new Prometheus metrics cortex_nova_no_host_found_total and cortex_nova_placement_total ## Test plan - [ ] Verify markdown renders correctly on GitHub - [ ] Confirm internal links to #inflightreservation anchor resolve correctly - [ ] Review metric names and classification categories match source code Assisted-by: Claude Code:claude-sonnet-4-20250514 [Bash] [Read] Co-authored-by: cortex-ai-agents[bot] <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 --- .../committed-resource-reservations.md | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/reservations/committed-resource-reservations.md b/docs/reservations/committed-resource-reservations.md index 83477358a..c1dc53674 100644 --- a/docs/reservations/committed-resource-reservations.md +++ b/docs/reservations/committed-resource-reservations.md @@ -12,6 +12,7 @@ Cortex reserves hypervisor capacity for customers who pre-commit resources (comm - [Reservation Lifecycle](#reservation-lifecycle) - [VM Lifecycle](#vm-lifecycle) - [Capacity Blocking](#capacity-blocking) + - [InFlightReservation](#inflightreservation) - [Reservation Controller](#reservation-controller) - [Info API](#info-api) - [Change-Commitments API](#change-commitments-api) @@ -19,6 +20,7 @@ Cortex reserves hypervisor capacity for customers who pre-commit resources (comm - [Report-Usage API](#report-usage-api) - [Report-Capacity API](#report-capacity-api) - [Syncer Task](#syncer-task) + - [Placement Observability (CRS Evaluation)](#placement-observability-crs-evaluation) The CR reservation implementation is located in `internal/scheduling/reservations/commitments/`. Key components include: - `CommittedResource` controller โ€” acceptance, rejection, child Reservation CRUD (memory) or arithmetic headroom check (cores) @@ -247,7 +249,7 @@ block = max(remaining, spec_only_unblocked) When a VM is in flight (Nova choosing between candidates), a pessimistic blocking reservation exists on each candidate host. For any SpecOnly VM that has such a reservation on the same host, the pessimistic blocking reservation is the authority โ€” the CR reservation must not double-count it. The `spec_only_unblocked` term excludes those VMs. -See the pessimistic blocking reservations documentation for the full interaction semantics. +See the [InFlightReservation](#inflightreservation) section below for how these reservations are managed. **Migration state (`Spec.TargetHost != Status.Host`):** @@ -261,11 +263,27 @@ When a reservation is being migrated to a new host, block the full `max(Spec.Res - **VM live migration within a reservation** (VM moves away from the reservation's host): handled implicitly by `hv.Status.Allocation`. Libvirt reports resource consumption on both source and target during live migration, so both hosts' `hv.Status.Allocation` already reflects the in-flight state. No special filter logic needed. The reservation controller will eventually remove the VM from the reservation once it's confirmed on the wrong host past the grace period. +#### InFlightReservation + +An `InFlightReservation` is a short-lived Reservation CRD (type `InFlightReservation`) that pessimistically blocks capacity on each candidate host while a VM is being scheduled. It prevents double-booking when multiple scheduling decisions are in flight concurrently. + +**Lifecycle:** +- **Created** by the scheduling pipeline at the end of a successful placement run, one per candidate host returned to Nova. Creation is skipped when the `SkipInflight` pipeline option is set (used by reservation scheduling, capacity checks, and failover โ€” any non-VM-placement run). +- **Deleted** once the VM has been confirmed on a host (the in-flight reservation is no longer needed) or after a timeout if the VM never lands. + +**Spec fields** (`InFlightReservationSpec`): +- `VMID` โ€” Nova server UUID of the VM being scheduled +- `UserID` โ€” owner of the VM +- `ProjectID` โ€” project/tenant of the VM +- `Intent` โ€” lifecycle operation that triggered the placement (e.g., create, migrate, resize) + +**Interaction with CR reservations:** When computing how much capacity a CR reservation must block, Spec-only VMs that already have an InFlightReservation on the same host are excluded from the CR reservation's block calculation (the `spec_only_unblocked` term). This avoids double-counting resources that are already blocked by the pessimistic InFlightReservation. + #### Reservation Controller The `Reservation` controller watches `Reservation` CRDs and `Hypervisor` CRDs. `MaxConcurrentReconciles=1` prevents overbooking during concurrent placements. -**Placement** โ€” finds hosts for new reservations (calls scheduler API) +**Placement** โ€” finds hosts for new reservations (calls scheduler API). Placement requests include a `domain_name` scheduler hint resolved from the reservation's `DomainID` via Keystone. This allows the `filter_external_customer` pipeline filter to enforce host restrictions for external customer domains. Domain name resolution uses an in-process cache that stores names indefinitely (domain names are immutable in OpenStack). If the Keystone integration is not configured (`keystoneSecretRef` absent), the hint is omitted and domain-based host restrictions are not enforced. **Allocation Verification** โ€” tracks VM lifecycle on reservations. The controller uses the Hypervisor CRD as the sole source of truth, with two triggers: - New VMs (within `committedResourceAllocationGracePeriod`, default: 15 min): verification deferred โ€” VM may still be spawning; requeued every `committedResourceRequeueIntervalGracePeriod` (default: 1 min) @@ -322,3 +340,37 @@ For each VM, the API reports whether it accounts to a specific commitment or PAY ### Syncer Task The syncer task runs periodically and syncs local `CommittedResource` CRD state to match Limes' view of commitments, correcting drift from missed API calls or restarts. It writes `CommittedResource` CRDs only โ€” capacity management is the controller's responsibility. + +### Placement Observability (CRS Evaluation) + +The `internal/scheduling/nova/crs/` package provides post-placement classification and Prometheus metrics for committed resource slot utilization. It answers the question: "For each VM placement (or no-host-found failure), what was the CR slot situation?" + +**Prometheus metrics:** + +| Metric | Labels | Description | +|--------|--------|-------------| +| `cortex_nova_no_host_found_total` | `cr_slot`, `flavor_group`, `intent` | No-host-found results classified by CR coverage | +| `cortex_nova_placement_total` | `flavor_group`, `intent`, `cr_slot` | Successful placements classified by CR slot outcome | + +PAYG placements (flavor not in any configured group) are not counted by either metric. + +**No-host-found classification (`cr_slot` label on `cortex_nova_no_host_found_total`):** + +| Category | Meaning | +|----------|---------| +| `no_cr` | Project has no active CommittedResources for the flavor group | +| `cr_exhausted` | CommittedResources exist but are fully occupied (used >= capacity) | +| `slot_exhausted` | CR has remaining capacity but no input host has a usable reservation slot | +| `slot_blocked` | A usable slot exists on an input host but scheduling constraints excluded all such hosts | + +**Placement classification (`cr_slot` label on `cortex_nova_placement_total`):** + +| Category | Meaning | +|----------|---------| +| `no_cr` | No active CR or CR capacity fully exhausted | +| `slot_missed` | CR has remaining capacity but no candidate host has a slot with remaining memory > 0 | +| `slot_used` | CR has remaining capacity and at least one candidate host has a usable slot | + +**Slot evaluator:** The `SlotEvaluator` is built once per scheduling request from Hypervisor and Reservation CRDs (no further K8s reads during classification). It computes per-host free memory and indexes ready CR reservation slots by host. `HasUsableSlot` checks whether a host has a slot that can accommodate the VM under the overfill model: `slot.remaining + host.base_free >= vmMemBytes`. + +**Recorder:** The `Recorder` is called after each placement decision. On success (`slot_used`), it writes the VM UUID into the best-fit reservation slot (`PickSlot` selects the slot that maximises coverage with tightest-fit tiebreaking). On no-host-found, it classifies the failure and increments the counter. From e3bd41d321328062b7c8663cdb7123338645e7ff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:24:50 +0200 Subject: [PATCH 03/21] Renovate: Update actions/checkout action to v7 (#961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v6.0.3` โ†’ `v7.0.0` | | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v6` โ†’ `v7` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v7.0.0`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700) [Compare Source](https://redirect.github.com/actions/checkout/compare/v7.0.0...v7.0.0) - Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2454](https://redirect.github.com/actions/checkout/pull/2454) - Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2458](https://redirect.github.com/actions/checkout/pull/2458) - Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2460](https://redirect.github.com/actions/checkout/pull/2460) - Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2461](https://redirect.github.com/actions/checkout/pull/2461) - Bump [@​actions/core](https://redirect.github.com/actions/core) and [@​actions/tool-cache](https://redirect.github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2459](https://redirect.github.com/actions/checkout/pull/2459) - upgrade module to esm and update dependencies by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2463](https://redirect.github.com/actions/checkout/pull/2463) - Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2462](https://redirect.github.com/actions/checkout/pull/2462) ### [`v7`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700) [Compare Source](https://redirect.github.com/actions/checkout/compare/v6.0.3...v7.0.0) - Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2454](https://redirect.github.com/actions/checkout/pull/2454) - Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2458](https://redirect.github.com/actions/checkout/pull/2458) - Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2460](https://redirect.github.com/actions/checkout/pull/2460) - Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2461](https://redirect.github.com/actions/checkout/pull/2461) - Bump [@​actions/core](https://redirect.github.com/actions/core) and [@​actions/tool-cache](https://redirect.github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2459](https://redirect.github.com/actions/checkout/pull/2459) - upgrade module to esm and update dependencies by [@​aiqiaoy](https://redirect.github.com/aiqiaoy) in [#​2463](https://redirect.github.com/actions/checkout/pull/2463) - Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://redirect.github.com/dependabot)\[bot] in [#​2462](https://redirect.github.com/actions/checkout/pull/2462)
--- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Disabled by config. Please merge this manually once you are satisfied. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ”• **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/check-alerts.yaml | 2 +- .github/workflows/claude-assistant.yaml | 4 ++-- .github/workflows/claude-weekly.yaml | 2 +- .github/workflows/codeql.yaml | 2 +- .github/workflows/helm-lint.yaml | 2 +- .github/workflows/lint.yaml | 2 +- .github/workflows/push-charts.yaml | 2 +- .github/workflows/push-images.yaml | 2 +- .github/workflows/rebuild-postgres.yaml | 4 ++-- .github/workflows/reuse.yaml | 2 +- .github/workflows/test.yaml | 2 +- .github/workflows/update-appversion.yml | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/check-alerts.yaml b/.github/workflows/check-alerts.yaml index eee95bfcb..8bc5ce4db 100644 --- a/.github/workflows/check-alerts.yaml +++ b/.github/workflows/check-alerts.yaml @@ -15,7 +15,7 @@ jobs: # needed); promtool is installed by the peimanja action below. runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Get changed bundles id: changed_bundles diff --git a/.github/workflows/claude-assistant.yaml b/.github/workflows/claude-assistant.yaml index 55ce97cc3..68f896ed7 100644 --- a/.github/workflows/claude-assistant.yaml +++ b/.github/workflows/claude-assistant.yaml @@ -14,7 +14,7 @@ jobs: outputs: allowed: ${{ steps.check.outputs.allowed }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 1 - name: Check sender against allowlist @@ -39,7 +39,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/claude-weekly.yaml b/.github/workflows/claude-weekly.yaml index f159fe429..7d0e8a0bb 100644 --- a/.github/workflows/claude-weekly.yaml +++ b/.github/workflows/claude-weekly.yaml @@ -15,7 +15,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 3a9815541..47b415216 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -27,7 +27,7 @@ jobs: runs-on: large_runner_16core_64gb steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/helm-lint.yaml b/.github/workflows/helm-lint.yaml index a765b8524..e7e261cdf 100644 --- a/.github/workflows/helm-lint.yaml +++ b/.github/workflows/helm-lint.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout PR - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 41f83e1c3..efc720e3a 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/push-charts.yaml b/.github/workflows/push-charts.yaml index a4559d15a..84c8962d1 100644 --- a/.github/workflows/push-charts.yaml +++ b/.github/workflows/push-charts.yaml @@ -17,7 +17,7 @@ jobs: packages: write runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Helm uses: azure/setup-helm@v5.0.0 - name: Log into registry diff --git a/.github/workflows/push-images.yaml b/.github/workflows/push-images.yaml index f3be685ce..ef3c39e9c 100644 --- a/.github/workflows/push-images.yaml +++ b/.github/workflows/push-images.yaml @@ -19,7 +19,7 @@ jobs: id-token: write runs-on: large_runner_16core_64gb steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx diff --git a/.github/workflows/rebuild-postgres.yaml b/.github/workflows/rebuild-postgres.yaml index a55c72433..745f9dec6 100644 --- a/.github/workflows/rebuild-postgres.yaml +++ b/.github/workflows/rebuild-postgres.yaml @@ -25,7 +25,7 @@ jobs: outputs: rebuild_fixes_cves: ${{ steps.compare.outputs.rebuild_fixes_cves }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -77,7 +77,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: main persist-credentials: false diff --git a/.github/workflows/reuse.yaml b/.github/workflows/reuse.yaml index d1e318ed7..1e8c94f04 100644 --- a/.github/workflows/reuse.yaml +++ b/.github/workflows/reuse.yaml @@ -10,7 +10,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: REUSE Compliance Check uses: fsfe/reuse-action@v6 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 402951a55..48c91cbfd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,7 @@ jobs: - 2375:2375 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/update-appversion.yml b/.github/workflows/update-appversion.yml index 2dc987633..a89de4203 100644 --- a/.github/workflows/update-appversion.yml +++ b/.github/workflows/update-appversion.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} persist-credentials: false From 7a5c1fb7f9cc6667d891759416c572e5475fe1c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:26:17 +0200 Subject: [PATCH 04/21] build(deps): bump github.com/opencontainers/runc from 1.3.3 to 1.3.6 in the go_modules group across 1 directory (#973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go_modules group with 1 update in the / directory: [github.com/opencontainers/runc](https://github.com/opencontainers/runc). Updates `github.com/opencontainers/runc` from 1.3.3 to 1.3.6
Release notes

Sourced from github.com/opencontainers/runc's releases.

runc v1.3.5 -- "Lo viejo funciona!"

This is the fifth patch release of the 1.3.z release series of runc, and primarily contains a few fixes for issues found in 1.3.4.

Fixed

  • Recursive atime-related mount flags (rrelatime et al.) are now applied properly. (#5115, #5098)
  • PR #4757 caused a regression that resulted in spurious cannot start a container that has stopped errors when running runc create and has thus been reverted. (#5158, #5153, #5151, #4645, #4757)

Changed

Static Linking Notices

The runc binary distributed with this release are statically linked with the following GNU LGPL-2.1 licensed libraries, with runc acting as a "work that uses the Library":

The versions of these libraries were not modified from their upstream versions, but in order to comply with the LGPL-2.1 (ยง6(a)), we have attached the complete source code for those libraries which (when combined with the attached runc source code) may be used to exercise your rights under the LGPL-2.1.

However we strongly suggest that you make use of your distribution's packages or download them from the authoritative upstream sources, especially since these libraries are related to the security of your containers.


Thanks to the following contributors for making this release possible:

Changelog

Sourced from github.com/opencontainers/runc's changelog.

[1.3.6] - 2026-06-13

On no account should you allow a Vogon to read poetry at you.

Security

This release includes a fix for the following low-severity security issue:

  • CVE-2026-41579 allowed a malicious image with a /dev symlink to have limited write access to the host filesystem in ways that our analysis indicates was too limited to be problematic in practice. This bug was very similar to those fixed in [CVE-2025-31133][], [CVE-2025-52565][], [CVE-2025-31133][] and was simply missed at the time when we hardened the rootfs preparation code. We have conducted a deeper audit and not found any other problematic cases.

    This patchset required backports for #5190 and #5285, which were primarily code reorganisations that were already backported to runc 1.4 and 1.5.

Fixed

Changed

  • When masking directories with maskPaths, runc will now reuse a single tmpfs instance (which is not writable) to reduce the number tmpfs superblocks that need to be reaped when containers die (in particular, Kubernetes applies masks to per-CPU sysfs directories which get expensive quickly). (#5275, #5281)

[1.5.0-rc.2] - 2026-04-02

ใ„ใ–ใ‚„ใ„ใ–ใ‚„ใ€่ฆ‹ใซ่กŒใ‹ใ‚“

[!NOTE] runc v1.5.0-rc.2 includes all of the patches backported to runc v1.4.2.

Fixed

  • Building with libpathrs for systems that use non-GNU awk, e.g. Debian. (#5196, #5194)

Added

  • Installation notes for libpathrs. (#5199, #5195)
  • Support for specs.LinuxSeccompFlagWaitKillableRecv. (#5183, #5172)
  • When building runc, RUNC_BUILDTAGS make or shell environment variable can

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/opencontainers/runc&package-manager=go_modules&previous-version=1.3.3&new-version=1.3.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/cobaltcore-dev/cortex/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 26e32019a..cac8433ea 100644 --- a/go.mod +++ b/go.mod @@ -80,7 +80,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runc v1.3.3 // indirect + github.com/opencontainers/runc v1.3.6 // indirect github.com/ory/dockertest v3.3.5+incompatible github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 3ff05f7cf..9f9c6c563 100644 --- a/go.sum +++ b/go.sum @@ -180,8 +180,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4= -github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= +github.com/opencontainers/runc v1.3.6 h1:SLGIymCtsk80iNPWgbc8dtjI30r+5mTVV+4dN8/17Sk= +github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4= github.com/ory/dockertest v3.3.5+incompatible h1:iLLK6SQwIhcbrG783Dghaaa3WPzGc+4Emza6EbVUUGA= github.com/ory/dockertest v3.3.5+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= From d171e418e42c963559d652bf09d18babdb4ed36b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:37:54 +0200 Subject: [PATCH 05/21] Renovate: Update github.com/sapcc (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Adoption](https://docs.renovatebot.com/merge-confidence/) | [Passing](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update | |---|---|---|---|---|---|---|---| | [github.com/sapcc/go-api-declarations](https://redirect.github.com/sapcc/go-api-declarations) | `v1.23.0` โ†’ `v1.24.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fsapcc%2fgo-api-declarations/v1.24.0?slim=true) | ![adoption](https://developer.mend.io/api/mc/badges/adoption/go/github.com%2fsapcc%2fgo-api-declarations/v1.24.0?slim=true) | ![passing](https://developer.mend.io/api/mc/badges/compatibility/go/github.com%2fsapcc%2fgo-api-declarations/v1.23.0/v1.24.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fsapcc%2fgo-api-declarations/v1.23.0/v1.24.0?slim=true) | require | minor | | [github.com/sapcc/go-bits](https://redirect.github.com/sapcc/go-bits) | `328f497` โ†’ `178db34` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fsapcc%2fgo-bits/v0.0.0-20260621162543-178db349f39d?slim=true) | ![adoption](https://developer.mend.io/api/mc/badges/adoption/go/github.com%2fsapcc%2fgo-bits/v0.0.0-20260621162543-178db349f39d?slim=true) | ![passing](https://developer.mend.io/api/mc/badges/compatibility/go/github.com%2fsapcc%2fgo-bits/v0.0.0-20260611141223-328f49772fed/v0.0.0-20260621162543-178db349f39d?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fsapcc%2fgo-bits/v0.0.0-20260611141223-328f49772fed/v0.0.0-20260621162543-178db349f39d?slim=true) | require | digest | --- ### Release Notes
sapcc/go-api-declarations (github.com/sapcc/go-api-declarations) ### [`v1.24.0`](https://redirect.github.com/sapcc/go-api-declarations/compare/v1.23.0...v1.24.0) [Compare Source](https://redirect.github.com/sapcc/go-api-declarations/compare/v1.23.0...v1.24.0)
--- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Enabled. โ™ป **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Markus Wieland Co-authored-by: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 18 ++++++++---------- .../reservations/commitments/api/info.go | 4 ++-- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index cac8433ea..a6b3201b4 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/ironcore-dev/ironcore v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/sapcc/go-bits v0.0.0-20260611141223-328f49772fed + github.com/sapcc/go-bits v0.0.0-20260621162543-178db349f39d go.xyrillian.de/gg v1.10.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 @@ -85,9 +85,9 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/poy/onpar v0.3.5 // indirect - github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect - github.com/sapcc/go-api-declarations v1.23.0 + github.com/sapcc/go-api-declarations v1.24.0 github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -109,7 +109,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 9f9c6c563..9bcb16cfc 100644 --- a/go.sum +++ b/go.sum @@ -195,19 +195,17 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sapcc/go-api-declarations v1.23.0 h1:9Gcree1lxMsZnZ/Ij9yWiLWvAyP4PQqMipmY+pYe+aY= -github.com/sapcc/go-api-declarations v1.23.0/go.mod h1:ZWRTijvgF8o8aHg5stgg7u4DF6jFrd0X97le/uGlZsA= -github.com/sapcc/go-bits v0.0.0-20260611141223-328f49772fed h1:Kus4OIlIGBaBReCv20gS9xh3zNnG6MHaM+gIDCMx02s= -github.com/sapcc/go-bits v0.0.0-20260611141223-328f49772fed/go.mod h1:0alQMC+7YOI96PivcxqVhOX4jJKfLFL2OlciYOxgyk8= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +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-20260621162543-178db349f39d h1:Y3haqPapstc6xt86bz1G/wnpmO3xRNd8fUFsQvIOi/Y= +github.com/sapcc/go-bits v0.0.0-20260621162543-178db349f39d/go.mod h1:iijO1r0HeCbtam2ekqGr9fZOrtFF13dKrCrvPRmbK/w= 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= @@ -271,8 +269,8 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index e02c8ec11..2abcabfed 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -165,7 +165,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l "CPU cores (usable by: %s)", flavorListStr, ), - Unit: liquid.UnitNone, + Unit: liquid.UnitPiece, Topology: liquid.AZSeparatedTopology, NeedsResourceDemand: false, HasCapacity: resCfg.Cores.HasCapacity, @@ -181,7 +181,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l "instances (usable by: %s)", flavorListStr, ), - Unit: liquid.UnitNone, + Unit: liquid.UnitPiece, Topology: liquid.AZSeparatedTopology, NeedsResourceDemand: false, HasCapacity: resCfg.Instances.HasCapacity, From 9120ac6f85469ff0fefbe31c332bad3b173260b0 Mon Sep 17 00:00:00 2001 From: "cortex-ai-agents[bot]" <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:03:31 +0200 Subject: [PATCH 06/21] docs(reservations): fix stale file paths, grace period behavior, and dead cross-reference (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VMSource refactor (#930) moved vm_source.go to the shared reservations package (internal/scheduling/reservations/) but the failover-reservations doc still listed it under the failover subdirectory. The grace period bypass for confirmed VMs (#925) changed allocation verification behavior โ€” confirmed VMs are now removed immediately when they disappear from the Hypervisor CRD, without waiting for the grace period โ€” but the committed-resource-reservations doc still described a single grace-period path for all missing VMs. Additionally, a dangling cross-reference pointed to non-existent "pessimistic blocking reservations documentation" and has been replaced with an inline explanation of InFlightReservation CRDs. Assisted-by: Claude Code:claude-sonnet-4-20250514 [Bash] [Read] Co-authored-by: cortex-ai-agents[bot] <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 --- .../committed-resource-reservations.md | 3 ++- docs/reservations/failover-reservations.md | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/reservations/committed-resource-reservations.md b/docs/reservations/committed-resource-reservations.md index c1dc53674..3775f34b2 100644 --- a/docs/reservations/committed-resource-reservations.md +++ b/docs/reservations/committed-resource-reservations.md @@ -288,7 +288,8 @@ The `Reservation` controller watches `Reservation` CRDs and `Hypervisor` CRDs. ` **Allocation Verification** โ€” tracks VM lifecycle on reservations. The controller uses the Hypervisor CRD as the sole source of truth, with two triggers: - New VMs (within `committedResourceAllocationGracePeriod`, default: 15 min): verification deferred โ€” VM may still be spawning; requeued every `committedResourceRequeueIntervalGracePeriod` (default: 1 min) - Established VMs: verified reactively when the Hypervisor CRD changes (VM appeared or disappeared in `Status.Instances`), with `committedResourceRequeueIntervalActive` (default: 5 min) as a safety-net fallback -- Missing VMs: removed from `Spec.Allocations` when not found on the Hypervisor CRD after the grace period +- Missing unconfirmed VMs (in `Spec.Allocations` only): removed from `Spec.Allocations` when not found on the Hypervisor CRD after the grace period +- Missing confirmed VMs (already present in `Status.Allocations`): bypass the grace period entirely โ€” their disappearance from the Hypervisor CRD is treated as authoritative and they are removed immediately **Reservation migration is not supported yet.** diff --git a/docs/reservations/failover-reservations.md b/docs/reservations/failover-reservations.md index 34a6ccc3f..c2af22a8e 100644 --- a/docs/reservations/failover-reservations.md +++ b/docs/reservations/failover-reservations.md @@ -5,13 +5,14 @@ The failover reservation system ensures VMs have pre-reserved capacity on altern ## File Structure ```text -internal/scheduling/reservations/failover/ -โ”œโ”€โ”€ config.go # Configuration struct (intervals, flavor requirements) -โ”œโ”€โ”€ controller.go # Handles lifecycle of Reservation CRD of type failover -โ”œโ”€โ”€ vm_source.go # VM data source (reads from Nova DB via postgres) -โ”œโ”€โ”€ reservation_eligibility.go # Checks if a VM can use a failover reservation from a HA perspective (independent of normal scheduling constraints) -โ”œโ”€โ”€ reservation_scheduling.go # Scheduling (new and reusing) of failover reservations via our scheduling pipeline -โ””โ”€โ”€ helpers.go # Utility functions for reservation manipulation +internal/scheduling/reservations/ +โ”œโ”€โ”€ vm_source.go # Shared VM data source interface (used by failover, commitments, and quota controllers) +โ””โ”€โ”€ failover/ + โ”œโ”€โ”€ config.go # Configuration struct (intervals, flavor requirements) + โ”œโ”€โ”€ controller.go # Handles lifecycle of Reservation CRD of type failover + โ”œโ”€โ”€ reservation_eligibility.go # Checks if a VM can use a failover reservation from a HA perspective (independent of normal scheduling constraints) + โ”œโ”€โ”€ reservation_scheduling.go # Scheduling (new and reusing) of failover reservations via our scheduling pipeline + โ””โ”€โ”€ helpers.go # Utility functions for reservation manipulation ``` ## Reconciliation Flow @@ -114,7 +115,7 @@ The main orchestrator with dual reconciliation: ### 2. VM Source (`vm_source.go`) -Interface `VMSource` with `DBVMSource` implementation: +Shared interface `VMSource` (located at `internal/scheduling/reservations/vm_source.go`) used by failover, commitments, and quota controllers: - Reads VMs from Nova postgres database (servers + flavors join) - Can trust either postgres (`OSEXTSRVATTRHost`) or Hypervisor CRD for VM location - Returns `VM` structs with UUID, flavor, resources, extra specs, AZ From 07891d8f0c8ced65245ae57d52ab6fc56958edbb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:28:33 +0200 Subject: [PATCH 07/21] Renovate: Update External dependencies (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-python](https://redirect.github.com/actions/setup-python) | action | minor | `v6.2.0` โ†’ `v6.3.0` | | [azure/setup-helm](https://redirect.github.com/azure/setup-helm) | action | patch | `v5.0.0` โ†’ `v5.0.1` | | [kube-prometheus-stack](https://redirect.github.com/prometheus-operator/kube-prometheus) ([source](https://redirect.github.com/prometheus-community/helm-charts)) | | minor | `87.0.1` โ†’ `87.1.0` | --- ### Release Notes
actions/setup-python (actions/setup-python) ### [`v6.3.0`](https://redirect.github.com/actions/setup-python/releases/tag/v6.3.0) [Compare Source](https://redirect.github.com/actions/setup-python/compare/v6.2.0...v6.3.0) ##### What's Changed ##### Enhancement - Add RHEL support and include Linux distro in cache keys by [@​priyagupta108](https://redirect.github.com/priyagupta108) in [#​1323](https://redirect.github.com/actions/setup-python/pull/1323) - Fix pip cache error handling on Windows by [@​priyagupta108](https://redirect.github.com/priyagupta108) in [#​1040](https://redirect.github.com/actions/setup-python/pull/1040) ##### Dependency update - Upgrade minimatch from 3.1.2 to 3.1.5 by [@​dependabot](https://redirect.github.com/dependabot) in [#​1281](https://redirect.github.com/actions/setup-python/pull/1281) - Upgrade actions dependencies by [@​gowridurgad](https://redirect.github.com/gowridurgad) with [@​Copilot](https://redirect.github.com/Copilot) in [#​1303](https://redirect.github.com/actions/setup-python/pull/1303) - Upgrade [@​actions/cache](https://redirect.github.com/actions/cache) to 5.1.0, log cache write denied by [@​jasongin](https://redirect.github.com/jasongin) in [#​1324](https://redirect.github.com/actions/setup-python/pull/1324) - Upgrade dependency versions and test workflow configuration by [@​HarithaVattikuti](https://redirect.github.com/HarithaVattikuti) in [#​1322](https://redirect.github.com/actions/setup-python/pull/1322) ##### Documentation - Update advanced-usage.md by [@​Dunky-Z](https://redirect.github.com/Dunky-Z) in [#​811](https://redirect.github.com/actions/setup-python/pull/811) ##### New Contributors - [@​gowridurgad](https://redirect.github.com/gowridurgad) with [@​Copilot](https://redirect.github.com/Copilot) made their first contribution in [#​1303](https://redirect.github.com/actions/setup-python/pull/1303) - [@​jasongin](https://redirect.github.com/jasongin) made their first contribution in [#​1324](https://redirect.github.com/actions/setup-python/pull/1324) - [@​Dunky-Z](https://redirect.github.com/Dunky-Z) made their first contribution in [#​811](https://redirect.github.com/actions/setup-python/pull/811) **Full Changelog**:
azure/setup-helm (azure/setup-helm) ### [`v5.0.1`](https://redirect.github.com/azure/setup-helm/compare/v5.0.0...v5.0.1) [Compare Source](https://redirect.github.com/azure/setup-helm/compare/v5.0.0...v5.0.1)
prometheus-community/helm-charts (kube-prometheus-stack) ### [`v87.1.0`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.1.0) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. #### What's Changed - \[kube-prometheus-stack] Add agent skill for prometheus-operator bumps by [@​DrFaust92](https://redirect.github.com/DrFaust92) in [#​7013](https://redirect.github.com/prometheus-community/helm-charts/pull/7013) - \[kube-prometheus-stack] Update kube-prometheus-stack dependency non-major updates by [@​renovate](https://redirect.github.com/renovate)\[bot] in [#​7020](https://redirect.github.com/prometheus-community/helm-charts/pull/7020) **Full Changelog**:
--- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Enabled. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/helm-lint.yaml | 4 ++-- .github/workflows/push-charts.yaml | 2 +- helm/dev/cortex-prometheus-operator/Chart.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/helm-lint.yaml b/.github/workflows/helm-lint.yaml index e7e261cdf..a186be03c 100644 --- a/.github/workflows/helm-lint.yaml +++ b/.github/workflows/helm-lint.yaml @@ -21,9 +21,9 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@v5.0.0 + uses: azure/setup-helm@v5.0.1 - - uses: actions/setup-python@v6.2.0 + - uses: actions/setup-python@v6.3.0 with: python-version: "3.13" check-latest: true diff --git a/.github/workflows/push-charts.yaml b/.github/workflows/push-charts.yaml index 84c8962d1..5090885dc 100644 --- a/.github/workflows/push-charts.yaml +++ b/.github/workflows/push-charts.yaml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up Helm - uses: azure/setup-helm@v5.0.0 + uses: azure/setup-helm@v5.0.1 - name: Log into registry uses: docker/login-action@v4 with: diff --git a/helm/dev/cortex-prometheus-operator/Chart.yaml b/helm/dev/cortex-prometheus-operator/Chart.yaml index 31c637ed3..1aaae2c49 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.0.1 + version: 87.1.0 From 593235949f3b64927567a2d651761e3b4696c3b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:29:12 +0200 Subject: [PATCH 08/21] Renovate: Update github.com/sapcc (#976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github.com/sapcc/go-bits](https://redirect.github.com/sapcc/go-bits) | require | digest | `178db34` โ†’ `b9734b4` | --- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Enabled. โ™ป **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a6b3201b4..dfb39a655 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/ironcore-dev/ironcore v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/sapcc/go-bits v0.0.0-20260621162543-178db349f39d + github.com/sapcc/go-bits v0.0.0-20260623114633-b9734b46a368 go.xyrillian.de/gg v1.10.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 diff --git a/go.sum b/go.sum index 9bcb16cfc..5e1eb83d5 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-20260621162543-178db349f39d h1:Y3haqPapstc6xt86bz1G/wnpmO3xRNd8fUFsQvIOi/Y= -github.com/sapcc/go-bits v0.0.0-20260621162543-178db349f39d/go.mod h1:iijO1r0HeCbtam2ekqGr9fZOrtFF13dKrCrvPRmbK/w= +github.com/sapcc/go-bits v0.0.0-20260623114633-b9734b46a368 h1:RZRK9YiWgFGXCAXm3rm5DVHUONKseTTZlYzvjtfkvn4= +github.com/sapcc/go-bits v0.0.0-20260623114633-b9734b46a368/go.mod h1:iijO1r0HeCbtam2ekqGr9fZOrtFF13dKrCrvPRmbK/w= 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= From 26d1de20896cea91ec9e400d9459a0183c54fa9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:29:34 +0200 Subject: [PATCH 09/21] Renovate: Update postgres Dockerfile base image (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | debian | | digest | `4e401d9` โ†’ `f3da281` | | debian | final | digest | `4e401d9` โ†’ `f3da281` | --- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Disabled by config. Please merge this manually once you are satisfied. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- postgres/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres/Dockerfile b/postgres/Dockerfile index 8c9d9fbd3..cc15571d2 100644 --- a/postgres/Dockerfile +++ b/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:trixie-slim@sha256:4e401d95de7083948053197a9c3913343cd06b706bf15eb6a0c3ccd26f436a0e +FROM debian:trixie-slim@sha256:f3da28155e2e26086464eba22cd235b22200b7143e8f3e1811bf359e3114bf96 # explicitly set user/group IDs RUN set -eux; \ From af70744678dbcdf1d4fca713a814b790b407d6dd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:18:53 +0200 Subject: [PATCH 10/21] Renovate: Update postgres Dockerfile base image (#979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | debian | | digest | `f3da281` โ†’ `28de087` | | debian | final | digest | `f3da281` โ†’ `28de087` | --- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Disabled by config. Please merge this manually once you are satisfied. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- postgres/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres/Dockerfile b/postgres/Dockerfile index cc15571d2..483c4ff84 100644 --- a/postgres/Dockerfile +++ b/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:trixie-slim@sha256:f3da28155e2e26086464eba22cd235b22200b7143e8f3e1811bf359e3114bf96 +FROM debian:trixie-slim@sha256:28de0877c2189802884ccd20f15ee41c203573bd87bb6b883f5f46362d24c5c2 # explicitly set user/group IDs RUN set -eux; \ From 26d62f1fe1d365d72e665e074911e5471a2e4817 Mon Sep 17 00:00:00 2001 From: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:19:30 +0200 Subject: [PATCH 11/21] chore: update hypervisor crd for local development (#980) --- Tiltfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tiltfile b/Tiltfile index bef579d17..e836cdbc5 100644 --- a/Tiltfile +++ b/Tiltfile @@ -83,7 +83,7 @@ local('kubectl wait --namespace cert-manager --for=condition=available deploymen ########### Dependency CRDs # Make sure the local cluster is running if you are running into startup issues here. -url = 'https://raw.githubusercontent.com/cobaltcore-dev/openstack-hypervisor-operator/d35f2bc2c5d4fd634b17e7a8dd77ff3025758fbb/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml' +url = 'https://raw.githubusercontent.com/cobaltcore-dev/openstack-hypervisor-operator/c57c5be250635c639c858395c0501aa8c422f4ea/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml' local('curl -L ' + url + ' | kubectl apply -f -') ########### Cortex Manager & CRDs From ded98bd313ab6bb565094b5b8837c9e1f7c1362a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:22:24 +0200 Subject: [PATCH 12/21] Renovate: Update github.com/sapcc/go-bits digest to 3999ce9 (#984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github.com/sapcc/go-bits](https://redirect.github.com/sapcc/go-bits) | require | digest | `b9734b4` โ†’ `3999ce9` | --- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Enabled. โ™ป **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. ๐Ÿ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index dfb39a655..b55638520 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.26.0 require ( github.com/cobaltcore-dev/openstack-hypervisor-operator v1.2.3 github.com/go-gorp/gorp v2.2.0+incompatible - github.com/gophercloud/gophercloud/v2 v2.12.0 + github.com/gophercloud/gophercloud/v2 v2.13.0 github.com/ironcore-dev/ironcore v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/sapcc/go-bits v0.0.0-20260623114633-b9734b46a368 + github.com/sapcc/go-bits v0.0.0-20260626143732-3999ce9f8fdc go.xyrillian.de/gg v1.10.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 diff --git a/go.sum b/go.sum index 5e1eb83d5..1f8ab39b1 100644 --- a/go.sum +++ b/go.sum @@ -123,8 +123,8 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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.12.0 h1:Gxmc/Bog1UDKkxTcQW7MSPTDviJXpLeEgVeN5KrxoCo= -github.com/gophercloud/gophercloud/v2 v2.12.0/go.mod h1:H7TTOxbLy8RIaHSNhI2GCrWIzw4Xpw8Xn2mBhCUT5kA= +github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= +github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= 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= @@ -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-20260623114633-b9734b46a368 h1:RZRK9YiWgFGXCAXm3rm5DVHUONKseTTZlYzvjtfkvn4= -github.com/sapcc/go-bits v0.0.0-20260623114633-b9734b46a368/go.mod h1:iijO1r0HeCbtam2ekqGr9fZOrtFF13dKrCrvPRmbK/w= +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/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= From 31a18341d868b8d067f504380d0c612a8b72e4f6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:24:34 +0000 Subject: [PATCH 13/21] Renovate: Update External dependencies (#982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Adoption](https://docs.renovatebot.com/merge-confidence/) | [Passing](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update | |---|---|---|---|---|---|---|---| | [github.com/ironcore-dev/ironcore](https://redirect.github.com/ironcore-dev/ironcore) | `v0.4.1` โ†’ `v0.4.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fironcore-dev%2fironcore/v0.4.2?slim=true) | ![adoption](https://developer.mend.io/api/mc/badges/adoption/go/github.com%2fironcore-dev%2fironcore/v0.4.2?slim=true) | ![passing](https://developer.mend.io/api/mc/badges/compatibility/go/github.com%2fironcore-dev%2fironcore/v0.4.1/v0.4.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fironcore-dev%2fironcore/v0.4.1/v0.4.2?slim=true) | require | patch | | go.xyrillian.de/gg | `v1.10.1` โ†’ `v1.11.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.xyrillian.de%2fgg/v1.11.1?slim=true) | ![adoption](https://developer.mend.io/api/mc/badges/adoption/go/go.xyrillian.de%2fgg/v1.11.1?slim=true) | ![passing](https://developer.mend.io/api/mc/badges/compatibility/go/go.xyrillian.de%2fgg/v1.10.1/v1.11.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.xyrillian.de%2fgg/v1.10.1/v1.11.1?slim=true) | require | minor | | [kube-prometheus-stack](https://redirect.github.com/prometheus-operator/kube-prometheus) ([source](https://redirect.github.com/prometheus-community/helm-charts)) | `87.1.0` โ†’ `87.3.0` | ![age](https://developer.mend.io/api/mc/badges/age/docker/ghcr.io%2fprometheus-community%2fcharts%2fkube-prometheus-stack/87.3.0?slim=true) | ![adoption](https://developer.mend.io/api/mc/badges/adoption/docker/ghcr.io%2fprometheus-community%2fcharts%2fkube-prometheus-stack/87.3.0?slim=true) | ![passing](https://developer.mend.io/api/mc/badges/compatibility/docker/ghcr.io%2fprometheus-community%2fcharts%2fkube-prometheus-stack/87.1.0/87.3.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/ghcr.io%2fprometheus-community%2fcharts%2fkube-prometheus-stack/87.1.0/87.3.0?slim=true) | | minor | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/18) for more information. --- ### Release Notes
ironcore-dev/ironcore (github.com/ironcore-dev/ironcore) ### [`v0.4.2`](https://redirect.github.com/ironcore-dev/ironcore/releases/tag/v0.4.2) [Compare Source](https://redirect.github.com/ironcore-dev/ironcore/compare/v0.4.1...v0.4.2) #### Changes #### ๐Ÿ› Bug Fixes - `MachineScheduler` cache loses track of bound machines [@​lukasfrank](https://redirect.github.com/lukasfrank) ([#​1492](https://redirect.github.com/ironcore-dev/ironcore/issues/1492)) - Fix cross-NIC deletion of ephemeral VirtualIPs [@​afritzler](https://redirect.github.com/afritzler) ([#​1493](https://redirect.github.com/ironcore-dev/ironcore/issues/1493)) - Grant RBAC for events in events.k8s.io API group [@​friegger](https://redirect.github.com/friegger) ([#​1490](https://redirect.github.com/ironcore-dev/ironcore/issues/1490)) #### ๐Ÿงฐ Maintenance - Remove function `SetVolumeUidToAllVolumes()` [@​kasabe28](https://redirect.github.com/kasabe28) ([#​1484](https://redirect.github.com/ironcore-dev/ironcore/issues/1484)) - Bump github.com/onsi/ginkgo/v2 from 2.31.0 to 2.32.0 in the go-dependencies group across 1 directory @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot) ([#​1486](https://redirect.github.com/ironcore-dev/ironcore/issues/1486)) - Bump actions/checkout from 6 to 7 in the gh-actions-dependencies group @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot) ([#​1487](https://redirect.github.com/ironcore-dev/ironcore/issues/1487)) - Bump the go-dependencies group with 2 updates @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot) ([#​1483](https://redirect.github.com/ironcore-dev/ironcore/issues/1483))
prometheus-community/helm-charts (kube-prometheus-stack) ### [`v87.3.0`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.3.0) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. ##### What's Changed - \[kube-prometheus-stack] render containers and initContainers with tpl by [@​firasmosbehi](https://redirect.github.com/firasmosbehi) in [#​7040](https://redirect.github.com/prometheus-community/helm-charts/pull/7040) **Full Changelog**: ### [`v87.2.1`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.2.1) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. #### What's Changed - \[kube-prometheus-stack] Update kube-prometheus-stack dependency non-major updates by [@​renovate](https://redirect.github.com/renovate)\[bot] in [#​7035](https://redirect.github.com/prometheus-community/helm-charts/pull/7035) **Full Changelog**: ### [`v87.2.0`](https://redirect.github.com/prometheus-community/helm-charts/releases/tag/kube-prometheus-stack-87.2.0) kube-prometheus-stack collects Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator. #### What's Changed - \[kube-prometheus-stack] Update kube-prometheus-stack dependency non-major updates by [@​renovate](https://redirect.github.com/renovate)\[bot] in [#​7034](https://redirect.github.com/prometheus-community/helm-charts/pull/7034) **Full Changelog**:
--- ### Configuration ๐Ÿ“… **Schedule**: (in timezone Europe/Berlin) - Branch creation - "after 6pm every weekday,every weekend,before 8am every weekday" - Automerge - At any time (no schedule defined) ๐Ÿšฆ **Automerge**: Enabled. โ™ป **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. ๐Ÿ‘ป **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/cobaltcore-dev/cortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 16 ++++++++-------- helm/dev/cortex-prometheus-operator/Chart.yaml | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index b55638520..97ef930e1 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/cobaltcore-dev/openstack-hypervisor-operator v1.2.3 github.com/go-gorp/gorp v2.2.0+incompatible github.com/gophercloud/gophercloud/v2 v2.13.0 - github.com/ironcore-dev/ironcore v0.4.1 + 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 - go.xyrillian.de/gg v1.10.1 + go.xyrillian.de/gg v1.11.1 k8s.io/api v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 diff --git a/go.sum b/go.sum index 1f8ab39b1..8941058a5 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs 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.4.1 h1:nuFDomg0Lt292ILd8xzCufz+PS6RQHex3aEE0BhJawI= -github.com/ironcore-dev/ironcore v0.4.1/go.mod h1:Gdznl3R0oD858qVMeVhTuQoADKMsVEuYKccFlJWQ0dI= +github.com/ironcore-dev/ironcore v0.4.2 h1:qYhW0MIc8boHxG5MhgoCECGPJTKbPYnR/sGYmmdqM6U= +github.com/ironcore-dev/ironcore v0.4.2/go.mod h1:3VRaWvT9GZhtlPuFQHQKxYviEZQw9aloAjS3h+c7jcc= github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= @@ -172,10 +172,10 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= +github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -257,8 +257,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.xyrillian.de/gg v1.10.1 h1:V6oSU+tl25vaRQaMy6Y3jl/0kNoY/a25x4WIk5zQFAw= -go.xyrillian.de/gg v1.10.1/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= +go.xyrillian.de/gg v1.11.1 h1:7P3kDFkTmR7jx2riYi0GwX5uhgrsL37QSrN16xH/n8E= +go.xyrillian.de/gg v1.11.1/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/helm/dev/cortex-prometheus-operator/Chart.yaml b/helm/dev/cortex-prometheus-operator/Chart.yaml index 1aaae2c49..59859b611 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.1.0 + version: 87.3.0 From 124ec2266e4282529609629dd6de4e43e90811a2 Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:03:12 +0200 Subject: [PATCH 14/21] feat: VM usage and fair capacity split for overlapping flavor groups (#983) - FlavorGroupCapacity CRD tracks running resources/instances, free capacity and exclusively free capacity - capacity calculation respects cases when flavor groups can use same resources: capacity is now split fairly across flavor groups sharing the same hypervisors via a deterministic round-robin algorithm, so the sum of reported capacities never exceeds real installed capacity --- api/v1alpha1/flavor_group_capacity_types.go | 57 +- api/v1alpha1/zz_generated.deepcopy.go | 21 + cmd/manager/main.go | 2 +- .../bundles/cortex-nova/templates/alerts.yaml | 4 +- .../cortex.cloud_flavorgroupcapacities.yaml | 87 +- .../reservations/capacity/controller.go | 529 ++++++++---- .../reservations/capacity/controller_test.go | 197 +++-- .../reservations/capacity/metrics.go | 79 +- .../scheduling/reservations/capacity/split.go | 259 ++++++ .../reservations/capacity/split_test.go | 535 ++++++++++++ .../commitments/api/report_capacity.go | 19 +- .../api/report_capacity_monitor.go | 8 +- .../commitments/api/report_capacity_test.go | 804 ++++++++---------- .../reservations/commitments/capacity.go | 98 +-- 14 files changed, 1939 insertions(+), 760 deletions(-) create mode 100644 internal/scheduling/reservations/capacity/split.go create mode 100644 internal/scheduling/reservations/capacity/split_test.go diff --git a/api/v1alpha1/flavor_group_capacity_types.go b/api/v1alpha1/flavor_group_capacity_types.go index 80596256e..7e9ee36c0 100644 --- a/api/v1alpha1/flavor_group_capacity_types.go +++ b/api/v1alpha1/flavor_group_capacity_types.go @@ -24,7 +24,11 @@ type FlavorGroupCapacitySpec struct { AvailabilityZone string `json:"availabilityZone"` } -// FlavorCapacityStatus holds per-flavor capacity numbers for one (flavor group ร— AZ) pair. +// FlavorCapacityStatus holds per-flavor scheduler probe results for one (flavor group ร— AZ) pair. +// These values come directly from scheduler probes and are independent of the cross-group +// capacity split (see FreeCapacity and ExclusivelyFreeCapacity on the parent status). +// "Placeable" means: if all remaining capacity in this AZ were used solely by this flavor, +// this is how many would fit. It does not account for competing flavor groups. type FlavorCapacityStatus struct { // FlavorName is the OpenStack flavor name (e.g. "hana-v2-small"). FlavorName string `json:"flavorName"` @@ -37,11 +41,11 @@ type FlavorCapacityStatus struct { // +kubebuilder:validation:Optional PlaceableVMs int64 `json:"placeableVms,omitempty"` - // TotalCapacityHosts is the number of eligible hosts in an empty-datacenter scenario. + // TotalCapacityHosts is the number of eligible hosts assuming an empty datacenter. // +kubebuilder:validation:Optional TotalCapacityHosts int64 `json:"totalCapacityHosts,omitempty"` - // TotalCapacityVMSlots is the maximum number of VM slots in an empty-datacenter scenario. + // TotalCapacityVMSlots is the maximum number of VM slots assuming an empty datacenter. // +kubebuilder:validation:Optional TotalCapacityVMSlots int64 `json:"totalCapacityVmSlots,omitempty"` } @@ -57,14 +61,45 @@ type FlavorGroupCapacityStatus struct { // +kubebuilder:validation:Optional CommittedCapacity int64 `json:"committedCapacity,omitempty"` - // TotalCapacity is the total capacity of all eligible hosts in an empty-datacenter scenario. + // CommittedCapacityBytes is CommittedCapacity converted to raw bytes. + // +kubebuilder:validation:Optional + CommittedCapacityBytes int64 `json:"committedCapacityBytes,omitempty"` + + // SmallestFlavorName is the name of the smallest flavor in this group, used as the + // slot unit for ExclusivelyFreeSlots and related capacity fields. + // +kubebuilder:validation:Optional + SmallestFlavorName string `json:"smallestFlavorName,omitempty"` + + // TotalCapacity is the installed capacity across all eligible hosts in an empty-datacenter + // scenario, expressed as raw resource amounts (bytes for memory, count for cores). // +kubebuilder:validation:Optional TotalCapacity map[string]resource.Quantity `json:"totalCapacity,omitempty"` - // TotalInstances is the total number of VM instances running on hypervisors in this AZ, - // derived from Hypervisor CRD Status.Instances (not filtered by flavor group). + // FreeCapacity is the sum of remaining resources across all candidate hosts for this + // group given current allocations. Because groups can share hosts, the sum across groups + // may exceed actual installed capacity โ€” this field reflects per-group availability + // before any cross-group fairness split. + // +kubebuilder:validation:Optional + FreeCapacity map[string]resource.Quantity `json:"freeCapacity,omitempty"` + + // ExclusivelyFreeCapacity is the share of remaining resources fairly attributed to this + // group by the round-robin capacity split. The sum across all groups for an AZ never + // exceeds actual installed capacity. + // +kubebuilder:validation:Optional + ExclusivelyFreeCapacity map[string]resource.Quantity `json:"exclusivelyFreeCapacity,omitempty"` + + // ExclusivelyFreeSlots is the number of smallest-flavor VM slots available from ExclusivelyFreeCapacity. // +kubebuilder:validation:Optional - TotalInstances int64 `json:"totalInstances,omitempty"` + ExclusivelyFreeSlots int64 `json:"exclusivelyFreeSlots,omitempty"` + + // RunningInstances is the number of VMs running in this (flavor group ร— AZ) whose + // flavor belongs to this group. + // +kubebuilder:validation:Optional + RunningInstances int64 `json:"runningInstances,omitempty"` + + // RunningResources is the total resource consumption of running VMs, keyed by resource type. + // +kubebuilder:validation:Optional + RunningResources map[string]resource.Quantity `json:"runningResources,omitempty"` // LastReconcileAt is the timestamp of the last successful reconcile. // +kubebuilder:validation:Optional @@ -80,7 +115,7 @@ type FlavorGroupCapacityStatus struct { // +kubebuilder:resource:scope=Cluster // +kubebuilder:printcolumn:name="FlavorGroup",type="string",JSONPath=".spec.flavorGroup" // +kubebuilder:printcolumn:name="AZ",type="string",JSONPath=".spec.availabilityZone" -// +kubebuilder:printcolumn:name="TotalInstances",type="integer",JSONPath=".status.totalInstances" +// +kubebuilder:printcolumn:name="Running",type="integer",JSONPath=".status.runningInstances" // +kubebuilder:printcolumn:name="LastReconcile",type="date",JSONPath=".status.lastReconcileAt" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" @@ -89,16 +124,10 @@ type FlavorGroupCapacityStatus struct { // The capacity API reads these CRDs instead of probing the scheduler on each request. type FlavorGroupCapacity struct { metav1.TypeMeta `json:",inline"` - - // metadata is a standard object metadata // +optional metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` - - // spec defines the desired state of FlavorGroupCapacity // +required Spec FlavorGroupCapacitySpec `json:"spec"` - - // status defines the observed state of FlavorGroupCapacity // +optional Status FlavorGroupCapacityStatus `json:"status,omitempty,omitzero"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 89fe75d93..9f654186a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -856,6 +856,27 @@ func (in *FlavorGroupCapacityStatus) DeepCopyInto(out *FlavorGroupCapacityStatus (*out)[key] = val.DeepCopy() } } + if in.FreeCapacity != nil { + in, out := &in.FreeCapacity, &out.FreeCapacity + *out = make(map[string]resource.Quantity, len(*in)) + for key, val := range *in { + (*out)[key] = val.DeepCopy() + } + } + if in.ExclusivelyFreeCapacity != nil { + in, out := &in.ExclusivelyFreeCapacity, &out.ExclusivelyFreeCapacity + *out = make(map[string]resource.Quantity, len(*in)) + for key, val := range *in { + (*out)[key] = val.DeepCopy() + } + } + if in.RunningResources != nil { + in, out := &in.RunningResources, &out.RunningResources + *out = make(map[string]resource.Quantity, len(*in)) + for key, val := range *in { + (*out)[key] = val.DeepCopy() + } + } in.LastReconcileAt.DeepCopyInto(&out.LastReconcileAt) if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 07f3e9ce2..af86e3d84 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -771,7 +771,7 @@ func main() { setupLog.Error(err, "failed to register capacity monitor metrics, continuing without metrics") } - capacityController := capacity.NewController(multiclusterClient, capacityConfig) + capacityController := capacity.NewController(multiclusterClient, capacityConfig, commitmentsVMSource) if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { return capacityController.Start(ctx) })); err != nil { diff --git a/helm/bundles/cortex-nova/templates/alerts.yaml b/helm/bundles/cortex-nova/templates/alerts.yaml index 6f3fabef2..e043481b1 100644 --- a/helm/bundles/cortex-nova/templates/alerts.yaml +++ b/helm/bundles/cortex-nova/templates/alerts.yaml @@ -570,8 +570,8 @@ spec: - alert: CortexNovaCommittedResourceCapacityDroppedToZero expr: | - (cortex_committed_resource_reported_capacity_gib{service="cortex-nova-metrics"} == 0) - and on(resource, az) (cortex_committed_resource_reported_capacity_gib{service="cortex-nova-metrics"} offset 30m > 0) + (cortex_committed_resource_reported_capacity{service="cortex-nova-metrics"} == 0) + and on(resource, az, unit_size) (cortex_committed_resource_reported_capacity{service="cortex-nova-metrics"} offset 30m > 0) for: 5m labels: context: committed-resource-api diff --git a/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml b/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml index 73a009ba4..952e15722 100644 --- a/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml +++ b/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml @@ -21,8 +21,8 @@ spec: - jsonPath: .spec.availabilityZone name: AZ type: string - - jsonPath: .status.totalInstances - name: TotalInstances + - jsonPath: .status.runningInstances + name: Running type: integer - jsonPath: .status.lastReconcileAt name: LastReconcile @@ -56,7 +56,7 @@ spec: metadata: type: object spec: - description: spec defines the desired state of FlavorGroupCapacity + description: FlavorGroupCapacitySpec defines the desired state of FlavorGroupCapacity. properties: availabilityZone: description: AvailabilityZone is the OpenStack AZ this capacity data @@ -70,7 +70,7 @@ spec: - flavorGroup type: object status: - description: status defines the observed state of FlavorGroupCapacity + description: FlavorGroupCapacityStatus defines the observed state of FlavorGroupCapacity. properties: committedCapacity: description: |- @@ -78,6 +78,11 @@ spec: expressed in multiples of the smallest flavor's memory. format: int64 type: integer + committedCapacityBytes: + description: CommittedCapacityBytes is CommittedCapacity converted + to raw bytes. + format: int64 + type: integer conditions: description: The current status conditions of the FlavorGroupCapacity. items: @@ -135,12 +140,33 @@ spec: - type type: object type: array + exclusivelyFreeCapacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + ExclusivelyFreeCapacity is the share of remaining resources fairly attributed to this + group by the round-robin capacity split. The sum across all groups for an AZ never + exceeds actual installed capacity. + type: object + exclusivelyFreeSlots: + description: ExclusivelyFreeSlots is the number of smallest-flavor + VM slots available from ExclusivelyFreeCapacity. + format: int64 + type: integer flavors: description: Flavors holds per-flavor capacity data for all flavors in the group. items: - description: FlavorCapacityStatus holds per-flavor capacity numbers - for one (flavor group ร— AZ) pair. + description: |- + FlavorCapacityStatus holds per-flavor scheduler probe results for one (flavor group ร— AZ) pair. + These values come directly from scheduler probes and are independent of the cross-group + capacity split (see FreeCapacity and ExclusivelyFreeCapacity on the parent status). + "Placeable" means: if all remaining capacity in this AZ were used solely by this flavor, + this is how many would fit. It does not account for competing flavor groups. properties: flavorName: description: FlavorName is the OpenStack flavor name (e.g. "hana-v2-small"). @@ -157,39 +183,68 @@ spec: type: integer totalCapacityHosts: description: TotalCapacityHosts is the number of eligible hosts - in an empty-datacenter scenario. + assuming an empty datacenter. format: int64 type: integer totalCapacityVmSlots: description: TotalCapacityVMSlots is the maximum number of VM - slots in an empty-datacenter scenario. + slots assuming an empty datacenter. format: int64 type: integer required: - flavorName type: object type: array + freeCapacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + FreeCapacity is the sum of remaining resources across all candidate hosts for this + group given current allocations. Because groups can share hosts, the sum across groups + may exceed actual installed capacity โ€” this field reflects per-group availability + before any cross-group fairness split. + type: object lastReconcileAt: description: LastReconcileAt is the timestamp of the last successful reconcile. format: date-time type: string - totalCapacity: + runningInstances: + description: |- + RunningInstances is the number of VMs running in this (flavor group ร— AZ) whose + flavor belongs to this group. + format: int64 + type: integer + runningResources: additionalProperties: anyOf: - type: integer - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: TotalCapacity is the total capacity of all eligible hosts - in an empty-datacenter scenario. + description: RunningResources is the total resource consumption of + running VMs, keyed by resource type. type: object - totalInstances: + smallestFlavorName: description: |- - TotalInstances is the total number of VM instances running on hypervisors in this AZ, - derived from Hypervisor CRD Status.Instances (not filtered by flavor group). - format: int64 - type: integer + SmallestFlavorName is the name of the smallest flavor in this group, used as the + slot unit for ExclusivelyFreeSlots and related capacity fields. + type: string + totalCapacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + TotalCapacity is the installed capacity across all eligible hosts in an empty-datacenter + scenario, expressed as raw resource amounts (bytes for memory, count for cores). + type: object type: object required: - spec diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index d49cada59..fd71f5a05 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -30,16 +30,19 @@ import ( var log = ctrl.Log.WithName("capacity-controller").WithValues("module", "capacity") // Controller reconciles FlavorGroupCapacity CRDs on a fixed interval. -// For each (flavor group ร— AZ) pair it probes all flavors in the group and updates the CRD status. +// For each AZ it probes all flavor groups, runs the round-robin capacity split, then writes +// one FlavorGroupCapacity CRD per (flavor group ร— AZ) pair. type Controller struct { client client.Client + vmSource reservations.VMSource schedulerClient *reservations.SchedulerClient config Config } -func NewController(c client.Client, config Config) *Controller { +func NewController(c client.Client, config Config, vmSource reservations.VMSource) *Controller { return &Controller{ client: c, + vmSource: vmSource, schedulerClient: reservations.NewSchedulerClient(config.SchedulerURL), config: config, } @@ -64,7 +67,18 @@ func (c *Controller) Start(ctx context.Context) error { } } -// reconcileAll iterates all flavor groups ร— AZs and upserts FlavorGroupCapacity CRDs. +type vmUsageKey struct{ group, az string } + +// vmUsage aggregates resource totals for running VMs in one (group ร— AZ). +// resources keys are ResourceMemory (bytes) and ResourceCores (count). +// fresh is false when the VMSource call failed โ€” running fields must not be overwritten. +type vmUsage struct { + instances int64 + resources map[string]int64 + fresh bool +} + +// reconcileAll iterates all AZs, runs the round-robin split per AZ, then writes CRDs. func (c *Controller) reconcileAll(ctx context.Context) error { logger := LoggerFromContext(ctx) startTime := time.Now() @@ -87,25 +101,22 @@ func (c *Controller) reconcileAll(ctx context.Context) error { azs := availabilityZones(hvList.Items) - // Compute reservation memory blocks once per cycle โ€” shared across all (group ร— AZ) pairs. blockedByReservations, err := c.blockedMemoryByHost(ctx) if err != nil { logger.Error(err, "failed to compute blocked memory by host, placeable slot counts may be overstated") blockedByReservations = map[string]int64{} } + usageByKey := c.computeVMUsage(ctx, flavorGroups, hvList.Items) + var succeeded, failed int - for groupName, groupData := range flavorGroups { - for _, az := range azs { - if err := c.reconcileOne(ctx, groupName, groupData, az, hvByName, hvList.Items, blockedByReservations); err != nil { - logger.Error(err, "failed to reconcile flavor group capacity", - "flavorGroup", groupName, "az", az) - failed++ - // Continue with other pairs rather than aborting the whole cycle. - continue - } - succeeded++ + for _, az := range azs { + if err := c.reconcileAZ(ctx, az, flavorGroups, hvByName, blockedByReservations, usageByKey); err != nil { + logger.Error(err, "failed to reconcile AZ", "az", az) + failed++ + continue } + succeeded += len(flavorGroups) } logger.Info("capacity reconcile cycle completed", @@ -118,23 +129,293 @@ func (c *Controller) reconcileAll(ctx context.Context) error { return nil } -// reconcileOne updates the FlavorGroupCapacity CRD for one (group ร— AZ) pair. -func (c *Controller) reconcileOne( +// computeVMUsage fetches running VMs and aggregates usage per (flavorGroup, az). +// On error returns an empty map with fresh=false โ€” callers must not overwrite running fields. +func (c *Controller) computeVMUsage( + ctx context.Context, + flavorGroups map[string]compute.FlavorGroupFeature, + hvs []hv1.Hypervisor, +) map[vmUsageKey]vmUsage { + + logger := LoggerFromContext(ctx) + result := make(map[vmUsageKey]vmUsage) + if c.vmSource == nil { + return result + } + + hvList := &hv1.HypervisorList{Items: hvs} + vms, err := c.vmSource.ListVMsOnHypervisors(ctx, hvList, true) + if err != nil { + logger.Error(err, "failed to list VMs for usage computation, running fields will retain last known values") + return result + } + + flavorToGroup := make(map[string]string) + flavorMemBytes := make(map[string]int64) + flavorVCPUs := make(map[string]int64) + for groupName, gd := range flavorGroups { + for _, f := range gd.Flavors { + flavorToGroup[f.Name] = groupName + flavorMemBytes[f.Name] = int64(f.MemoryMB) * 1024 * 1024 //nolint:gosec + flavorVCPUs[f.Name] = int64(f.VCPUs) //nolint:gosec + } + } + + for _, vm := range vms { + groupName, ok := flavorToGroup[vm.FlavorName] + if !ok { + continue + } + key := vmUsageKey{group: groupName, az: vm.AvailabilityZone} + u := result[key] + if u.resources == nil { + u.resources = make(map[string]int64) + } + u.instances++ + u.resources[ResourceMemory] += flavorMemBytes[vm.FlavorName] + u.resources[ResourceCores] += flavorVCPUs[vm.FlavorName] + u.fresh = true + result[key] = u + } + return result +} + +// hvRemainingResources returns remaining schedulable resources after subtracting +// current allocations and (for memory) active reservation blocks. +// Returns nil if the hypervisor has no capacity data. +func hvRemainingResources(hv hv1.Hypervisor, blockedMemBytes int64) map[string]int64 { + effCap := hv.Status.EffectiveCapacity + if effCap == nil { + effCap = hv.Status.Capacity + } + if effCap == nil { + return nil + } + + result := make(map[string]int64, 2) + + if qty, ok := effCap[hv1.ResourceMemory]; ok { + mem := qty.Value() + if alloc, ok := hv.Status.Allocation[hv1.ResourceMemory]; ok { + mem -= alloc.Value() + } + mem -= blockedMemBytes + if mem < 0 { + mem = 0 + } + result[ResourceMemory] = mem + } + + if qty, ok := effCap[hv1.ResourceCPU]; ok { + cpu := qty.Value() + if alloc, ok := hv.Status.Allocation[hv1.ResourceCPU]; ok { + cpu -= alloc.Value() + } + if cpu < 0 { + cpu = 0 + } + result[ResourceCores] = cpu + } + + return result +} + +// reconcileAZ runs the round-robin capacity split for all flavor groups in one AZ, +// then writes one FlavorGroupCapacity CRD per group that had all probes succeed. +// Groups with failed probes are skipped โ€” their CRDs retain the last good state. +func (c *Controller) reconcileAZ( ctx context.Context, - groupName string, - groupData compute.FlavorGroupFeature, az string, + flavorGroups map[string]compute.FlavorGroupFeature, hvByName map[string]hv1.Hypervisor, - allHVs []hv1.Hypervisor, blockedByReservations map[string]int64, + usageByKey map[vmUsageKey]vmUsage, ) error { - smallestFlavorBytes := int64(groupData.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec - if smallestFlavorBytes <= 0 { - return fmt.Errorf("smallest flavor %q has invalid memory %d MB", - groupData.SmallestFlavor.Name, groupData.SmallestFlavor.MemoryMB) + logger := LoggerFromContext(ctx) + + type probeResult struct { + groupName string + groupData compute.FlavorGroupFeature + flavors []v1alpha1.FlavorCapacityStatus + // allFresh is false if any scheduler probe failed; the group's CRD is left unchanged. + allFresh bool + smallestCandidates []string + committedCapacity int64 + } + + results := make([]probeResult, 0, len(flavorGroups)) + + groupNames := make([]string, 0, len(flavorGroups)) + for name := range flavorGroups { + groupNames = append(groupNames, name) + } + sort.Strings(groupNames) + + for _, groupName := range groupNames { + groupData := flavorGroups[groupName] + + smallestFlavorBytes := int64(groupData.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec + if smallestFlavorBytes <= 0 { + logger.Error(fmt.Errorf("smallest flavor %q has invalid memory %d MB", + groupData.SmallestFlavor.Name, groupData.SmallestFlavor.MemoryMB), + "skipping flavor group", "flavorGroup", groupName) + continue + } + + // Probe all flavors. Sort for stable CRD output. + flavors := make([]compute.FlavorInGroup, len(groupData.Flavors)) + copy(flavors, groupData.Flavors) + sort.Slice(flavors, func(i, j int) bool { return flavors[i].Name < flavors[j].Name }) + + allFresh := true + newFlavors := make([]v1alpha1.FlavorCapacityStatus, 0, len(flavors)) + + // Load existing per-flavor data to preserve stale values on probe failure. + crdName := crdNameFor(groupName, az) + var existing v1alpha1.FlavorGroupCapacity + if err := c.client.Get(ctx, types.NamespacedName{Name: crdName}, &existing); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get FlavorGroupCapacity %s: %w", crdName, err) + } + existingByName := make(map[string]v1alpha1.FlavorCapacityStatus, len(existing.Status.Flavors)) + for _, f := range existing.Status.Flavors { + existingByName[f.FlavorName] = f + } + + var smallestCandidates []string + for _, flavor := range flavors { + cur := existingByName[flavor.Name] + cur.FlavorName = flavor.Name + + totalVMSlots, totalHosts, _, totalErr := c.probeScheduler(ctx, flavor, az, c.config.TotalPipeline, hvByName, true, nil) + placeableVMs, placeableHosts, candidates, placeableErr := c.probeScheduler(ctx, flavor, az, c.config.PlaceablePipeline, hvByName, false, blockedByReservations) + + if totalErr != nil { + allFresh = false + } else { + cur.TotalCapacityVMSlots = totalVMSlots + cur.TotalCapacityHosts = totalHosts + } + if placeableErr != nil { + allFresh = false + } else { + cur.PlaceableVMs = placeableVMs + cur.PlaceableHosts = placeableHosts + } + // Capture candidates for the smallest flavor โ€” used as split inputs. + if flavor.Name == groupData.SmallestFlavor.Name && placeableErr == nil { + smallestCandidates = candidates + } + newFlavors = append(newFlavors, cur) + } + + committedCapacity, committedErr := c.sumCommittedCapacity(ctx, groupName, az, smallestFlavorBytes) + if committedErr != nil { + logger.Error(committedErr, "failed to sum committed capacity", + "flavorGroup", groupName, "az", az) + committedCapacity = 0 + } + + results = append(results, probeResult{ + groupName: groupName, + groupData: groupData, + flavors: newFlavors, + allFresh: allFresh, + smallestCandidates: smallestCandidates, + committedCapacity: committedCapacity, + }) } + // Build HostState and GroupInput for the round-robin split. + // Only include groups where all probes succeeded. + hosts := make(map[string]HostState) + groupInputs := make([]GroupInput, 0, len(results)) + for _, r := range results { + if !r.allFresh || r.smallestCandidates == nil { + continue + } + flavorMemBytes := int64(r.groupData.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec + flavorVCPUs := int64(r.groupData.SmallestFlavor.VCPUs) //nolint:gosec + + candidateHosts := make([]string, 0, len(r.smallestCandidates)) + for _, h := range r.smallestCandidates { + candidateHosts = append(candidateHosts, h) + if _, ok := hosts[h]; !ok { + hv, hvOk := hvByName[h] + if !hvOk { + continue + } + remaining := hvRemainingResources(hv, blockedByReservations[h]) + if remaining != nil { + hosts[h] = HostState{Remaining: remaining} + memSlots := remaining[ResourceMemory] / flavorMemBytes + cpuSlots := remaining[ResourceCores] / flavorVCPUs + usableSlots := memSlots + if cpuSlots < usableSlots { + usableSlots = cpuSlots + } + strandedMem := remaining[ResourceMemory] - usableSlots*flavorMemBytes + strandedCPU := remaining[ResourceCores] - usableSlots*flavorVCPUs + logger.V(1).Info("candidate host for capacity split", + "az", az, "flavorGroup", r.groupName, "host", h, + "usableSlots", usableSlots, + "strandedMemoryGiB", strandedMem/(1024*1024*1024), + "strandedCores", strandedCPU) + } + } + } + sort.Strings(candidateHosts) // stable order + groupInputs = append(groupInputs, GroupInput{ + Name: r.groupName, + FlavorResources: map[string]int64{ + ResourceMemory: flavorMemBytes, + ResourceCores: flavorVCPUs, + }, + CandidateHosts: candidateHosts, + }) + } + + freeResources, exclusiveResources, unassigned := SplitCapacity(groupInputs, hosts) + if unassigned[ResourceMemory] > 0 || unassigned[ResourceCores] > 0 { + logger.Info("fragmented capacity not assigned to any group", + "az", az, + "unassignedMemoryGiB", unassigned[ResourceMemory]/(1024*1024*1024), + "unassignedCores", unassigned[ResourceCores], + "candidateHosts", len(hosts), + "groups", len(groupInputs)) + } + + // Write one CRD per group. Skip groups with failed probes โ€” their CRDs retain last good state. + for _, r := range results { + if !r.allFresh { + continue + } + if err := c.writeCRD(ctx, r.groupName, r.groupData, az, + r.flavors, r.committedCapacity, + usageByKey[vmUsageKey{r.groupName, az}], + freeResources[r.groupName], + exclusiveResources[r.groupName], + ); err != nil { + logger.Error(err, "failed to write FlavorGroupCapacity CRD", + "flavorGroup", r.groupName, "az", az) + } + } + return nil +} + +// writeCRD upserts one FlavorGroupCapacity CRD with fresh computed values. +func (c *Controller) writeCRD( + ctx context.Context, + groupName string, + groupData compute.FlavorGroupFeature, + az string, + newFlavors []v1alpha1.FlavorCapacityStatus, + committedCapacity int64, + usage vmUsage, + freeRes map[string]int64, + exclusiveRes map[string]int64, +) error { + crdName := crdNameFor(groupName, az) var existing v1alpha1.FlavorGroupCapacity @@ -154,54 +435,9 @@ func (c *Controller) reconcileOne( return fmt.Errorf("failed to get FlavorGroupCapacity %s: %w", crdName, err) } - // Build a lookup of existing per-flavor data so we can preserve stale values on probe failure. - existingByName := make(map[string]v1alpha1.FlavorCapacityStatus, len(existing.Status.Flavors)) - for _, f := range existing.Status.Flavors { - existingByName[f.FlavorName] = f - } - - // Probe all flavors in the group. Sort for stable CRD output. - flavors := make([]compute.FlavorInGroup, len(groupData.Flavors)) - copy(flavors, groupData.Flavors) - sort.Slice(flavors, func(i, j int) bool { return flavors[i].Name < flavors[j].Name }) - - allFresh := true - newFlavors := make([]v1alpha1.FlavorCapacityStatus, 0, len(flavors)) - for _, flavor := range flavors { - cur := existingByName[flavor.Name] - cur.FlavorName = flavor.Name - - totalVMSlots, totalHosts, totalErr := c.probeScheduler(ctx, flavor, az, c.config.TotalPipeline, hvByName, true, nil) - placeableVMs, placeableHosts, placeableErr := c.probeScheduler(ctx, flavor, az, c.config.PlaceablePipeline, hvByName, false, blockedByReservations) - - if totalErr != nil { - allFresh = false - } else { - cur.TotalCapacityVMSlots = totalVMSlots - cur.TotalCapacityHosts = totalHosts - } - if placeableErr != nil { - allFresh = false - } else { - cur.PlaceableVMs = placeableVMs - cur.PlaceableHosts = placeableHosts - } - newFlavors = append(newFlavors, cur) - } - - // Count total instances and committed capacity (always available regardless of probe results). - totalInstances := countInstancesInAZ(allHVs, az) - committedCapacity, committedErr := c.sumCommittedCapacity(ctx, groupName, az, smallestFlavorBytes) - if committedErr != nil { - LoggerFromContext(ctx).Error(committedErr, "failed to sum committed capacity", - "flavorGroup", groupName, "az", az) - committedCapacity = 0 - } - - // Compute TotalCapacity: for each flavor multiply slot count by its RAM/CPU, - // then take the max across all flavors independently for each resource. - // This reveals the most capacity because the flavor best matching the host's - // resource ratio saturates more resources and produces a higher product. + // TotalCapacity: for each flavor multiply slot count by its resources; take the max + // across all flavors independently. The flavor best matching the host's resource + // ratio saturates more resources and produces a higher product. flavorSpecByName := make(map[string]compute.FlavorInGroup, len(groupData.Flavors)) for _, f := range groupData.Flavors { flavorSpecByName[f.Name] = f @@ -222,39 +458,34 @@ func (c *Controller) reconcileOne( } } - // Only update TotalCapacity when all probes succeeded (allFresh=true). - // This preserves stale values across transient probe failures and ensures - // the CR controller can distinguish "not yet probed" (key absent) from - // "probed but zero capacity" (key present, value=0). - var totalCapacity map[string]resource.Quantity - if allFresh { - totalCapacity = map[string]resource.Quantity{ - string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(maxMemBytes, resource.BinarySI), - string(v1alpha1.CommittedResourceTypeCores): *resource.NewQuantity(maxCPUCores, resource.DecimalSI), - } - } else { - totalCapacity = existing.Status.TotalCapacity - } - patch := client.MergeFrom(existing.DeepCopy()) existing.Status.Flavors = newFlavors - existing.Status.TotalInstances = totalInstances existing.Status.CommittedCapacity = committedCapacity - existing.Status.TotalCapacity = totalCapacity + existing.Status.CommittedCapacityBytes = committedCapacity * int64(groupData.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec + existing.Status.SmallestFlavorName = groupData.SmallestFlavor.Name + existing.Status.TotalCapacity = map[string]resource.Quantity{ + string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(maxMemBytes, resource.BinarySI), + string(v1alpha1.CommittedResourceTypeCores): *resource.NewQuantity(maxCPUCores, resource.DecimalSI), + } + // Only overwrite running fields when the VM data is fresh โ€” a VMSource outage must not + // zero out the last-known values while the CRD is still marked ready. + if usage.fresh { + existing.Status.RunningInstances = usage.instances + existing.Status.RunningResources = resMapToQuantity(usage.resources) + } + existing.Status.FreeCapacity = resMapToQuantity(freeRes) + existing.Status.ExclusivelyFreeCapacity = resMapToQuantity(exclusiveRes) + if flavorMemBytes := int64(groupData.SmallestFlavor.MemoryMB) * 1024 * 1024; flavorMemBytes > 0 { //nolint:gosec + existing.Status.ExclusivelyFreeSlots = exclusiveRes[ResourceMemory] / flavorMemBytes + } existing.Status.LastReconcileAt = metav1.Now() freshCondition := metav1.Condition{ Type: v1alpha1.FlavorGroupCapacityConditionReady, ObservedGeneration: existing.Generation, - } - if allFresh { - freshCondition.Status = metav1.ConditionTrue - freshCondition.Reason = "ReconcileSucceeded" - freshCondition.Message = "capacity data is up-to-date" - } else { - freshCondition.Status = metav1.ConditionFalse - freshCondition.Reason = "ReconcileFailed" - freshCondition.Message = "one or more flavor probes failed" + Status: metav1.ConditionTrue, + Reason: "ReconcileSucceeded", + Message: "capacity data is up-to-date", } meta.SetStatusCondition(&existing.Status.Conditions, freshCondition) @@ -264,11 +495,8 @@ func (c *Controller) reconcileOne( return nil } -// probeScheduler calls the scheduler with the given pipeline and returns VM slots + host count. -// Capacity is computed as sum of floor(hostMemory / flavorMemory) across returned hosts. -// When ignoreAllocations is true (total/empty-datacenter probe), raw effective capacity is used. -// When false (placeable probe), hv.Status.Allocation and blockedByReservations are subtracted so -// that slots reflect remaining capacity after running VMs and active reservation blocks. +// probeScheduler calls the scheduler and returns slot count, host count, and candidate host names. +// ignoreAllocations=true (total probe) uses raw effective capacity; false (placeable probe) subtracts allocations. func (c *Controller) probeScheduler( ctx context.Context, flavor compute.FlavorInGroup, @@ -276,11 +504,11 @@ func (c *Controller) probeScheduler( hvByName map[string]hv1.Hypervisor, ignoreAllocations bool, blockedByReservations map[string]int64, -) (capacity, hosts int64, err error) { +) (capacity, hosts int64, candidateHosts []string, err error) { flavorBytes := int64(flavor.MemoryMB) * 1024 * 1024 //nolint:gosec if flavorBytes <= 0 { - return 0, 0, fmt.Errorf("flavor %q has invalid memory %d MB", flavor.Name, flavor.MemoryMB) + return 0, 0, nil, fmt.Errorf("flavor %q has invalid memory %d MB", flavor.Name, flavor.MemoryMB) } // Build EligibleHosts from all known hypervisors so that novaLimitHostsToRequest @@ -301,48 +529,53 @@ func (c *Controller) probeScheduler( AvailabilityZone: az, Pipeline: pipeline, EligibleHosts: eligibleHosts, - }, scheduling.Options{SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + }, scheduling.Options{ + ReadOnly: true, + SkipHistory: true, + SkipInflight: true, + SkipCommittedResourceTracking: true, + }) if err != nil { - return 0, 0, fmt.Errorf("scheduler call failed (pipeline=%s): %w", pipeline, err) + return 0, 0, nil, fmt.Errorf("scheduler call failed (pipeline=%s): %w", pipeline, err) } - hosts = int64(len(resp.Hosts)) for _, hostName := range resp.Hosts { hv, ok := hvByName[hostName] if !ok { continue } - effectiveCap := hv.Status.EffectiveCapacity - if effectiveCap == nil { - effectiveCap = hv.Status.Capacity - } - if effectiveCap == nil { - continue - } - memCap, ok := effectiveCap[hv1.ResourceMemory] - if !ok { - continue - } - capBytes := memCap.Value() - if !ignoreAllocations { - if alloc, ok := hv.Status.Allocation[hv1.ResourceMemory]; ok { - capBytes -= alloc.Value() + var capBytes int64 + if ignoreAllocations { + effCap := hv.Status.EffectiveCapacity + if effCap == nil { + effCap = hv.Status.Capacity } - capBytes -= blockedByReservations[hostName] - if capBytes < 0 { - capBytes = 0 + if effCap == nil { + continue } + memCap, ok := effCap[hv1.ResourceMemory] + if !ok { + continue + } + capBytes = memCap.Value() + } else { + remaining := hvRemainingResources(hv, blockedByReservations[hostName]) + if remaining == nil { + continue + } + capBytes = remaining[ResourceMemory] } - if capBytes > 0 { - capacity += capBytes / flavorBytes + if slots := capBytes / flavorBytes; slots > 0 { + capacity += slots + candidateHosts = append(candidateHosts, hostName) } } - return capacity, hosts, nil + hosts = int64(len(candidateHosts)) + return capacity, hosts, candidateHosts, nil } -// blockedMemoryByHost lists all Reservations and returns the total bytes blocked per host name. -// Only placed reservations (TargetHost or Status.Host non-empty) are counted. -// When a reservation is being migrated (TargetHost != Status.Host), both hosts are blocked. +// blockedMemoryByHost returns total reservation-blocked bytes per host. +// Both TargetHost and Status.Host are blocked; migration blocks both simultaneously. func (c *Controller) blockedMemoryByHost(ctx context.Context) (map[string]int64, error) { var list v1alpha1.ReservationList if err := c.client.List(ctx, &list); err != nil { @@ -377,9 +610,8 @@ func (c *Controller) blockedMemoryByHost(ctx context.Context) (map[string]int64, return blocked, nil } -// sumCommittedCapacity sums AcceptedSpec.Amount (or Spec.Amount as fallback) across all -// CommittedResource CRDs for the given (flavorGroup, az) pair with an active state -// (guaranteed or confirmed) and resource type memory. Returns the total in slots. +// sumCommittedCapacity sums active CommittedResource amounts (memory type, guaranteed/confirmed) +// for the given (flavorGroup, az) pair. Returns the total in smallest-flavor slots. func (c *Controller) sumCommittedCapacity(ctx context.Context, groupName, az string, smallestFlavorBytes int64) (int64, error) { var list v1alpha1.CommittedResourceList if err := c.client.List(ctx, &list); err != nil { @@ -411,6 +643,25 @@ func (c *Controller) sumCommittedCapacity(ctx context.Context, groupName, az str return total, nil } +// resMapToQuantity converts a raw resource map to a map[string]resource.Quantity. +// Keys are passed through unchanged โ€” ResourceMemory == string(CommittedResourceTypeMemory) +// and ResourceCores == string(CommittedResourceTypeCores) by design. +func resMapToQuantity(res map[string]int64) map[string]resource.Quantity { + if len(res) == 0 { + return nil + } + out := make(map[string]resource.Quantity, len(res)) + for k, v := range res { + switch k { + case ResourceMemory: + out[k] = *resource.NewQuantity(v, resource.BinarySI) + case ResourceCores: + out[k] = *resource.NewQuantity(v, resource.DecimalSI) + } + } + return out +} + // availabilityZones returns a sorted, deduplicated list of AZs from Hypervisor CRD labels. func availabilityZones(hvs []hv1.Hypervisor) []string { azSet := make(map[string]struct{}) @@ -427,18 +678,6 @@ func availabilityZones(hvs []hv1.Hypervisor) []string { return azs } -// countInstancesInAZ counts total VM instances across all hypervisors in the given AZ. -func countInstancesInAZ(hvs []hv1.Hypervisor, az string) int64 { - var total int64 - for _, hv := range hvs { - if hv.Labels["topology.kubernetes.io/zone"] != az { - continue - } - total += int64(len(hv.Status.Instances)) - } - return total -} - // crdNameFor produces a collision-safe DNS label for a (flavorGroup, az) pair. // A 6-hex-char FNV-1a hash of the raw inputs is appended so that pairs differing only // by characters that sanitise identically (e.g. "." vs "-") still get unique names. diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index 8e25ff644..fe026416b 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -78,7 +78,7 @@ func newFlavorGroupKnowledge(t *testing.T, groupName string, smallestMemoryMB ui } } -// newHypervisor creates a Hypervisor CRD with a topology AZ label and effective capacity. +// newHypervisor creates a Hypervisor CRD with a topology AZ label, memory and CPU effective capacity. func newHypervisor(name, az string, memoryBytes int64, instanceIDs ...string) *hv1.Hypervisor { hv := &hv1.Hypervisor{ ObjectMeta: metav1.ObjectMeta{ @@ -87,9 +87,11 @@ func newHypervisor(name, az string, memoryBytes int64, instanceIDs ...string) *h }, } if memoryBytes > 0 { - qty := resource.NewQuantity(memoryBytes, resource.BinarySI) + memQty := resource.NewQuantity(memoryBytes, resource.BinarySI) + cpuQty := resource.NewQuantity(128, resource.DecimalSI) // generous CPU so memory is the binding constraint hv.Status.EffectiveCapacity = map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: *qty, + hv1.ResourceMemory: *memQty, + hv1.ResourceCPU: *cpuQty, } } for _, id := range instanceIDs { @@ -109,6 +111,12 @@ func newMockSchedulerServer(t *testing.T, hosts []string) *httptest.Server { })) } +// newController is a test helper that creates a Controller with a nil VMSource. +func newController(t *testing.T, c client.Client, cfg Config) *Controller { + t.Helper() + return NewController(c, cfg, nil) +} + // --- unit tests for pure helper functions --- var ( @@ -173,25 +181,15 @@ func TestAvailabilityZones(t *testing.T) { } func TestCountInstancesInAZ(t *testing.T) { - hvs := []hv1.Hypervisor{ - *newHypervisor("h1", "az-a", 0, "vm1", "vm2"), - *newHypervisor("h2", "az-a", 0, "vm3"), - *newHypervisor("h3", "az-b", 0, "vm4"), - } - if got := countInstancesInAZ(hvs, "az-a"); got != 3 { - t.Errorf("countInstancesInAZ(az-a) = %d, want 3", got) - } - if got := countInstancesInAZ(hvs, "az-b"); got != 1 { - t.Errorf("countInstancesInAZ(az-b) = %d, want 1", got) - } - if got := countInstancesInAZ(hvs, "az-c"); got != 0 { - t.Errorf("countInstancesInAZ(az-c) = %d, want 0", got) - } + // countInstancesInAZ was removed since TotalInstances is no longer stored in the CRD. + // The AZ instance count is now derived from RunningInstances per flavor group. + // This test is replaced by the reconcileAZ integration tests which verify RunningInstances. + t.Skip("countInstancesInAZ removed โ€” see TestReconcileAZ_CreatesCRD") } -// --- integration-style tests for reconcileOne --- +// --- integration-style tests for reconcileAZ --- -func TestReconcileOne_CreatesCRD(t *testing.T) { +func TestReconcileAZ_CreatesCRD(t *testing.T) { const ( groupName = "hana-v2" az = "qa-de-1a" @@ -209,11 +207,11 @@ func TestReconcileOne_CreatesCRD(t *testing.T) { WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}, &v1alpha1.Knowledge{}). Build() - // Both probes return host-1 so capacity = floor(4GiB/4GiB) = 1 + // Both probes return host-1 โ†’ total capacity = floor(4GiB/4GiB) = 1, placeable = 1. schedulerServer := newMockSchedulerServer(t, []string{"host-1"}) defer schedulerServer.Close() - ctrl := NewController(fakeClient, Config{ + ctrl := newController(t, fakeClient, Config{ SchedulerURL: schedulerServer.URL, TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", @@ -226,8 +224,10 @@ func TestReconcileOne_CreatesCRD(t *testing.T) { } hvByName := map[string]hv1.Hypervisor{"host-1": *hv} - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { - t.Fatalf("reconcileOne failed: %v", err) + if err := ctrl.reconcileAZ(context.Background(), az, + map[string]compute.FlavorGroupFeature{groupName: groupData}, + hvByName, map[string]int64{}, map[vmUsageKey]vmUsage{}); err != nil { + t.Fatalf("reconcileAZ failed: %v", err) } var crd v1alpha1.FlavorGroupCapacity @@ -253,12 +253,18 @@ func TestReconcileOne_CreatesCRD(t *testing.T) { if f.PlaceableHosts != 1 { t.Errorf("PlaceableHosts = %d, want 1", f.PlaceableHosts) } - if crd.Status.TotalInstances != 1 { - t.Errorf("TotalInstances = %d, want 1", crd.Status.TotalInstances) + // Round-robin assigns the 1 available slot โ†’ ExclusivelyFreeCapacity[memory] = 1 flavor slot worth. + excl := crd.Status.ExclusivelyFreeCapacity[string(v1alpha1.CommittedResourceTypeMemory)] + if excl.IsZero() { + t.Errorf("ExclusivelyFreeCapacity[memory] is zero, want non-zero (1 slot assigned)") + } + // TotalInstances removed; per-group running VMs sourced from VMSource (nil in this test โ†’ 0). + if crd.Status.RunningInstances != 0 { + t.Errorf("RunningInstances = %d, want 0 (no VMSource configured)", crd.Status.RunningInstances) } } -func TestReconcileOne_SetsReadyConditionFalseOnSchedulerError(t *testing.T) { +func TestReconcileAZ_SkipsCRDWriteOnSchedulerError(t *testing.T) { const ( groupName = "hana-v2" az = "qa-de-1a" @@ -274,13 +280,13 @@ func TestReconcileOne_SetsReadyConditionFalseOnSchedulerError(t *testing.T) { WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}, &v1alpha1.Knowledge{}). Build() - // Scheduler returns 500 to simulate error + // Scheduler returns 500 to simulate error. failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer failServer.Close() - ctrl := NewController(fakeClient, Config{ + ctrl := newController(t, fakeClient, Config{ SchedulerURL: failServer.URL, TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", @@ -292,28 +298,23 @@ func TestReconcileOne_SetsReadyConditionFalseOnSchedulerError(t *testing.T) { Flavors: []compute.FlavorInGroup{smallFlavor}, } - // reconcileOne returns no error itself (it continues on probe failure), but sets Ready=False - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, map[string]hv1.Hypervisor{}, []hv1.Hypervisor{}, map[string]int64{}); err != nil { - t.Fatalf("reconcileOne failed: %v", err) + if err := ctrl.reconcileAZ(context.Background(), az, + map[string]compute.FlavorGroupFeature{groupName: groupData}, + map[string]hv1.Hypervisor{}, map[string]int64{}, map[vmUsageKey]vmUsage{}); err != nil { + t.Fatalf("reconcileAZ failed: %v", err) } - var crd v1alpha1.FlavorGroupCapacity - if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: crdNameFor(groupName, az)}, &crd); err != nil { - t.Fatalf("failed to get CRD: %v", err) - } - - var freshStatus metav1.ConditionStatus - for _, c := range crd.Status.Conditions { - if c.Type == v1alpha1.FlavorGroupCapacityConditionReady { - freshStatus = c.Status - } + // Stale probes โ†’ CRD must NOT be written; last good state is preserved. + var list v1alpha1.FlavorGroupCapacityList + if err := fakeClient.List(context.Background(), &list); err != nil { + t.Fatalf("failed to list CRDs: %v", err) } - if freshStatus != metav1.ConditionFalse { - t.Errorf("Ready condition = %q, want %q", freshStatus, metav1.ConditionFalse) + if len(list.Items) != 0 { + t.Errorf("expected 0 CRDs (stale cycle skips write), got %d", len(list.Items)) } } -func TestReconcileOne_IdempotentUpdate(t *testing.T) { +func TestReconcileAZ_IdempotentUpdate(t *testing.T) { const ( groupName = "hana-v2" az = "qa-de-1a" @@ -326,7 +327,7 @@ func TestReconcileOne_IdempotentUpdate(t *testing.T) { knowledge := newFlavorGroupKnowledge(t, groupName, memMB) crdName := crdNameFor(groupName, az) - // Pre-create the CRD to test the update path (not create path) + // Pre-create the CRD to test the update path (not create path). existing := &v1alpha1.FlavorGroupCapacity{ ObjectMeta: metav1.ObjectMeta{Name: crdName}, Spec: v1alpha1.FlavorGroupCapacitySpec{ @@ -344,7 +345,7 @@ func TestReconcileOne_IdempotentUpdate(t *testing.T) { schedulerServer := newMockSchedulerServer(t, []string{"host-1"}) defer schedulerServer.Close() - ctrl := NewController(fakeClient, Config{ + ctrl := newController(t, fakeClient, Config{ SchedulerURL: schedulerServer.URL, TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", @@ -356,14 +357,15 @@ func TestReconcileOne_IdempotentUpdate(t *testing.T) { Flavors: []compute.FlavorInGroup{smallFlavor}, } hvByName := map[string]hv1.Hypervisor{"host-1": *hv} + groups := map[string]compute.FlavorGroupFeature{groupName: groupData} // First call - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { - t.Fatalf("first reconcileOne failed: %v", err) + if err := ctrl.reconcileAZ(context.Background(), az, groups, hvByName, map[string]int64{}, map[vmUsageKey]vmUsage{}); err != nil { + t.Fatalf("first reconcileAZ failed: %v", err) } - // Second call โ€” should not error on the already-existing CRD - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { - t.Fatalf("second reconcileOne failed: %v", err) + // Second call โ€” should not error on the already-existing CRD. + if err := ctrl.reconcileAZ(context.Background(), az, groups, hvByName, map[string]int64{}, map[vmUsageKey]vmUsage{}); err != nil { + t.Fatalf("second reconcileAZ failed: %v", err) } var crd v1alpha1.FlavorGroupCapacity @@ -382,14 +384,14 @@ func TestReconcileAll_SkipsGroupsWithNoAZs(t *testing.T) { scheme := newTestScheme(t) knowledge := newFlavorGroupKnowledge(t, "hana-v2", 2048) - // No hypervisors โ†’ no AZs โ†’ reconcileAll returns without error + // No hypervisors โ†’ no AZs โ†’ reconcileAll returns without error. fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(knowledge). WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}, &v1alpha1.Knowledge{}). Build() - ctrl := NewController(fakeClient, Config{ + ctrl := newController(t, fakeClient, Config{ SchedulerURL: "http://localhost:9999", // unreachable; not called TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", @@ -418,34 +420,44 @@ func TestProbeScheduler_CapacityCalculation(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - // Scheduler returns both hosts + // Scheduler returns both hosts. srv := newMockSchedulerServer(t, []string{"host-1", "host-2"}) defer srv.Close() - c := NewController(fakeClient, Config{SchedulerURL: srv.URL}) + c := newController(t, fakeClient, Config{SchedulerURL: srv.URL}) hvByName := map[string]hv1.Hypervisor{ "host-1": *hv1Obj, "host-2": *hv2Obj, } flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} - capacity, hosts, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil) + capacity, hosts, candidates, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil) if err != nil { t.Fatalf("probeScheduler failed: %v", err) } if hosts != 2 { t.Errorf("hosts = %d, want 2", hosts) } - // host-1 = 1 slot (4GiB/4GiB), host-2 = 2 slots (8GiB/4GiB) + // host-1 = 1 slot (4GiB/4GiB), host-2 = 2 slots (8GiB/4GiB). if capacity != 3 { t.Errorf("capacity = %d, want 3", capacity) } + // Both hosts should appear in the candidate list. + candidateSet := make(map[string]struct{}, len(candidates)) + for _, h := range candidates { + candidateSet[h] = struct{}{} + } + if _, ok := candidateSet["host-1"]; !ok { + t.Errorf("host-1 missing from candidates %v", candidates) + } + if _, ok := candidateSet["host-2"]; !ok { + t.Errorf("host-2 missing from candidates %v", candidates) + } } // TestProbeScheduler_SubtractsAllocationsWhenNotIgnored verifies that placeable-probe slot // counting uses remaining capacity (effectiveCapacity โˆ’ allocation) while the total-probe uses -// raw capacity. This is the regression test for the bug where both probes used raw capacity, -// making running VMs invisible in the usage = total โˆ’ placeable calculation. +// raw capacity. func TestProbeScheduler_SubtractsAllocationsWhenNotIgnored(t *testing.T) { const memMB = 4096 const memBytes = int64(memMB) * 1024 * 1024 @@ -462,12 +474,12 @@ func TestProbeScheduler_SubtractsAllocationsWhenNotIgnored(t *testing.T) { srv := newMockSchedulerServer(t, []string{"host-1"}) defer srv.Close() - c := NewController(fakeClient, Config{SchedulerURL: srv.URL}) + c := newController(t, fakeClient, Config{SchedulerURL: srv.URL}) hvByName := map[string]hv1.Hypervisor{"host-1": *hv} flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} // Total probe (ignoreAllocations=true): raw capacity โ†’ 2 slots. - totalCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) + totalCap, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) if err != nil { t.Fatalf("probeScheduler (total) failed: %v", err) } @@ -476,7 +488,7 @@ func TestProbeScheduler_SubtractsAllocationsWhenNotIgnored(t *testing.T) { } // Placeable probe (ignoreAllocations=false): capacity โˆ’ allocation โ†’ 1 slot. - placeableCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, nil) + placeableCap, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, nil) if err != nil { t.Fatalf("probeScheduler (placeable) failed: %v", err) } @@ -491,7 +503,7 @@ func TestReconcileAll_MultipleGroupsAndAZs(t *testing.T) { const memMB = 2048 const memBytes = int64(memMB) * 1024 * 1024 - // Two AZs, two hypervisors + // Two AZs, two hypervisors. hv1Obj := newHypervisor("h1", "az-a", memBytes) hv2Obj := newHypervisor("h2", "az-b", memBytes) knowledge := newFlavorGroupKnowledge(t, "2152", memMB) @@ -505,7 +517,7 @@ func TestReconcileAll_MultipleGroupsAndAZs(t *testing.T) { srv := newMockSchedulerServer(t, []string{}) defer srv.Close() - c := NewController(fakeClient, Config{ + c := newController(t, fakeClient, Config{ SchedulerURL: srv.URL, TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", @@ -515,7 +527,7 @@ func TestReconcileAll_MultipleGroupsAndAZs(t *testing.T) { t.Fatalf("reconcileAll failed: %v", err) } - // Expect one CRD per AZ for the single group + // Expect one CRD per AZ for the single group. var list v1alpha1.FlavorGroupCapacityList if err := fakeClient.List(context.Background(), &list); err != nil { t.Fatalf("failed to list CRDs: %v", err) @@ -532,7 +544,7 @@ func TestReconcileAll_MultipleGroupsAndAZs(t *testing.T) { func TestReconcileAll_FlavorGroupsKnowledgeNotReady(t *testing.T) { scheme := newTestScheme(t) - // Knowledge CRD exists but is not Ready + // Knowledge CRD exists but is not Ready. knowledge := &v1alpha1.Knowledge{ ObjectMeta: metav1.ObjectMeta{Name: "flavor-groups"}, Spec: v1alpha1.KnowledgeSpec{ @@ -556,29 +568,44 @@ func TestReconcileAll_FlavorGroupsKnowledgeNotReady(t *testing.T) { WithStatusSubresource(&v1alpha1.Knowledge{}). Build() - c := NewController(fakeClient, Config{ + c := newController(t, fakeClient, Config{ SchedulerURL: "http://localhost:9999", TotalPipeline: "kvm-report-capacity", PlaceablePipeline: "kvm-general-purpose", }) - // Should return an error when knowledge is not ready + // Should return an error when knowledge is not ready. if err := c.reconcileAll(context.Background()); err == nil { t.Error("reconcileAll should fail when flavor groups knowledge is not ready") } } -func TestReconcileOne_ZeroMemoryFlavorReturnsError(t *testing.T) { +func TestReconcileAZ_ZeroMemoryFlavorSkipped(t *testing.T) { scheme := newTestScheme(t) - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - c := NewController(fakeClient, Config{}) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}). + Build() + c := newController(t, fakeClient, Config{}) groupData := compute.FlavorGroupFeature{ SmallestFlavor: compute.FlavorInGroup{Name: "bad-flavor", MemoryMB: 0}, } - err := c.reconcileOne(context.Background(), "hana-v2", groupData, "az-a", nil, nil, nil) - if err == nil { - t.Error("expected error for zero-memory flavor") + // reconcileAZ logs and skips groups with zero memory; it does not return an error. + err := c.reconcileAZ(context.Background(), "az-a", + map[string]compute.FlavorGroupFeature{"hana-v2": groupData}, + nil, nil, nil) + if err != nil { + t.Errorf("reconcileAZ should not return error for zero-memory flavor, got: %v", err) + } + + // No CRD should have been created. + var list v1alpha1.FlavorGroupCapacityList + if err := fakeClient.List(context.Background(), &list); err != nil { + t.Fatalf("failed to list CRDs: %v", err) + } + if len(list.Items) != 0 { + t.Errorf("expected 0 CRDs, got %d", len(list.Items)) } } @@ -619,17 +646,17 @@ func TestSumCommittedCapacity(t *testing.T) { scheme := newTestScheme(t) objects := []client.Object{ - // Should count: confirmed, memory, right group+AZ, AcceptedAmount set + // Should count: confirmed, memory, right group+AZ, AcceptedAmount set. newCR("cr1", groupName, az, v1alpha1.CommitmentStatusConfirmed, v1alpha1.CommittedResourceTypeMemory, "8Gi", "8Gi"), - // Should count: guaranteed, memory, right group+AZ, no AcceptedAmount โ†’ falls back to Spec.Amount + // Should count: guaranteed, memory, right group+AZ, no AcceptedAmount โ†’ falls back to Spec.Amount. newCR("cr2", groupName, az, v1alpha1.CommitmentStatusGuaranteed, v1alpha1.CommittedResourceTypeMemory, "4Gi", ""), - // Should NOT count: wrong state + // Should NOT count: wrong state. newCR("cr3", groupName, az, v1alpha1.CommitmentStatusPlanned, v1alpha1.CommittedResourceTypeMemory, "4Gi", ""), - // Should NOT count: wrong resource type + // Should NOT count: wrong resource type. newCR("cr4", groupName, az, v1alpha1.CommitmentStatusConfirmed, v1alpha1.CommittedResourceTypeCores, "4Gi", ""), - // Should NOT count: wrong AZ + // Should NOT count: wrong AZ. newCR("cr5", groupName, "other-az", v1alpha1.CommitmentStatusConfirmed, v1alpha1.CommittedResourceTypeMemory, "4Gi", ""), - // Should NOT count: wrong flavor group + // Should NOT count: wrong flavor group. newCR("cr6", "other-group", az, v1alpha1.CommitmentStatusConfirmed, v1alpha1.CommittedResourceTypeMemory, "4Gi", ""), } @@ -638,8 +665,8 @@ func TestSumCommittedCapacity(t *testing.T) { WithObjects(objects...). Build() - c := NewController(fakeClient, Config{}) - // smallestFlavorBytes = 4GiB โ†’ cr1 = 8GiB/4GiB = 2 slots, cr2 = 4GiB/4GiB = 1 slot โ†’ total = 3 + c := newController(t, fakeClient, Config{}) + // smallestFlavorBytes = 4GiB โ†’ cr1 = 8GiB/4GiB = 2 slots, cr2 = 4GiB/4GiB = 1 slot โ†’ total = 3. got, err := c.sumCommittedCapacity(context.Background(), groupName, az, memBytes) if err != nil { t.Fatalf("sumCommittedCapacity failed: %v", err) @@ -667,12 +694,12 @@ func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { srv := newMockSchedulerServer(t, []string{"host-1"}) defer srv.Close() - c := NewController(fakeClient, Config{SchedulerURL: srv.URL}) + c := newController(t, fakeClient, Config{SchedulerURL: srv.URL}) hvByName := map[string]hv1.Hypervisor{"host-1": *hv} flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} // Total probe: raw 3 slots, no subtraction. - totalCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) + totalCap, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) if err != nil { t.Fatalf("probeScheduler (total) failed: %v", err) } @@ -684,7 +711,7 @@ func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { blockedByReservations := map[string]int64{ "host-1": memBytes, // 1 reservation blocking 1 slot's worth of memory } - placeableCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, blockedByReservations) + placeableCap, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, blockedByReservations) if err != nil { t.Fatalf("probeScheduler (placeable) failed: %v", err) } diff --git a/internal/scheduling/reservations/capacity/metrics.go b/internal/scheduling/reservations/capacity/metrics.go index f282cc9d0..27293e0eb 100644 --- a/internal/scheduling/reservations/capacity/metrics.go +++ b/internal/scheduling/reservations/capacity/metrics.go @@ -20,12 +20,17 @@ var ( // Monitor provides Prometheus metrics for FlavorGroupCapacity CRDs. // It implements prometheus.Collector and reads CRD status on each Collect call. type Monitor struct { - client client.Client - vmSlotsEmpty *prometheus.GaugeVec - vmSlotsPlaceable *prometheus.GaugeVec - hostsEmpty *prometheus.GaugeVec - hostsPlaceable *prometheus.GaugeVec - committedCapacity *prometheus.GaugeVec + client client.Client + vmSlotsEmpty *prometheus.GaugeVec + vmSlotsPlaceable *prometheus.GaugeVec + hostsEmpty *prometheus.GaugeVec + hostsPlaceable *prometheus.GaugeVec + committedCapacityGiB *prometheus.GaugeVec + committedReservations *prometheus.GaugeVec + runningInstances *prometheus.GaugeVec + freeCapacityGiB *prometheus.GaugeVec + exclusivelyFreeCapacityGiB *prometheus.GaugeVec + exclusivelyFreeSlots *prometheus.GaugeVec } // NewMonitor creates a new Monitor that reads FlavorGroupCapacity CRDs. @@ -48,10 +53,30 @@ func NewMonitor(c client.Client) Monitor { Name: "cortex_committed_resource_capacity_hosts_placeable", Help: "Number of hosts still able to accept a new VM of this flavor.", }, capacityFlavorLabels), - committedCapacity: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + committedCapacityGiB: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "cortex_committed_resource_committed_gib", - Help: "Sum of AcceptedAmount in GiB across Ready CommittedResource CRDs for this flavor group and AZ.", + Help: "Total committed memory in GiB for this flavor group and AZ.", }, capacityLabels), + committedReservations: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "cortex_committed_resource_committed_reservations", + Help: "Number of committed reservation slots (smallest-flavor units) for this flavor group and AZ.", + }, capacityLabels), + runningInstances: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "cortex_committed_resource_running_instances", + Help: "Number of running VMs whose flavor belongs to this flavor group and AZ.", + }, capacityFlavorLabels), + freeCapacityGiB: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "cortex_committed_resource_free_capacity_gib", + Help: "Sum of remaining memory in GiB across all candidate hosts for this flavor group before the cross-group split. May overlap across groups sharing hosts.", + }, capacityFlavorLabels), + exclusivelyFreeCapacityGiB: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "cortex_committed_resource_exclusively_free_capacity_gib", + Help: "Memory in GiB fairly attributed to this flavor group by the round-robin split. Sum across groups never exceeds installed capacity.", + }, capacityFlavorLabels), + exclusivelyFreeSlots: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "cortex_committed_resource_exclusively_free_slots", + Help: "Number of smallest-flavor VM slots available after the cross-group capacity split.", + }, capacityFlavorLabels), } } @@ -61,7 +86,12 @@ func (m *Monitor) Describe(ch chan<- *prometheus.Desc) { m.vmSlotsPlaceable.Describe(ch) m.hostsEmpty.Describe(ch) m.hostsPlaceable.Describe(ch) - m.committedCapacity.Describe(ch) + m.committedCapacityGiB.Describe(ch) + m.committedReservations.Describe(ch) + m.runningInstances.Describe(ch) + m.freeCapacityGiB.Describe(ch) + m.exclusivelyFreeCapacityGiB.Describe(ch) + m.exclusivelyFreeSlots.Describe(ch) } // Collect implements prometheus.Collector โ€” lists all FlavorGroupCapacity CRDs and exports gauges. @@ -79,14 +109,34 @@ func (m *Monitor) Collect(ch chan<- prometheus.Metric) { m.vmSlotsPlaceable.Reset() m.hostsEmpty.Reset() m.hostsPlaceable.Reset() - m.committedCapacity.Reset() + m.committedCapacityGiB.Reset() + m.committedReservations.Reset() + m.runningInstances.Reset() + m.freeCapacityGiB.Reset() + m.exclusivelyFreeCapacityGiB.Reset() + m.exclusivelyFreeSlots.Reset() for _, crd := range list.Items { groupAZLabels := prometheus.Labels{ "flavor_group": crd.Spec.FlavorGroup, "az": crd.Spec.AvailabilityZone, } - m.committedCapacity.With(groupAZLabels).Set(float64(crd.Status.CommittedCapacity)) + groupAZFlavorLabels := prometheus.Labels{ + "flavor_group": crd.Spec.FlavorGroup, + "az": crd.Spec.AvailabilityZone, + "flavor_name": crd.Status.SmallestFlavorName, + } + m.committedCapacityGiB.With(groupAZLabels).Set(float64(crd.Status.CommittedCapacityBytes) / (1024 * 1024 * 1024)) + m.committedReservations.With(groupAZLabels).Set(float64(crd.Status.CommittedCapacity)) + m.runningInstances.With(groupAZFlavorLabels).Set(float64(crd.Status.RunningInstances)) + + if qty, ok := crd.Status.FreeCapacity[string(v1alpha1.CommittedResourceTypeMemory)]; ok { + m.freeCapacityGiB.With(groupAZFlavorLabels).Set(float64(qty.Value()) / (1024 * 1024 * 1024)) + } + if qty, ok := crd.Status.ExclusivelyFreeCapacity[string(v1alpha1.CommittedResourceTypeMemory)]; ok { + m.exclusivelyFreeCapacityGiB.With(groupAZFlavorLabels).Set(float64(qty.Value()) / (1024 * 1024 * 1024)) + } + m.exclusivelyFreeSlots.With(groupAZFlavorLabels).Set(float64(crd.Status.ExclusivelyFreeSlots)) for _, f := range crd.Status.Flavors { flavorLabels := prometheus.Labels{ @@ -105,5 +155,10 @@ func (m *Monitor) Collect(ch chan<- prometheus.Metric) { m.vmSlotsPlaceable.Collect(ch) m.hostsEmpty.Collect(ch) m.hostsPlaceable.Collect(ch) - m.committedCapacity.Collect(ch) + m.committedCapacityGiB.Collect(ch) + m.committedReservations.Collect(ch) + m.runningInstances.Collect(ch) + m.freeCapacityGiB.Collect(ch) + m.exclusivelyFreeCapacityGiB.Collect(ch) + m.exclusivelyFreeSlots.Collect(ch) } diff --git a/internal/scheduling/reservations/capacity/split.go b/internal/scheduling/reservations/capacity/split.go new file mode 100644 index 000000000..ebeefb5bc --- /dev/null +++ b/internal/scheduling/reservations/capacity/split.go @@ -0,0 +1,259 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package capacity + +import "sort" + +// Resource keys used for capacity splitting. Match CommittedResourceType constants. +const ( + ResourceMemory = "memory" + ResourceCores = "cores" +) + +type GroupInput struct { + Name string + FlavorResources map[string]int64 + CandidateHosts []string +} + +type HostState struct { + Remaining map[string]int64 +} + +type groupState struct { + input GroupInput + remaining []string + assignedCount int64 +} + +func fits(flavorRes, hostRemaining map[string]int64) bool { + for r, needed := range flavorRes { + if hostRemaining[r] < needed { + return false + } + } + return true +} + +// sortGroups re-orders states in-place by the round-robin priority: +// 1. ASC number of remaining candidate hosts (fewest candidates first) +// 2. DESC flavor memory (larger flavor first) +// 3. DESC flavor cores +// 4. ASC group name (stable tiebreaker) +func sortGroups(states []groupState) { + sort.SliceStable(states, func(i, j int) bool { + a, b := states[i], states[j] + if len(a.remaining) != len(b.remaining) { + return len(a.remaining) < len(b.remaining) + } + if a.input.FlavorResources[ResourceMemory] != b.input.FlavorResources[ResourceMemory] { + return a.input.FlavorResources[ResourceMemory] > b.input.FlavorResources[ResourceMemory] + } + if a.input.FlavorResources[ResourceCores] != b.input.FlavorResources[ResourceCores] { + return a.input.FlavorResources[ResourceCores] > b.input.FlavorResources[ResourceCores] + } + return a.input.Name < b.input.Name + }) +} + +// bestHost returns the name of the host to consume next from remaining. +// Sort order: least (remaining_mem % flavor_mem), then least remaining memory, then least CPU, then host name. +func bestHost(remaining []string, hostRes map[string]map[string]int64, flavorRes map[string]int64) string { + flavorMem := flavorRes[ResourceMemory] + best := remaining[0] + for _, h := range remaining[1:] { + bMem := hostRes[best][ResourceMemory] + hMem := hostRes[h][ResourceMemory] + + var hWaste, bWaste int64 + if flavorMem > 0 { + hWaste = hMem % flavorMem + bWaste = bMem % flavorMem + } + if hWaste != bWaste { + if hWaste < bWaste { + best = h + } + continue + } + if hMem != bMem { + if hMem < bMem { + best = h + } + continue + } + hCPU := hostRes[h][ResourceCores] + bCPU := hostRes[best][ResourceCores] + if hCPU < bCPU || (hCPU == bCPU && h < best) { + best = h + } + } + return best +} + +// initGroupStates builds the initial per-group state: candidates filtered to hosts that fit the flavor. +func initGroupStates(groups []GroupInput, hosts map[string]HostState) []groupState { + states := make([]groupState, len(groups)) + for i, g := range groups { + remaining := make([]string, 0, len(g.CandidateHosts)) + for _, h := range g.CandidateHosts { + if hs, ok := hosts[h]; ok && fits(g.FlavorResources, hs.Remaining) { + remaining = append(remaining, h) + } + } + sort.Strings(remaining) + states[i] = groupState{input: g, remaining: remaining} + } + return states +} + +// computeFreeResources returns the usable capacity per group before any allocation. +// Uses floor(remaining/flavorSize)*flavorSize per host โ€” binding resource determines slot count. +func computeFreeResources(groups []GroupInput, hosts map[string]HostState) map[string]map[string]int64 { + free := make(map[string]map[string]int64, len(groups)) + for _, g := range groups { + res := make(map[string]int64) + for _, h := range g.CandidateHosts { + hs, ok := hosts[h] + if !ok { + continue + } + slots, initialized := int64(0), false + for r, need := range g.FlavorResources { + if need <= 0 { + continue + } + s := hs.Remaining[r] / need + if !initialized || s < slots { + slots, initialized = s, true + } + } + if !initialized || slots == 0 { + continue + } + for r, need := range g.FlavorResources { + res[r] += slots * need + } + } + free[g.Name] = res + } + return free +} + +// copyHostResources returns a mutable copy of the host resource map. +func copyHostResources(hosts map[string]HostState) map[string]map[string]int64 { + hostRes := make(map[string]map[string]int64, len(hosts)) + for name, hs := range hosts { + res := make(map[string]int64, len(hs.Remaining)) + for r, v := range hs.Remaining { + res[r] = v + } + hostRes[name] = res + } + return hostRes +} + +// allocateRoundRobin performs the round-robin capacity assignment in-place on hostRes. +// Each round assigns one flavor-sized allocation per group in priority order until no +// group has eligible hosts. +func allocateRoundRobin(states []groupState, hostRes map[string]map[string]int64) { + for { + sortGroups(states) + + progress := false + for i := range states { + g := &states[i] + if len(g.remaining) == 0 { + continue + } + + chosen := bestHost(g.remaining, hostRes, g.input.FlavorResources) + for r, amount := range g.input.FlavorResources { + hostRes[chosen][r] -= amount + } + g.assignedCount++ + progress = true + + // Only `chosen` changed โ€” drop it from any group that can no longer fit it. + for j := range states { + if fits(states[j].input.FlavorResources, hostRes[chosen]) { + continue + } + filtered := make([]string, 0, len(states[j].remaining)) + for _, h := range states[j].remaining { + if h != chosen { + filtered = append(filtered, h) + } + } + states[j].remaining = filtered + } + } + + if !progress { + break + } + } +} + +// computeUnassigned sums remaining resources on candidate hosts after allocation. +// Non-candidate hosts are excluded โ€” their leftover is not fragmentation. +func computeUnassigned(groups []GroupInput, hostRes map[string]map[string]int64) map[string]int64 { + candidateSet := make(map[string]struct{}) + for _, g := range groups { + for _, h := range g.CandidateHosts { + candidateSet[h] = struct{}{} + } + } + unassigned := make(map[string]int64) + for h, res := range hostRes { + if _, isCandidate := candidateSet[h]; !isCandidate { + continue + } + for r, remaining := range res { + if remaining > 0 { + unassigned[r] += remaining + } + } + } + return unassigned +} + +// collectExclusiveResources builds the exclusive allocation map from group assigned counts. +func collectExclusiveResources(states []groupState) map[string]map[string]int64 { + exclusive := make(map[string]map[string]int64, len(states)) + for _, g := range states { + resources := make(map[string]int64, len(g.input.FlavorResources)) + for r, amount := range g.input.FlavorResources { + resources[r] = g.assignedCount * amount + } + exclusive[g.input.Name] = resources + } + return exclusive +} + +// SplitCapacity runs the round-robin capacity assignment algorithm. +// +// For each AZ it assigns resources (in raw units โ€” bytes for memory, count for cores) +// to flavor groups in a fair, deterministic way such that no host is over-committed. +// Groups sharing hypervisors are served round-robin so no group monopolises shared hosts. +// +// Returns: +// - freeResources[groupName][resource]: sum of remaining resources across all candidate +// hosts for each group before the split. May overlap across groups sharing hosts. +// - exclusiveResources[groupName][resource]: fairly attributed share after the split; +// sum across groups never exceeds actual installed capacity. +// - unassigned[resource]: resources on candidate hosts not claimed by any group due to +// fragmentation (for operator log visibility). +// +// The caller divides exclusiveResources[group][ResourceMemory] by the group's flavor memory +// to obtain the slot count meaningful to that group. +func SplitCapacity(groups []GroupInput, hosts map[string]HostState) (freeResources, exclusiveResources map[string]map[string]int64, unassigned map[string]int64) { + states := initGroupStates(groups, hosts) + freeResources = computeFreeResources(groups, hosts) + hostRes := copyHostResources(hosts) + allocateRoundRobin(states, hostRes) + unassigned = computeUnassigned(groups, hostRes) + exclusiveResources = collectExclusiveResources(states) + return freeResources, exclusiveResources, unassigned +} diff --git a/internal/scheduling/reservations/capacity/split_test.go b/internal/scheduling/reservations/capacity/split_test.go new file mode 100644 index 000000000..a4b1ff212 --- /dev/null +++ b/internal/scheduling/reservations/capacity/split_test.go @@ -0,0 +1,535 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package capacity + +import ( + "testing" +) + +const GiB = 1024 * 1024 * 1024 + +func flavor(memBytes, cores int64) map[string]int64 { + return map[string]int64{ResourceMemory: memBytes, ResourceCores: cores} +} + +func host(memBytes, cores int64) HostState { + return HostState{Remaining: map[string]int64{ResourceMemory: memBytes, ResourceCores: cores}} +} + +func TestFits(t *testing.T) { + tests := []struct { + name string + flavor map[string]int64 + remaining map[string]int64 + want bool + }{ + { + name: "all resources fit", + flavor: flavor(4*GiB, 2), + remaining: map[string]int64{ResourceMemory: 8 * GiB, ResourceCores: 4}, + want: true, + }, + { + name: "memory too small", + flavor: flavor(4*GiB, 2), + remaining: map[string]int64{ResourceMemory: 3 * GiB, ResourceCores: 4}, + want: false, + }, + { + name: "CPU too small", + flavor: flavor(4*GiB, 2), + remaining: map[string]int64{ResourceMemory: 8 * GiB, ResourceCores: 1}, + want: false, + }, + { + name: "exact fit", + flavor: flavor(4*GiB, 2), + remaining: map[string]int64{ResourceMemory: 4 * GiB, ResourceCores: 2}, + want: true, + }, + { + name: "zero CPU in flavor always fits on CPU", + flavor: map[string]int64{ResourceMemory: 4 * GiB, ResourceCores: 0}, + remaining: map[string]int64{ResourceMemory: 4 * GiB, ResourceCores: 0}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := fits(tc.flavor, tc.remaining); got != tc.want { + t.Errorf("fits() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestInitGroupStates(t *testing.T) { + tests := []struct { + name string + groups []GroupInput + hosts map[string]HostState + wantRemaining map[string][]string // group name โ†’ expected remaining hosts + }{ + { + name: "eligible hosts included", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(8*GiB, 4), + "h2": host(2*GiB, 4), // too small + }, + wantRemaining: map[string][]string{"g1": {"h1"}}, + }, + { + name: "CPU exhausted host excluded", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{ + "h1": host(8*GiB, 0), // zero CPU + }, + wantRemaining: map[string][]string{"g1": {}}, + }, + { + name: "remaining hosts sorted for stable order", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"hb", "ha", "hc"}}, + }, + hosts: map[string]HostState{ + "ha": host(8*GiB, 4), + "hb": host(8*GiB, 4), + "hc": host(8*GiB, 4), + }, + wantRemaining: map[string][]string{"g1": {"ha", "hb", "hc"}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + states := initGroupStates(tc.groups, tc.hosts) + if len(states) != len(tc.groups) { + t.Fatalf("initGroupStates returned %d states, want %d (one per group)", len(states), len(tc.groups)) + } + for _, st := range states { + want := tc.wantRemaining[st.input.Name] + if len(st.remaining) != len(want) { + t.Errorf("group %s: remaining = %v, want %v", st.input.Name, st.remaining, want) + continue + } + for i := range want { + if st.remaining[i] != want[i] { + t.Errorf("group %s: remaining[%d] = %q, want %q", st.input.Name, i, st.remaining[i], want[i]) + } + } + } + }) + } +} + +func TestComputeFreeResources(t *testing.T) { + tests := []struct { + name string + groups []GroupInput + hosts map[string]HostState + wantFreeMem map[string]int64 + wantFreeCores map[string]int64 // optional; only set when exercising CPU binding + }{ + { + name: "full slots counted", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{"h1": host(8*GiB, 4)}, + wantFreeMem: map[string]int64{"g1": 8 * GiB}, + wantFreeCores: map[string]int64{"g1": 4}, + }, + { + name: "sub-flavor remainder excluded", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{"h1": host(6*GiB, 8)}, + wantFreeMem: map[string]int64{"g1": 4 * GiB}, + }, + { + // Memory allows 4 slots, CPU allows 1 โ†’ 1 slot usable for both resources. + name: "CPU is binding constraint", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{"h1": host(16*GiB, 2)}, + wantFreeMem: map[string]int64{"g1": 4 * GiB}, + wantFreeCores: map[string]int64{"g1": 2}, + }, + { + name: "host below flavor threshold contributes nothing", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{"h1": host(3*GiB, 4)}, + wantFreeMem: map[string]int64{"g1": 0}, + }, + { + name: "two candidate hosts summed", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(8*GiB, 4), + "h2": host(4*GiB, 2), + }, + wantFreeMem: map[string]int64{"g1": 12 * GiB}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + free := computeFreeResources(tc.groups, tc.hosts) + for group, wantMem := range tc.wantFreeMem { + if got := free[group][ResourceMemory]; got != wantMem { + t.Errorf("free[%s][memory] = %d, want %d", group, got, wantMem) + } + } + for group, wantCores := range tc.wantFreeCores { + if got := free[group][ResourceCores]; got != wantCores { + t.Errorf("free[%s][cores] = %d, want %d", group, got, wantCores) + } + } + }) + } +} + +func TestComputeUnassigned(t *testing.T) { + tests := []struct { + name string + groups []GroupInput + hostRes map[string]map[string]int64 + wantUnassigned map[string]int64 + }{ + { + name: "candidate leftover counted", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hostRes: map[string]map[string]int64{"h1": {ResourceMemory: 2 * GiB, ResourceCores: 0}}, + wantUnassigned: map[string]int64{ResourceMemory: 2 * GiB}, + }, + { + name: "non-candidate host excluded", + groups: []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hostRes: map[string]map[string]int64{ + "h1": {ResourceMemory: 0}, + "h2": {ResourceMemory: 8 * GiB}, // not a candidate + }, + wantUnassigned: map[string]int64{ResourceMemory: 0}, + }, + { + name: "nothing unassigned when fully used", + groups: []GroupInput{{Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}}, + hostRes: map[string]map[string]int64{"h1": {ResourceMemory: 0}}, + wantUnassigned: map[string]int64{ResourceMemory: 0}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := computeUnassigned(tc.groups, tc.hostRes) + for r, want := range tc.wantUnassigned { + if got[r] != want { + t.Errorf("unassigned[%s] = %d, want %d", r, got[r], want) + } + } + }) + } +} + +func TestBestHost(t *testing.T) { + tests := []struct { + name string + remaining []string + hostRes map[string]map[string]int64 + flavorRes map[string]int64 + want string + }{ + { + name: "lower modulo remainder preferred", + remaining: []string{"h1", "h2"}, + hostRes: map[string]map[string]int64{ + "h1": {ResourceMemory: 5 * GiB, ResourceCores: 8}, + "h2": {ResourceMemory: 8 * GiB, ResourceCores: 4}, // 8%4=0 waste + }, + flavorRes: flavor(4*GiB, 2), + want: "h2", + }, + { + name: "equal waste: prefer less remaining memory", + remaining: []string{"h1", "h2"}, + hostRes: map[string]map[string]int64{ + "h1": {ResourceMemory: 8 * GiB, ResourceCores: 4}, + "h2": {ResourceMemory: 4 * GiB, ResourceCores: 4}, // less remaining + }, + flavorRes: flavor(4*GiB, 2), + want: "h2", + }, + { + name: "equal memory: prefer less CPU", + remaining: []string{"h1", "h2"}, + hostRes: map[string]map[string]int64{ + "h1": {ResourceMemory: 4 * GiB, ResourceCores: 8}, + "h2": {ResourceMemory: 4 * GiB, ResourceCores: 4}, // fewer cores + }, + flavorRes: flavor(4*GiB, 2), + want: "h2", + }, + { + name: "all equal: lexicographic tiebreaker", + remaining: []string{"hb", "ha"}, + hostRes: map[string]map[string]int64{ + "ha": {ResourceMemory: 4 * GiB, ResourceCores: 2}, + "hb": {ResourceMemory: 4 * GiB, ResourceCores: 2}, + }, + flavorRes: flavor(4*GiB, 2), + want: "ha", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := bestHost(tc.remaining, tc.hostRes, tc.flavorRes); got != tc.want { + t.Errorf("bestHost() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestSplitCapacity covers the full round-robin assignment algorithm end-to-end. +func TestSplitCapacity(t *testing.T) { + tests := []struct { + name string + groups []GroupInput + hosts map[string]HostState + wantAssignedMem map[string]int64 + wantAssignedCores map[string]int64 // optional + wantUnassignedMem int64 + wantUnassignedCores int64 // optional + wantFreeMem map[string]int64 // optional + }{ + { + name: "single group, two hosts", + groups: []GroupInput{ + {Name: "hana", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(8*GiB, 4), // 2 slots + "h2": host(4*GiB, 2), // 1 slot + }, + wantAssignedMem: map[string]int64{"hana": 3 * 4 * GiB}, + wantAssignedCores: map[string]int64{"hana": 3 * 2}, + wantUnassignedMem: 0, + }, + { + name: "disjoint groups, each with own host", + groups: []GroupInput{ + {Name: "gp", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + {Name: "hana", FlavorResources: flavor(8*GiB, 4), CandidateHosts: []string{"h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(8*GiB, 4), + "h2": host(8*GiB, 4), + }, + wantAssignedMem: map[string]int64{"gp": 8 * GiB, "hana": 8 * GiB}, + wantUnassignedMem: 0, + }, + { + // Both groups share the same host; round-robin gives each one slot. + name: "overlapping groups, fair round-robin split", + groups: []GroupInput{ + {Name: "alpha", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"shared"}}, + {Name: "beta", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"shared"}}, + }, + hosts: map[string]HostState{ + "shared": host(8*GiB, 4), + }, + wantAssignedMem: map[string]int64{"alpha": 4 * GiB, "beta": 4 * GiB}, + wantUnassignedMem: 0, + }, + { + // "constrained" has only one candidate host; fewer candidates โ†’ served first. + name: "overlapping groups, constrained group served first", + groups: []GroupInput{ + {Name: "constrained", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"shared"}}, + {Name: "free", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"shared", "exclusive"}}, + }, + hosts: map[string]HostState{ + "shared": host(4*GiB, 2), + "exclusive": host(8*GiB, 4), + }, + wantAssignedMem: map[string]int64{"constrained": 4 * GiB, "free": 8 * GiB}, + wantUnassignedMem: 0, + }, + { + // Host has memory but no CPU โ€” flavor requires both, so host is ineligible. + name: "CPU exhausted host dropped", + groups: []GroupInput{ + {Name: "gp", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"cpu-full"}}, + }, + hosts: map[string]HostState{ + "cpu-full": host(16*GiB, 0), + }, + wantAssignedMem: map[string]int64{"gp": 0}, + wantUnassignedMem: 16 * GiB, + wantUnassignedCores: 0, // CPU was zero, so nothing to strand on that axis + }, + { + name: "host too small for flavor", + groups: []GroupInput{ + {Name: "hana", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{ + "h1": host(3*GiB, 4), + }, + wantAssignedMem: map[string]int64{"hana": 0}, + wantUnassignedMem: 3 * GiB, + }, + { + // Same candidate count โ†’ tiebreak on larger flavor first: hana8 goes before gp4. + name: "larger flavor served first on candidate-count tie", + groups: []GroupInput{ + {Name: "hana8", FlavorResources: flavor(8*GiB, 4), CandidateHosts: []string{"h1"}}, + {Name: "gp4", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}, + }, + hosts: map[string]HostState{ + "h1": host(12*GiB, 8), + }, + wantAssignedMem: map[string]int64{"hana8": 8 * GiB, "gp4": 4 * GiB}, + wantUnassignedMem: 0, + }, + { + // h2 chosen first (0 waste); 1 GiB strands on h1 after its one slot. + name: "host selection prefers lower modulo remainder", + groups: []GroupInput{ + {Name: "gp", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(5*GiB, 8), + "h2": host(8*GiB, 4), + }, + wantAssignedMem: map[string]int64{"gp": 3 * 4 * GiB}, + wantUnassignedMem: 1 * GiB, + }, + { + // h1: floor(6/4)*4 = 4 GiB usable; h2: below threshold โ†’ 0. + name: "free capacity excludes sub-flavor remainder", + groups: []GroupInput{ + {Name: "gp", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + }, + hosts: map[string]HostState{ + "h1": host(6*GiB, 8), + "h2": host(3*GiB, 4), + }, + wantAssignedMem: map[string]int64{"gp": 4 * GiB}, + wantUnassignedMem: 2*GiB + 3*GiB, + wantFreeMem: map[string]int64{"gp": 4 * GiB}, + }, + { + name: "no groups", + groups: nil, + hosts: map[string]HostState{"h1": host(8*GiB, 16)}, + wantAssignedMem: map[string]int64{}, + wantUnassignedMem: 0, + }, + { + name: "no hosts", + groups: []GroupInput{{Name: "hana", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1"}}}, + hosts: map[string]HostState{}, + wantAssignedMem: map[string]int64{"hana": 0}, + wantUnassignedMem: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + free, assigned, unassigned := SplitCapacity(tc.groups, tc.hosts) + + for groupName, wantMem := range tc.wantAssignedMem { + if got := assigned[groupName][ResourceMemory]; got != wantMem { + t.Errorf("assigned[%s][memory] = %d, want %d", groupName, got, wantMem) + } + } + for groupName, wantCores := range tc.wantAssignedCores { + if got := assigned[groupName][ResourceCores]; got != wantCores { + t.Errorf("assigned[%s][cores] = %d, want %d", groupName, got, wantCores) + } + } + if got := unassigned[ResourceMemory]; got != tc.wantUnassignedMem { + t.Errorf("unassigned[memory] = %d, want %d", got, tc.wantUnassignedMem) + } + if tc.wantUnassignedCores != 0 { + if got := unassigned[ResourceCores]; got != tc.wantUnassignedCores { + t.Errorf("unassigned[cores] = %d, want %d", got, tc.wantUnassignedCores) + } + } + for groupName, wantMem := range tc.wantFreeMem { + if got := free[groupName][ResourceMemory]; got != wantMem { + t.Errorf("free[%s][memory] = %d, want %d", groupName, got, wantMem) + } + } + }) + } +} + +// TestSplitCapacity_SumNeverExceedsTotal is a property test: total assigned memory +// must never exceed total available memory across all hosts. +func TestSplitCapacity_SumNeverExceedsTotal(t *testing.T) { + groups := []GroupInput{ + {Name: "g1", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2", "h3"}}, + {Name: "g2", FlavorResources: flavor(8*GiB, 4), CandidateHosts: []string{"h2", "h3"}}, + {Name: "g3", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h3"}}, + } + hosts := map[string]HostState{ + "h1": host(12*GiB, 6), + "h2": host(16*GiB, 8), + "h3": host(24*GiB, 12), + } + + _, assigned, _ := SplitCapacity(groups, hosts) + + var totalInstalled, totalAssigned int64 + for _, hs := range hosts { + totalInstalled += hs.Remaining[ResourceMemory] + } + for _, res := range assigned { + totalAssigned += res[ResourceMemory] + } + if totalAssigned > totalInstalled { + t.Errorf("totalAssigned (%d) > totalInstalled (%d): capacity overreported", totalAssigned, totalInstalled) + } +} + +// TestSplitCapacity_Deterministic verifies identical input always produces identical output. +func TestSplitCapacity_Deterministic(t *testing.T) { + groups := []GroupInput{ + {Name: "c", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h1", "h2"}}, + {Name: "a", FlavorResources: flavor(8*GiB, 4), CandidateHosts: []string{"h1", "h2"}}, + {Name: "b", FlavorResources: flavor(4*GiB, 2), CandidateHosts: []string{"h2"}}, + } + hosts := map[string]HostState{ + "h1": host(16*GiB, 8), + "h2": host(8*GiB, 4), + } + + _, first, firstUnassigned := SplitCapacity(groups, hosts) + for i := range 10 { + _, got, gotUnassigned := SplitCapacity(groups, hosts) + for _, g := range groups { + if got[g.Name][ResourceMemory] != first[g.Name][ResourceMemory] { + t.Errorf("run %d: assigned[%s][memory] = %d, want %d (non-deterministic)", + i, g.Name, got[g.Name][ResourceMemory], first[g.Name][ResourceMemory]) + } + } + if gotUnassigned[ResourceMemory] != firstUnassigned[ResourceMemory] { + t.Errorf("run %d: unassigned[memory] = %d, want %d (non-deterministic)", + i, gotUnassigned[ResourceMemory], firstUnassigned[ResourceMemory]) + } + } +} diff --git a/internal/scheduling/reservations/commitments/api/report_capacity.go b/internal/scheduling/reservations/commitments/api/report_capacity.go index b8cc67f73..5277d51d0 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity.go @@ -9,13 +9,27 @@ import ( "strconv" "time" + "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" ) -// handles POST /commitments/v1/report-capacity requests from Limes: +// unitSizeForResource returns the GiB value of one declared unit for the given resource name. +// For RAM resources it reads RAMUnitGiB from config (defaults to 1). Cores and instances are always 1. +func (api *HTTPAPI) unitSizeForResource(resName liquid.ResourceName) string { + group, resType, err := commitments.GetFlavorGroupAndTypeFromResource(string(resName)) + if err != nil || resType != v1alpha1.CommittedResourceTypeMemory { + return "1" + } + unitGiB := api.config.ResourceConfigForGroup(group).RAM.RAMUnitGiB + if unitGiB == 0 { + return "1" + } + return strconv.FormatUint(unitGiB, 10) +} + // See: https://github.com/sapcc/go-api-declarations/blob/main/liquid/commitment.go // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid // Reports available capacity across all flavor group resources. Note, unit is specified in the Info API response with multiple of the smallest memory resource unit within a flavor group. @@ -73,8 +87,9 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) // Update capacity gauge for each resource/AZ combination. for resName, resReport := range report.Resources { + unitSize := api.unitSizeForResource(resName) for az, azReport := range resReport.PerAZ { - api.capacityMonitor.reportedCapacity.WithLabelValues(string(resName), string(az)).Set(float64(azReport.Capacity)) + api.capacityMonitor.reportedCapacity.WithLabelValues(string(resName), string(az), unitSize).Set(float64(azReport.Capacity)) } } diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go b/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go index cb7b21336..05f1498f8 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go @@ -25,17 +25,15 @@ func NewReportCapacityAPIMonitor() ReportCapacityAPIMonitor { Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10}, }, []string{"status_code"}), reportedCapacity: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "cortex_committed_resource_reported_capacity_gib", - Help: "Last reported capacity in GiB per resource and availability zone as returned by the capacity API", - }, []string{"resource", "az"}), + Name: "cortex_committed_resource_reported_capacity", + Help: "Last reported capacity per resource and AZ as returned by the capacity API. unit_size indicates the GiB value of one declared unit (e.g. 480 for a HANA slot, 1 for variable-ratio RAM or cores).", + }, []string{"resource", "az", "unit_size"}), } for _, statusCode := range []string{"200", "500", "503"} { m.requestCounter.WithLabelValues(statusCode) m.requestDuration.WithLabelValues(statusCode) } - // resource/az are dynamic; sentinel ensures the metric family exists for alert validation. - m.reportedCapacity.WithLabelValues("", "") return m } diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go index 19117eb7b..530fe319f 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -14,93 +14,52 @@ import ( "testing" "github.com/sapcc/go-api-declarations/liquid" + "k8s.io/apimachinery/pkg/api/resource" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/cobaltcore-dev/cortex/api/v1alpha1" commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" ) +// defaultCapacityConfig enables all three resource types for all groups. +var defaultCapacityConfig = commitments.APIConfig{ + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": { + RAM: commitments.RAMResourceTypeConfig{HasCapacity: true}, + Cores: commitments.ResourceTypeConfig{HasCapacity: true}, + Instances: commitments.ResourceTypeConfig{HasCapacity: true}, + }, + }, +} + func TestHandleReportCapacity(t *testing.T) { - // Setup fake client scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatal(err) } - - // testCapacityConfig enables capacity reporting for all groups via "*" catch-all. - testCapacityConfig := commitments.APIConfig{ - EnableReportCapacity: true, - FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ - "*": { - RAM: commitments.RAMResourceTypeConfig{HasCapacity: true}, - Cores: commitments.ResourceTypeConfig{HasCapacity: true}, - Instances: commitments.ResourceTypeConfig{HasCapacity: true}, - }, - }, - } - - // Create empty flavor groups knowledge so capacity calculation doesn't fail - emptyKnowledge := createEmptyFlavorGroupKnowledge() - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(emptyKnowledge). - Build() - - api := NewAPIWithConfig(fakeClient, testCapacityConfig, nil) + api := NewAPIWithConfig( + fake.NewClientBuilder().WithScheme(scheme).WithObjects(createEmptyFlavorGroupKnowledge()).Build(), + commitments.APIConfig{EnableReportCapacity: true, FlavorGroupResourceConfig: defaultCapacityConfig.FlavorGroupResourceConfig}, + nil, + ) tests := []struct { name string method string - body interface{} + body any expectedStatus int - checkResponse func(*testing.T, *liquid.ServiceCapacityReport) }{ - { - name: "POST request succeeds", - method: http.MethodPost, - body: liquid.ServiceCapacityRequest{}, - expectedStatus: http.StatusOK, - checkResponse: func(t *testing.T, resp *liquid.ServiceCapacityReport) { - // Resources may be nil or empty for empty capacity - if len(resp.Resources) != 0 { - t.Errorf("Expected empty or nil Resources, got %d resources", len(resp.Resources)) - } - }, - }, - { - name: "POST with empty body succeeds", - method: http.MethodPost, - body: nil, - expectedStatus: http.StatusOK, - checkResponse: func(t *testing.T, resp *liquid.ServiceCapacityReport) { - // Resources may be nil or empty for empty capacity - if len(resp.Resources) != 0 { - t.Errorf("Expected empty or nil Resources, got %d resources", len(resp.Resources)) - } - }, - }, - { - name: "GET request fails", - method: http.MethodGet, - body: nil, - expectedStatus: http.StatusMethodNotAllowed, - checkResponse: nil, - }, - { - name: "PUT request fails", - method: http.MethodPut, - body: nil, - expectedStatus: http.StatusMethodNotAllowed, - checkResponse: nil, - }, + {name: "POST succeeds", method: http.MethodPost, body: liquid.ServiceCapacityRequest{}, expectedStatus: http.StatusOK}, + {name: "POST with empty body succeeds", method: http.MethodPost, body: nil, expectedStatus: http.StatusOK}, + {name: "GET fails", method: http.MethodGet, body: nil, expectedStatus: http.StatusMethodNotAllowed}, + {name: "PUT fails", method: http.MethodPut, body: nil, expectedStatus: http.StatusMethodNotAllowed}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create request var req *http.Request if tt.body != nil { bodyBytes, err := json.Marshal(tt.body) @@ -111,314 +70,305 @@ func TestHandleReportCapacity(t *testing.T) { } else { req = httptest.NewRequest(tt.method, "/commitments/v1/report-capacity", http.NoBody) } - req = req.WithContext(context.Background()) - - // Create response recorder rr := httptest.NewRecorder() - - // Call handler - api.HandleReportCapacity(rr, req) - - // Check status code + api.HandleReportCapacity(rr, req.WithContext(context.Background())) if rr.Code != tt.expectedStatus { - t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code) - } - - // Check response if applicable - if tt.checkResponse != nil && rr.Code == http.StatusOK { - var resp liquid.ServiceCapacityReport - if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - tt.checkResponse(t, &resp) + t.Errorf("status = %d, want %d", rr.Code, tt.expectedStatus) } }) } } +// TestCapacityCalculator covers calculator behavior across different scenarios. func TestCapacityCalculator(t *testing.T) { - // Setup fake client with Knowledge CRD scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatal(err) } - testCapacityConfig := commitments.APIConfig{ - FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ - "*": { - RAM: commitments.RAMResourceTypeConfig{HasCapacity: true}, - Cores: commitments.ResourceTypeConfig{HasCapacity: true}, - Instances: commitments.ResourceTypeConfig{HasCapacity: true}, + const flavorMemBytes = 32752 * 1024 * 1024 // test flavor: 32752 MiB + + newCalculator := func(objects ...client.Object) *commitments.CapacityCalculator { + return commitments.NewCapacityCalculator( + fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...). + WithStatusSubresource(&v1alpha1.FlavorGroupCapacity{}).Build(), + defaultCapacityConfig, + ) + } + + tests := []struct { + name string + checkFn func(t *testing.T) + }{ + { + name: "no knowledge โ†’ error", + checkFn: func(t *testing.T) { + _, err := newCalculator().CalculateCapacity(context.Background(), + liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("expected not-found error, got %v", err) + } + }, + }, + { + name: "empty knowledge โ†’ 0 resources", + checkFn: func(t *testing.T) { + report, err := newCalculator(createEmptyFlavorGroupKnowledge()).CalculateCapacity( + context.Background(), liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}}) + if err != nil { + t.Fatal(err) + } + if len(report.Resources) != 0 { + t.Errorf("expected 0 resources, got %d", len(report.Resources)) + } + }, + }, + { + name: "knowledge only โ†’ perAZ entries match requested AZs", + checkFn: func(t *testing.T) { + azs := []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"} + report, err := newCalculator(createTestFlavorGroupKnowledge(t)).CalculateCapacity( + context.Background(), liquid.ServiceCapacityRequest{AllAZs: azs}) + if err != nil { + t.Fatal(err) + } + if len(report.Resources) != 3 { + t.Fatalf("expected 3 resources, got %d", len(report.Resources)) + } + for _, res := range report.Resources { + verifyPerAZMatchesRequest(t, res, azs) + } + }, + }, + { + name: "empty AllAZs โ†’ empty perAZ maps", + checkFn: func(t *testing.T) { + report, err := newCalculator(createTestFlavorGroupKnowledge(t)).CalculateCapacity( + context.Background(), liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}}) + if err != nil { + t.Fatal(err) + } + for name, res := range report.Resources { + if len(res.PerAZ) != 0 { + t.Errorf("%s: expected empty PerAZ, got %d entries", name, len(res.PerAZ)) + } + } + }, + }, + { + name: "different AZ sets each get their own entries", + checkFn: func(t *testing.T) { + calc := newCalculator(createTestFlavorGroupKnowledge(t)) + req1 := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"}} + req2 := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"us-west-1a", "us-west-1b", "us-west-1c"}} + for _, req := range []liquid.ServiceCapacityRequest{req1, req2} { + report, err := calc.CalculateCapacity(context.Background(), req) + if err != nil { + t.Fatal(err) + } + for _, res := range report.Resources { + verifyPerAZMatchesRequest(t, res, req.AllAZs) + } + } }, }, } - t.Run("CalculateCapacity returns error when no flavor groups knowledge exists", func(t *testing.T) { - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{ - AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, - } - _, err := calculator.CalculateCapacity(context.Background(), req) - if err == nil { - t.Fatal("Expected error when flavor groups knowledge doesn't exist, got nil") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("Expected 'not found' error, got: %v", err) - } - }) - - t.Run("CalculateCapacity returns empty report when flavor groups knowledge exists but is empty", func(t *testing.T) { - // Create empty flavor groups knowledge - emptyKnowledge := createEmptyFlavorGroupKnowledge() - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(emptyKnowledge). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{ - AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, - } - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if report.Resources == nil { - t.Error("Expected Resources map to be initialized") - } - - if len(report.Resources) != 0 { - t.Errorf("Expected 0 resources, got %d", len(report.Resources)) - } - }) - - t.Run("CalculateCapacity returns perAZ entries for all AZs from request", func(t *testing.T) { - flavorGroupKnowledge := createTestFlavorGroupKnowledge(t) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(flavorGroupKnowledge). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{ - AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"}, - } - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if len(report.Resources) != 3 { - t.Fatalf("Expected 3 resources (_ram, _cores, _instances), got %d", len(report.Resources)) - } - - // Verify all resources have exactly the requested AZs - verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_ram"], req.AllAZs) - verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_cores"], req.AllAZs) - verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_instances"], req.AllAZs) - }) - - t.Run("CalculateCapacity with empty AllAZs returns empty perAZ maps", func(t *testing.T) { - flavorGroupKnowledge := createTestFlavorGroupKnowledge(t) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(flavorGroupKnowledge). - Build() + // CRD-value cases: all use fixed-ratio knowledge + one CRD for az-one. + type crdValueCase struct { + name string + runningInstances int64 + exclusiveFreeBytes int64 + ready bool + checkAZ liquid.AvailabilityZone + wantCapacity uint64 + wantUsage *uint64 // nil = expect absent + cfg *commitments.APIConfig + wantResourceCount int // 0 = don't check + } + u := func(v uint64) *uint64 { return &v } - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}} - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } + crdCases := []crdValueCase{ + { + // running=200, exclusively_free=800 slots โ†’ capacity=1000, usage=200 + name: "ready CRD: capacity = running + exclusively free, usage = running", + runningInstances: 200, exclusiveFreeBytes: 800 * flavorMemBytes, ready: true, + checkAZ: "az-one", wantCapacity: 1000, wantUsage: u(200), + }, + { + // stale CRD: last-known capacity still reported, usage omitted + name: "stale CRD: capacity reported, usage absent", + runningInstances: 200, exclusiveFreeBytes: 800 * flavorMemBytes, ready: false, + checkAZ: "az-one", wantCapacity: 1000, wantUsage: nil, + }, + { + // CRD only covers az-one; az-two has no CRD โ†’ capacity=0 + name: "missing CRD for AZ: capacity=0", + runningInstances: 500, exclusiveFreeBytes: 400, ready: true, + checkAZ: "az-two", wantCapacity: 0, wantUsage: nil, + }, + } - if len(report.Resources) != 3 { - t.Fatalf("Expected 3 resources, got %d", len(report.Resources)) - } + for _, tc := range crdCases { + tests = append(tests, struct { + name string + checkFn func(t *testing.T) + }{ + name: tc.name, + checkFn: func(t *testing.T) { + cfg := defaultCapacityConfig + if tc.cfg != nil { + cfg = *tc.cfg + } + crd := createTestFlavorGroupCapacity(tc.runningInstances, tc.exclusiveFreeBytes, tc.ready) + calc := commitments.NewCapacityCalculator( + fake.NewClientBuilder().WithScheme(scheme). + WithObjects(createTestFlavorGroupKnowledge(t), crd). + WithStatusSubresource(crd).Build(), + cfg, + ) + allAZs := []liquid.AvailabilityZone{"az-one"} + if tc.checkAZ != "az-one" { + allAZs = append(allAZs, tc.checkAZ) + } + report, err := calc.CalculateCapacity(context.Background(), + liquid.ServiceCapacityRequest{AllAZs: allAZs}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.wantResourceCount > 0 && len(report.Resources) != tc.wantResourceCount { + t.Fatalf("expected %d resources, got %d", tc.wantResourceCount, len(report.Resources)) + } + ramRes := report.Resources["hw_version_test-group_ram"] + if ramRes == nil { + t.Fatal("missing hw_version_test-group_ram") + } + az := ramRes.PerAZ[tc.checkAZ] + if az == nil { + t.Fatalf("missing entry for AZ %s", tc.checkAZ) + } + if az.Capacity != tc.wantCapacity { + t.Errorf("capacity = %d, want %d", az.Capacity, tc.wantCapacity) + } + if tc.wantUsage == nil { + if az.Usage.IsSome() { + t.Error("expected usage absent, got value") + } + } else { + if usage := az.Usage.UnwrapOr(99999); usage != *tc.wantUsage { + t.Errorf("usage = %d, want %d", usage, *tc.wantUsage) + } + } + }, + }) + } - for resName, res := range report.Resources { - if len(res.PerAZ) != 0 { - t.Errorf("%s: expected empty PerAZ, got %d entries", resName, len(res.PerAZ)) + // HasCapacity=false case โ€” different config, checks resource count. + tests = append(tests, struct { + name string + checkFn func(t *testing.T) + }{ + name: "HasCapacity=false omits resource from report", + checkFn: func(t *testing.T) { + cfg := commitments.APIConfig{ + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": { + RAM: commitments.RAMResourceTypeConfig{HasCapacity: true}, + Cores: commitments.ResourceTypeConfig{HasCapacity: true}, + Instances: commitments.ResourceTypeConfig{HasCapacity: false}, + }, + }, } - } - }) - - t.Run("CalculateCapacity responds to different AZ sets correctly", func(t *testing.T) { - flavorGroupKnowledge := createTestFlavorGroupKnowledge(t) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(flavorGroupKnowledge). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - - req1 := liquid.ServiceCapacityRequest{ - AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"}, - } - report1, err := calculator.CalculateCapacity(context.Background(), req1) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - req2 := liquid.ServiceCapacityRequest{ - AllAZs: []liquid.AvailabilityZone{"us-west-1a", "us-west-1b", "us-west-1c", "us-west-1d"}, - } - report2, err := calculator.CalculateCapacity(context.Background(), req2) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - // Verify reports have exactly the requested AZs - for _, res := range report1.Resources { - verifyPerAZMatchesRequest(t, res, req1.AllAZs) - } - for _, res := range report2.Resources { - verifyPerAZMatchesRequest(t, res, req2.AllAZs) - } - }) - - t.Run("CalculateCapacity reads capacity and usage from Ready CRD", func(t *testing.T) { - knowledge := createTestFlavorGroupKnowledge(t) - crd := createTestFlavorGroupCapacity(1000, 800, true) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(knowledge, crd). - WithStatusSubresource(crd). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ramRes := report.Resources["hw_version_test-group_ram"] - if ramRes == nil { - t.Fatal("expected hw_version_test-group_ram resource") - } - azReport := ramRes.PerAZ["az-one"] - if azReport == nil { - t.Fatal("expected az-one entry") - } - if azReport.Capacity != 1000 { - t.Errorf("expected capacity=1000, got %d", azReport.Capacity) - } - if !azReport.Usage.IsSome() { - t.Fatal("expected usage to be set for Ready CRD") - } - // usage = (total - placeable) slots = (1000 - 800) = 200 slots - if usage := azReport.Usage.UnwrapOr(0); usage != 200 { - t.Errorf("expected usage=200 (200 slots), got %d", usage) - } - }) - - t.Run("CalculateCapacity returns zero capacity for missing CRD", func(t *testing.T) { - knowledge := createTestFlavorGroupKnowledge(t) - // CRD exists only for az-one; az-two has no CRD - crd := createTestFlavorGroupCapacity(500, 400, true) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(knowledge, crd). - WithStatusSubresource(crd). - Build() - - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}} - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ramRes := report.Resources["hw_version_test-group_ram"] - if ramRes == nil { - t.Fatal("expected hw_version_test-group_ram resource") - } - azTwo := ramRes.PerAZ["az-two"] - if azTwo == nil { - t.Fatal("expected az-two entry even without CRD") - } - if azTwo.Capacity != 0 { - t.Errorf("expected capacity=0 for missing CRD, got %d", azTwo.Capacity) - } + crd := createTestFlavorGroupCapacity(100, 80, true) + calc := commitments.NewCapacityCalculator( + fake.NewClientBuilder().WithScheme(scheme). + WithObjects(createTestFlavorGroupKnowledge(t), crd). + WithStatusSubresource(crd).Build(), + cfg, + ) + report, err := calc.CalculateCapacity(context.Background(), + liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}}) + if err != nil { + t.Fatal(err) + } + if len(report.Resources) != 2 { + t.Errorf("expected 2 resources (ram, cores), got %d", len(report.Resources)) + } + if _, ok := report.Resources["hw_version_test-group_instances"]; ok { + t.Error("hw_version_test-group_instances should be absent") + } + }, }) - t.Run("CalculateCapacity omits usage for stale CRD (Ready=False)", func(t *testing.T) { - knowledge := createTestFlavorGroupKnowledge(t) - crd := createTestFlavorGroupCapacity(1000, 800, false) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(knowledge, crd). - WithStatusSubresource(crd). - Build() + for _, tt := range tests { + t.Run(tt.name, tt.checkFn) + } +} - calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) - req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } +// TestCapacityCalculator_VariableRatio tests RAM capacity/usage for variable-ratio groups. +// Covers both exact-MiB flavors and the 16 MiB vRAM offset case (hw_video:ram_max_mb=16), +// where the actual MemoryMB is 16 less than the nominal value. Both must be handled correctly. +func TestCapacityCalculator_VariableRatio(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } - ramRes := report.Resources["hw_version_test-group_ram"] - if ramRes == nil { - t.Fatal("expected hw_version_test-group_ram resource") - } - azReport := ramRes.PerAZ["az-one"] - if azReport == nil { - t.Fatal("expected az-one entry") - } - // Stale CRD: last-known capacity is still reported (1000 slots) - if azReport.Capacity != 1000 { - t.Errorf("expected last-known capacity=1000 for stale CRD, got %d", azReport.Capacity) - } - // Stale CRD: usage must be absent (None) - if azReport.Usage.IsSome() { - t.Error("expected usage to be absent (None) for stale CRD") - } - }) + const ramUnitGiB = 2 // 1 declared unit = 2 GiB - t.Run("CalculateCapacity omits resources with HasCapacity=false", func(t *testing.T) { - knowledge := createTestFlavorGroupKnowledge(t) - crd := createTestFlavorGroupCapacity(100, 80, true) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(knowledge, crd). - WithStatusSubresource(crd). - Build() + tests := []struct { + name string + flavorMemMiB int + wantRAMCap uint64 + wantRAMUsage uint64 + }{ + { + // Exact: 3 running + 5 free = 8 ร— 2 GiB = 8 declared units. + name: "exact 2 GiB flavor (no vRAM offset)", + flavorMemMiB: 2048, + wantRAMCap: 8, + wantRAMUsage: 3, + }, + { + // 3 VMs ร— 2032 MiB = 6096 MiB. 6096 / 2048 = 2 (not 3) โ€” undercount by 1 unit. + // Capacity: (3+5)ร—2032 MiB / 2048 MiB = 7 (not 8). Known limitation. + name: "2032 MiB flavor (16 MiB vRAM offset, hw_video:ram_max_mb=16)", + flavorMemMiB: 2032, + wantRAMCap: 7, + wantRAMUsage: 2, + }, + } - // Only RAM and Cores have capacity; Instances does not. - cfgNoInstances := commitments.APIConfig{ - FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ - "*": { - RAM: commitments.RAMResourceTypeConfig{HasCapacity: true}, - Cores: commitments.ResourceTypeConfig{HasCapacity: true}, - Instances: commitments.ResourceTypeConfig{HasCapacity: false}, + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + flavorMemBytes := int64(tc.flavorMemMiB) * 1024 * 1024 + knowledge := createVariableRatioFlavorGroupKnowledge(t, tc.flavorMemMiB) + // 3 running VMs, 5 exclusively free slots + crd := createFlavorGroupCapacityWithResources(3, 5*flavorMemBytes, 3*flavorMemBytes, 3*8) + cfg := commitments.APIConfig{ + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": {RAM: commitments.RAMResourceTypeConfig{HasCapacity: true, RAMUnitGiB: ramUnitGiB}}, }, - }, - } - calculator := commitments.NewCapacityCalculator(fakeClient, cfgNoInstances) - req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} - report, err := calculator.CalculateCapacity(context.Background(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(report.Resources) != 2 { - t.Fatalf("expected 2 resources (ram, cores), got %d: %v", len(report.Resources), report.Resources) - } - if _, ok := report.Resources["hw_version_test-group_instances"]; ok { - t.Error("expected hw_version_test-group_instances to be absent (HasCapacity=false)") - } - }) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme).WithObjects(knowledge, crd).WithStatusSubresource(crd).Build() + + report, err := commitments.NewCapacityCalculator(fakeClient, cfg).CalculateCapacity( + context.Background(), liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + az := report.Resources["hw_version_test-group_ram"].PerAZ["az-one"] + if az.Capacity != tc.wantRAMCap { + t.Errorf("RAM capacity = %d, want %d", az.Capacity, tc.wantRAMCap) + } + if usage := az.Usage.UnwrapOr(99); usage != tc.wantRAMUsage { + t.Errorf("RAM usage = %d, want %d", usage, tc.wantRAMUsage) + } + }) + } } -// This follows the same semantics as nova liquid: the response must contain -// entries for all AZs in AllAZs, no more and no less. func verifyPerAZMatchesRequest(t *testing.T, res *liquid.ResourceCapacityReport, requestedAZs []liquid.AvailabilityZone) { t.Helper() if res == nil { @@ -430,142 +380,136 @@ func verifyPerAZMatchesRequest(t *testing.T, res *liquid.ResourceCapacityReport, } for _, az := range requestedAZs { if _, ok := res.PerAZ[az]; !ok { - t.Errorf("missing entry for requested AZ %s", az) + t.Errorf("missing entry for AZ %s", az) } } for az := range res.PerAZ { if !slices.Contains(requestedAZs, az) { - t.Errorf("unexpected AZ %s in response (not in request)", az) + t.Errorf("unexpected AZ %s in response", az) } } } -// createEmptyFlavorGroupKnowledge creates an empty flavor groups Knowledge CRD func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge { - // Box empty array properly - emptyFeatures := []map[string]interface{}{} - raw, err := v1alpha1.BoxFeatureList(emptyFeatures) + raw, err := v1alpha1.BoxFeatureList([]map[string]interface{}{}) if err != nil { - panic(err) // Should never happen for empty slice + panic(err) } - return &v1alpha1.Knowledge{ - ObjectMeta: v1.ObjectMeta{ - Name: "flavor-groups", - // No namespace - Knowledge is cluster-scoped - }, + ObjectMeta: v1.ObjectMeta{Name: "flavor-groups"}, Spec: v1alpha1.KnowledgeSpec{ SchedulingDomain: v1alpha1.SchedulingDomainNova, - Extractor: v1alpha1.KnowledgeExtractorSpec{ - Name: "flavor_groups", - }, + Extractor: v1alpha1.KnowledgeExtractorSpec{Name: "flavor_groups"}, }, Status: v1alpha1.KnowledgeStatus{ - Conditions: []v1.Condition{ - { - Type: v1alpha1.KnowledgeConditionReady, - Status: "True", - }, - }, - Raw: raw, + Conditions: []v1.Condition{{Type: v1alpha1.KnowledgeConditionReady, Status: "True"}}, + Raw: raw, }, } } -// createTestFlavorGroupCapacity creates a FlavorGroupCapacity CRD for testing. -// totalSlots and placeableSlots are for the named smallest flavor entry. -// ready controls whether the Ready condition is True or False. -func createTestFlavorGroupCapacity(totalSlots, placeableSlots int64, ready bool) *v1alpha1.FlavorGroupCapacity { - const group = "test-group" - const az = "az-one" - const smallestFlavorName = "test_c8_m32" +// createTestFlavorGroupCapacity creates a FlavorGroupCapacity CRD for a fixed-ratio group. +func createTestFlavorGroupCapacity(runningInstances, exclusiveFreeMemBytes int64, ready bool) *v1alpha1.FlavorGroupCapacity { conditionStatus := v1.ConditionTrue if !ready { conditionStatus = v1.ConditionFalse } + status := v1alpha1.FlavorGroupCapacityStatus{ + Flavors: []v1alpha1.FlavorCapacityStatus{{FlavorName: "test_c8_m32"}}, + RunningInstances: runningInstances, + Conditions: []v1.Condition{{Type: v1alpha1.FlavorGroupCapacityConditionReady, Status: conditionStatus}}, + } + if exclusiveFreeMemBytes > 0 { + const flavorMemBytes = 32752 * 1024 * 1024 // test flavor memory size + status.ExclusivelyFreeCapacity = map[string]resource.Quantity{ + string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(exclusiveFreeMemBytes, resource.BinarySI), + } + status.ExclusivelyFreeSlots = exclusiveFreeMemBytes / flavorMemBytes + } return &v1alpha1.FlavorGroupCapacity{ - ObjectMeta: v1.ObjectMeta{ - Name: group + "-" + az, - }, - Spec: v1alpha1.FlavorGroupCapacitySpec{ - FlavorGroup: group, - AvailabilityZone: az, - }, - Status: v1alpha1.FlavorGroupCapacityStatus{ - Flavors: []v1alpha1.FlavorCapacityStatus{ - { - FlavorName: smallestFlavorName, - TotalCapacityVMSlots: totalSlots, - PlaceableVMs: placeableSlots, - }, - }, - Conditions: []v1.Condition{ - { - Type: v1alpha1.FlavorGroupCapacityConditionReady, - Status: conditionStatus, - }, - }, - }, + ObjectMeta: v1.ObjectMeta{Name: "test-group-az-one"}, + Spec: v1alpha1.FlavorGroupCapacitySpec{FlavorGroup: "test-group", AvailabilityZone: "az-one"}, + Status: status, } } -// that accepts commitments (has fixed RAM/core ratio) +// createTestFlavorGroupKnowledge creates a fixed-ratio (HANA-style) flavor group Knowledge CRD. func createTestFlavorGroupKnowledge(t *testing.T) *v1alpha1.Knowledge { t.Helper() - features := []map[string]interface{}{ { "name": "test-group", "flavors": []map[string]interface{}{ - { - "name": "test_c8_m32", - "vcpus": 8, - "memoryMB": 32752, - "diskGB": 50, - }, - }, - "largestFlavor": map[string]interface{}{ - "name": "test_c8_m32", - "vcpus": 8, - "memoryMB": 32752, - "diskGB": 50, - }, - "smallestFlavor": map[string]interface{}{ - "name": "test_c8_m32", - "vcpus": 8, - "memoryMB": 32752, - "diskGB": 50, + {"name": "test_c8_m32", "vcpus": 8, "memoryMB": 32752, "diskGB": 50}, }, - // Fixed RAM/core ratio (4096 MiB per vCPU) - required for group to accept commitments - "ramCoreRatio": 4096, + "largestFlavor": map[string]interface{}{"name": "test_c8_m32", "vcpus": 8, "memoryMB": 32752, "diskGB": 50}, + "smallestFlavor": map[string]interface{}{"name": "test_c8_m32", "vcpus": 8, "memoryMB": 32752, "diskGB": 50}, + "ramCoreRatio": 4096, // fixed RAM/core ratio โ†’ slots-based reporting }, } - - // Use BoxFeatureList to properly format the features raw, err := v1alpha1.BoxFeatureList(features) if err != nil { t.Fatal(err) } - return &v1alpha1.Knowledge{ - ObjectMeta: v1.ObjectMeta{ - Name: "flavor-groups", - // No namespace - Knowledge is cluster-scoped + ObjectMeta: v1.ObjectMeta{Name: "flavor-groups"}, + Spec: v1alpha1.KnowledgeSpec{ + SchedulingDomain: v1alpha1.SchedulingDomainNova, + Extractor: v1alpha1.KnowledgeExtractorSpec{Name: "flavor_groups"}, + }, + Status: v1alpha1.KnowledgeStatus{ + Conditions: []v1.Condition{{Type: v1alpha1.KnowledgeConditionReady, Status: "True"}}, + Raw: raw, + }, + } +} + +// createVariableRatioFlavorGroupKnowledge creates a Knowledge CRD for a variable-ratio group +// (no ramCoreRatio, so RAM is reported in GiB units, not slots). +func createVariableRatioFlavorGroupKnowledge(t *testing.T, flavorMemMiB int) *v1alpha1.Knowledge { + t.Helper() + features := []map[string]interface{}{ + { + "name": "test-group", + "flavors": []map[string]interface{}{{"name": "test-flavor", "vcpus": 8, "memoryMB": flavorMemMiB, "diskGB": 50}}, + "largestFlavor": map[string]interface{}{"name": "test-flavor", "vcpus": 8, "memoryMB": flavorMemMiB, "diskGB": 50}, + "smallestFlavor": map[string]interface{}{"name": "test-flavor", "vcpus": 8, "memoryMB": flavorMemMiB, "diskGB": 50}, + // no ramCoreRatio โ†’ variable-ratio group }, + } + raw, err := v1alpha1.BoxFeatureList(features) + if err != nil { + t.Fatal(err) + } + return &v1alpha1.Knowledge{ + ObjectMeta: v1.ObjectMeta{Name: "flavor-groups"}, Spec: v1alpha1.KnowledgeSpec{ SchedulingDomain: v1alpha1.SchedulingDomainNova, - Extractor: v1alpha1.KnowledgeExtractorSpec{ - Name: "flavor_groups", - }, + Extractor: v1alpha1.KnowledgeExtractorSpec{Name: "flavor_groups"}, }, Status: v1alpha1.KnowledgeStatus{ - Conditions: []v1.Condition{ - { - Type: v1alpha1.KnowledgeConditionReady, - Status: "True", - }, + Conditions: []v1.Condition{{Type: v1alpha1.KnowledgeConditionReady, Status: "True", Reason: "ExtractorSucceeded"}}, + Raw: raw, + }, + } +} + +// createFlavorGroupCapacityWithResources creates a ready FlavorGroupCapacity CRD with +// RunningInstances, ExclusivelyFreeCapacity, and RunningResources all populated. +func createFlavorGroupCapacityWithResources(runningInstances, exclusiveFreeMemBytes, runningMemBytes, runningCores int64) *v1alpha1.FlavorGroupCapacity { + return &v1alpha1.FlavorGroupCapacity{ + ObjectMeta: v1.ObjectMeta{Name: "test-group-az-one"}, + Spec: v1alpha1.FlavorGroupCapacitySpec{FlavorGroup: "test-group", AvailabilityZone: "az-one"}, + Status: v1alpha1.FlavorGroupCapacityStatus{ + RunningInstances: runningInstances, + RunningResources: map[string]resource.Quantity{ + string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(runningMemBytes, resource.BinarySI), + string(v1alpha1.CommittedResourceTypeCores): *resource.NewQuantity(runningCores, resource.DecimalSI), + }, + ExclusivelyFreeCapacity: map[string]resource.Quantity{ + string(v1alpha1.CommittedResourceTypeMemory): *resource.NewQuantity(exclusiveFreeMemBytes, resource.BinarySI), }, - Raw: raw, + Conditions: []v1.Condition{{Type: v1alpha1.FlavorGroupCapacityConditionReady, Status: v1.ConditionTrue}}, }, } } diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index 2f366404c..ee3fc1a99 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -28,25 +28,21 @@ func NewCapacityCalculator(client client.Client, conf APIConfig) *CapacityCalcul // CalculateCapacity computes per-AZ capacity for all flavor groups. // For each flavor group, three resources are reported: _ram, _cores, _instances. -// Capacity and usage are read from FlavorGroupCapacity CRDs pre-computed by the capacity controller. -// Usage is approximated from slot counts (total โˆ’ placeable of the smallest flavor); this may -// slightly under-report usage when larger flavors are running, showing more free capacity than -// reality โ€” acceptable for capacity planning purposes. +// All values are read from FlavorGroupCapacity CRDs pre-computed by the capacity controller: +// - Capacity: RunningInstances + ExclusivelyFreeCapacity converted to slots. +// - Usage: RunningInstances / RunningResources. func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.ServiceCapacityRequest) (liquid.ServiceCapacityReport, error) { - // Get all flavor groups from Knowledge CRDs (needed for smallest-flavor lookup). knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client} flavorGroups, err := knowledge.GetAllFlavorGroups(ctx, nil) if err != nil { return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to get flavor groups: %w", err) } - // Get version from Knowledge CRD (same as info API version). var infoVersion int64 = -1 if knowledgeCRD, err := knowledge.Get(ctx); err == nil && knowledgeCRD != nil && !knowledgeCRD.Status.LastContentChange.IsZero() { infoVersion = knowledgeCRD.Status.LastContentChange.Unix() } - // List all FlavorGroupCapacity CRDs and index by (flavorGroup, az). var capacityList v1alpha1.FlavorGroupCapacityList if err := c.client.List(ctx, &capacityList); err != nil { return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to list FlavorGroupCapacity CRDs: %w", err) @@ -58,7 +54,6 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S crdByKey[groupAZKey{crd.Spec.FlavorGroup, crd.Spec.AvailabilityZone}] = crd } - // Build capacity report for all flavor groups. report := liquid.ServiceCapacityReport{ InfoVersion: infoVersion, Resources: make(map[liquid.ResourceName]*liquid.ResourceCapacityReport), @@ -72,11 +67,7 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S continue } - smallestFlavorName := groupData.SmallestFlavor.Name - // Add 16 MiB before dividing: flavors may reserve 16 MiB for video RAM (hw_video:ram_max_mb=16), - // so a nominal "2 GiB" flavor may report 2032 MiB. Adding 16 restores the intended GiB boundary. - memoryGiBPerSlot := (groupData.SmallestFlavor.MemoryMB + 16) / 1024 - vcpusPerSlot := groupData.SmallestFlavor.VCPUs + ramUnitBytes := int64(resCfg.RAM.RAMUnitMiB()) * 1024 * 1024 //nolint:gosec ramAZCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) coresAZCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) @@ -85,7 +76,6 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S for _, az := range req.AllAZs { crd, ok := crdByKey[groupAZKey{groupName, string(az)}] if !ok { - // No CRD for this (group, AZ) pair โ€” report zero. zero := &liquid.AZResourceCapacityReport{Capacity: 0} ramAZCapacity[az] = zero coresAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} @@ -93,55 +83,67 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S continue } - // If the CRD data is stale, report last-known capacity but omit usage. if !apimeta.IsStatusConditionTrue(crd.Status.Conditions, v1alpha1.FlavorGroupCapacityConditionReady) { logger.Info("FlavorGroupCapacity CRD is stale, reporting capacity without usage", "flavorGroup", groupName, "az", az) } - // Find the smallest-flavor entry in the CRD status. - var smallest *v1alpha1.FlavorCapacityStatus - for i := range crd.Status.Flavors { - if crd.Status.Flavors[i].FlavorName == smallestFlavorName { - smallest = &crd.Status.Flavors[i] - break - } - } - if smallest == nil { - zero := &liquid.AZResourceCapacityReport{Capacity: 0} - ramAZCapacity[az] = zero - coresAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} - instancesAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} - continue - } + // ExclusivelyFreeSlots is pre-computed by the controller using min(memSlots, cpuSlots). + exclusiveFreeSlots := uint64(crd.Status.ExclusivelyFreeSlots) //nolint:gosec + + // Capacity = running + exclusively free, all derived from CRD bytes. + runningInstances := uint64(crd.Status.RunningInstances) //nolint:gosec + instancesCapacity := runningInstances + exclusiveFreeSlots - totalSlots := uint64(smallest.TotalCapacityVMSlots) //nolint:gosec // slot count from CRD, realistically bounded + // RAM capacity: running bytes + exclusively free bytes โ†’ declared units. + // Fixed-ratio groups report in slots (1 unit = 1 instance). var ramCapacity uint64 if groupData.HasFixedRamCoreRatio() { - ramCapacity = totalSlots - } else { - ramCapacity = totalSlots * memoryGiBPerSlot + ramCapacity = instancesCapacity + } else if ramUnitBytes > 0 { + runningMemBytes := int64(0) + if qty, ok := crd.Status.RunningResources[string(v1alpha1.CommittedResourceTypeMemory)]; ok { + runningMemBytes = qty.Value() + } + freeMemBytes := int64(0) + if qty, ok := crd.Status.ExclusivelyFreeCapacity[string(v1alpha1.CommittedResourceTypeMemory)]; ok { + freeMemBytes = qty.Value() + } + ramCapacity = uint64(runningMemBytes+freeMemBytes) / uint64(ramUnitBytes) + } + + // Cores capacity: running cores + exclusively free cores. + var coresCapacity uint64 + runningCoresCount := int64(0) + if qty, ok := crd.Status.RunningResources[string(v1alpha1.CommittedResourceTypeCores)]; ok { + runningCoresCount = qty.Value() + } + freeCoresCount := int64(0) + if qty, ok := crd.Status.ExclusivelyFreeCapacity[string(v1alpha1.CommittedResourceTypeCores)]; ok { + freeCoresCount = qty.Value() } + coresCapacity = uint64(runningCoresCount + freeCoresCount) + ramEntry := &liquid.AZResourceCapacityReport{Capacity: ramCapacity} - coresEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots * vcpusPerSlot} - instancesEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots} + coresEntry := &liquid.AZResourceCapacityReport{Capacity: coresCapacity} + instancesEntry := &liquid.AZResourceCapacityReport{Capacity: instancesCapacity} - // Usage is approximated from slot counts. This may slightly under-report usage when - // larger flavors are running (safe direction: shows more free capacity than reality). + // Usage from actual running VMs โ€” only when CRD data is fresh. if apimeta.IsStatusConditionTrue(crd.Status.Conditions, v1alpha1.FlavorGroupCapacityConditionReady) { - placeableSlots := uint64(smallest.PlaceableVMs) //nolint:gosec // slot count from CRD, realistically bounded - var usedSlots uint64 - if totalSlots > placeableSlots { - usedSlots = totalSlots - placeableSlots - } + instancesEntry.Usage = Some[uint64](runningInstances) + coresEntry.Usage = Some[uint64](uint64(runningCoresCount)) + if groupData.HasFixedRamCoreRatio() { - ramEntry.Usage = Some[uint64](usedSlots) - } else { - ramEntry.Usage = Some[uint64](usedSlots * memoryGiBPerSlot) + ramEntry.Usage = Some[uint64](runningInstances) + } else if ramUnitBytes > 0 { + runningMemBytes := int64(0) + if qty, ok := crd.Status.RunningResources[string(v1alpha1.CommittedResourceTypeMemory)]; ok { + runningMemBytes = qty.Value() + } + ramEntry.Usage = Some[uint64](uint64(runningMemBytes) / uint64(ramUnitBytes)) } - coresEntry.Usage = Some[uint64](usedSlots * vcpusPerSlot) - instancesEntry.Usage = Some[uint64](usedSlots) } + ramAZCapacity[az] = ramEntry coresAZCapacity[az] = coresEntry instancesAZCapacity[az] = instancesEntry From 92e09ced111a9426d790356686b45328285a0b8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:24:19 +0200 Subject: [PATCH 15/21] bump app version [skip ci] (#975) bump app version [skip ci] ``` bumped cortex: sha-6daa5050 -> sha-124ec226 bumped cortex-shim: sha-b7fbbe1a -> sha-124ec226 bumped cortex-postgres: sha-8a5b5dd6 -> sha-af707446 ``` Co-authored-by: mblos <156897072+mblos@users.noreply.github.com> --- helm/library/cortex-postgres/Chart.yaml | 2 +- helm/library/cortex-shim/Chart.yaml | 2 +- helm/library/cortex/Chart.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/helm/library/cortex-postgres/Chart.yaml b/helm/library/cortex-postgres/Chart.yaml index b3eddfac2..86201d1f5 100644 --- a/helm/library/cortex-postgres/Chart.yaml +++ b/helm/library/cortex-postgres/Chart.yaml @@ -6,4 +6,4 @@ name: cortex-postgres description: Postgres setup for Cortex. type: application version: 0.6.5 -appVersion: "sha-8a5b5dd6" +appVersion: "sha-af707446" diff --git a/helm/library/cortex-shim/Chart.yaml b/helm/library/cortex-shim/Chart.yaml index 91c51f728..c7a6c9067 100644 --- a/helm/library/cortex-shim/Chart.yaml +++ b/helm/library/cortex-shim/Chart.yaml @@ -3,6 +3,6 @@ name: cortex-shim description: A Helm chart to distribute cortex shims. type: application version: 0.1.2 -appVersion: "sha-b7fbbe1a" +appVersion: "sha-124ec226" icon: "https://example.com/icon.png" dependencies: [] diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index 826f166ed..2fe093797 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -3,6 +3,6 @@ name: cortex description: A Helm chart to distribute cortex. type: application version: 0.1.2 -appVersion: "sha-6daa5050" +appVersion: "sha-124ec226" icon: "https://example.com/icon.png" dependencies: [] From ca72ab474aaec7584bca604fc7e11b58038d70be Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:45:13 +0200 Subject: [PATCH 16/21] feat: allows KVM-enabled deployments to opt out of critical-severity alerting (#988) --- helm/bundles/cortex-nova/templates/alerts.yaml | 2 +- helm/bundles/cortex-nova/values.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/helm/bundles/cortex-nova/templates/alerts.yaml b/helm/bundles/cortex-nova/templates/alerts.yaml index e043481b1..840e969f2 100644 --- a/helm/bundles/cortex-nova/templates/alerts.yaml +++ b/helm/bundles/cortex-nova/templates/alerts.yaml @@ -26,7 +26,7 @@ spec: context: liveness dashboard: cortex-status-dashboard/cortex-status-dashboard service: cortex - severity: {{ if .Values.kvm.enabled }}critical{{ else }}warning{{ end }} + severity: {{ if and .Values.kvm.enabled .Values.kvm.criticalAlerts }}critical{{ else }}warning{{ end }} support_group: workload-management playbook: docs/support/playbook/cortex/alerts/down annotations: diff --git a/helm/bundles/cortex-nova/values.yaml b/helm/bundles/cortex-nova/values.yaml index a07279ff0..1171f3b01 100644 --- a/helm/bundles/cortex-nova/values.yaml +++ b/helm/bundles/cortex-nova/values.yaml @@ -70,6 +70,9 @@ openstack: kvm: # Use this flag to enable/disable KVM host related features. enabled: false + # Set to false to keep KVM-related alerts at warning severity instead of critical. + # Useful when rolling out a new KVM region before it is production-ready. + criticalAlerts: true cortex: &cortex crd: {enable: false} From 685aaa53f1e497fae10d478a6dd8ea41aa31fefb Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:51:20 +0200 Subject: [PATCH 17/21] chore: bump cortex chart to v0.0.77 (#993) --- helm/bundles/cortex-cinder/Chart.yaml | 8 ++++---- helm/bundles/cortex-crds/Chart.yaml | 4 ++-- helm/bundles/cortex-ironcore/Chart.yaml | 4 ++-- helm/bundles/cortex-manila/Chart.yaml | 8 ++++---- helm/bundles/cortex-nova/Chart.yaml | 8 ++++---- helm/bundles/cortex-placement-shim/Chart.yaml | 2 +- helm/bundles/cortex-pods/Chart.yaml | 4 ++-- helm/library/cortex-postgres/Chart.yaml | 2 +- helm/library/cortex-shim/Chart.yaml | 2 +- helm/library/cortex/Chart.yaml | 2 +- 10 files changed, 22 insertions(+), 22 deletions(-) diff --git a/helm/bundles/cortex-cinder/Chart.yaml b/helm/bundles/cortex-cinder/Chart.yaml index 751939f50..5d819ff38 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.5 + version: 0.6.6 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 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 8501238a9..b6b188d9e 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 # 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 7a42ac470..910aaf815 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 # 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 803146db8..c66e97f15 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.5 + version: 0.6.6 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 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 6dd02a092..7ad6c9e5f 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres - name: cortex-postgres repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.6.5 + version: 0.6.6 # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-placement-shim/Chart.yaml b/helm/bundles/cortex-placement-shim/Chart.yaml index dc115d3b1..2d5a0df36 100644 --- a/helm/bundles/cortex-placement-shim/Chart.yaml +++ b/helm/bundles/cortex-placement-shim/Chart.yaml @@ -11,7 +11,7 @@ dependencies: # from: file://../../library/cortex-shim - name: cortex-shim repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 # 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 81d5bfce7..b95d0d2ab 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.76 +version: 0.0.77 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.2 + version: 0.1.3 # 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/library/cortex-postgres/Chart.yaml b/helm/library/cortex-postgres/Chart.yaml index 86201d1f5..194b0e609 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.5 +version: 0.6.6 appVersion: "sha-af707446" diff --git a/helm/library/cortex-shim/Chart.yaml b/helm/library/cortex-shim/Chart.yaml index c7a6c9067..81d4a4f15 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.2 +version: 0.1.3 appVersion: "sha-124ec226" icon: "https://example.com/icon.png" dependencies: [] diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index 2fe093797..b5241e28a 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.1.2 +version: 0.1.3 appVersion: "sha-124ec226" icon: "https://example.com/icon.png" dependencies: [] From 6f7cd1e534ee4f1348d20106c3ec1539ba125ac6 Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:10:39 +0200 Subject: [PATCH 18/21] chore: bump cortex-placement-shim to 0.1.3 (#994) --- helm/bundles/cortex-placement-shim/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/bundles/cortex-placement-shim/Chart.yaml b/helm/bundles/cortex-placement-shim/Chart.yaml index 2d5a0df36..1179a123d 100644 --- a/helm/bundles/cortex-placement-shim/Chart.yaml +++ b/helm/bundles/cortex-placement-shim/Chart.yaml @@ -5,7 +5,7 @@ apiVersion: v2 name: cortex-placement-shim description: A Helm chart deploying the Cortex placement shim. type: application -version: 0.1.2 +version: 0.1.3 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-shim From cda83f52d38bcee9f63423d91dc3d3b692d2114d Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:10:51 +0200 Subject: [PATCH 19/21] fix: add release-bump-planner agent and fix incomplete helm version bumps (#995) - re-adds the release-bump-planner agent - also fixes missing patch bumps for cortex-postgres, cortex-shim, and cortex-placement-shim --- .claude/agents/release-bump-planner.md | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .claude/agents/release-bump-planner.md diff --git a/.claude/agents/release-bump-planner.md b/.claude/agents/release-bump-planner.md new file mode 100644 index 000000000..8dd0ee3a5 --- /dev/null +++ b/.claude/agents/release-bump-planner.md @@ -0,0 +1,113 @@ +--- +name: release-bump-planner +description: Read-only agent that reads current helm chart versions and the release digest, then produces a structured bump plan. Used by the /release orchestrator as Phase 3. Does not edit files or open PRs. +tools: Bash, Read +model: inherit +--- + +# Release Bump Planner + +You produce a structured version bump plan for a given release. You are read-only โ€” you do NOT edit files, create branches, or open pull requests. Your only output is the bump plan text. + +--- + +## Input + +The caller provides: +1. The release PR number +2. The full release digest (containing `### Changed Charts` and `### Breaking Changes` sections) + +--- + +## Step 1: Read current versions + +```bash +grep "^version:" helm/library/cortex/Chart.yaml +grep "^version:" helm/library/cortex-postgres/Chart.yaml +grep "^version:" helm/library/cortex-shim/Chart.yaml +grep "^version:" helm/bundles/cortex-nova/Chart.yaml +grep "^version:" helm/bundles/cortex-cinder/Chart.yaml +grep "^version:" helm/bundles/cortex-manila/Chart.yaml +grep "^version:" helm/bundles/cortex-crds/Chart.yaml +grep "^version:" helm/bundles/cortex-ironcore/Chart.yaml +grep "^version:" helm/bundles/cortex-pods/Chart.yaml +grep "^version:" helm/bundles/cortex-placement-shim/Chart.yaml +``` + +Also read the dependency versions inside each bundle: +```bash +grep -A2 "name: cortex$\|name: cortex-postgres$\|name: cortex-shim$" helm/bundles/*/Chart.yaml | grep "version:" +``` + +--- + +## Step 2: Determine library bump types + +For each library chart, determine whether it needs a bump and what kind: + +**cortex** (core library): +- If listed in `### Changed Charts` in the digest AND `### Breaking Changes` is non-empty: **minor bump** (e.g. `0.1.2 โ†’ 0.2.0`) +- If listed in `### Changed Charts` with no breaking changes: **patch bump** (e.g. `0.1.2 โ†’ 0.1.3`) +- If NOT listed in `### Changed Charts`: **patch bump** anyway โ€” its `appVersion` SHA always changes on every image build, which makes `ct lint` require a version increment vs the `release` branch. + +**cortex-postgres** and **cortex-shim**: +- Always **patch bump**, regardless of digest content. Their `appVersion` SHA is updated by CI on every image push, which always produces a diff vs the `release` branch and triggers `ct lint`'s version increment requirement. + +**Rule for minor bump**: increment the middle segment, reset patch to 0 (e.g. `0.1.14 โ†’ 0.2.0`). +**Rule for patch bump**: increment the last segment (e.g. `0.1.2 โ†’ 0.1.3`). + +--- + +## Step 3: Determine bundle bumps + +All bundles except `cortex-placement-shim` get a **patch bump** to their top-level `version:` โ€” use `$NEW_BUNDLES` for these. + +`cortex-placement-shim` has its own independent version counter, but it depends on `cortex-shim`. Because `cortex-shim` is always patch-bumped (Step 2), the `cortex-shim` dependency pin inside `cortex-placement-shim/Chart.yaml` will change, which creates a diff vs `release` and triggers `ct lint`. Therefore `cortex-placement-shim` must also get its own **patch bump** (independent of `$NEW_BUNDLES`). + +--- + +## Step 4: Output the plan + +Output exactly this format. No preamble, no closing remarks. Replace all `` / `` placeholders with actual version strings. + +``` +### Library bumps +- cortex: โ†’ +- cortex-postgres: โ†’ +- cortex-shim: โ†’ + +### Bundle dependency updates +- cortex-nova: cortex (alias: cortex-knowledge-controllers) โ†’ +- cortex-nova: cortex (alias: cortex-scheduling-controllers) โ†’ +- cortex-nova: cortex-postgres โ†’ +- cortex-cinder: cortex (alias: cortex-knowledge-controllers) โ†’ +- cortex-cinder: cortex (alias: cortex-scheduling-controllers) โ†’ +- cortex-cinder: cortex-postgres โ†’ +- cortex-manila: cortex (alias: cortex-knowledge-controllers) โ†’ +- cortex-manila: cortex (alias: cortex-scheduling-controllers) โ†’ +- cortex-manila: cortex-postgres โ†’ +- cortex-crds: cortex โ†’ +- cortex-ironcore: cortex โ†’ +- cortex-pods: cortex โ†’ +- cortex-placement-shim: cortex-shim โ†’ +``` + +Only include entries that actually exist in that bundle's Chart.yaml. Omit alias lines for bundles that don't use them. + +``` +### Bundle self-bumps +- cortex-nova: โ†’ +- cortex-cinder: โ†’ +- cortex-manila: โ†’ +- cortex-crds: โ†’ +- cortex-ironcore: โ†’ +- cortex-pods: โ†’ +- cortex-placement-shim: โ†’ + +### Bumped Versions Summary +cortex โ†’, cortex-postgres โ†’, cortex-shim โ†’, bundles โ†’, cortex-placement-shim โ†’ +``` + +Notes: +- The `### Bumped Versions Summary` is a single line โ€” the orchestrator forwards it verbatim to the changelog writer. +- The six standard bundles (`cortex-nova`, `cortex-cinder`, `cortex-manila`, `cortex-crds`, `cortex-ironcore`, `cortex-pods`) share the same new version in their self-bumps. `cortex-placement-shim` has its own independent counter and will be a different value. From 981beb4b3166699ecb9a6c6a4eb553f6d0feb03e Mon Sep 17 00:00:00 2001 From: "cortex-ai-agents[bot]" <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:41:18 +0200 Subject: [PATCH 20/21] docs: add changelog for release v0.2.0 (#992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add changelog entry for the v0.2.0 release - Documents breaking CRD changes (FlavorGroupCapacity status field removals/additions) - Documents new features (fair capacity split, KVM critical alerts opt-out) - Documents dependency updates **Prerequisite for release PR #990.** ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cortex-ai-agents[bot] <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Co-authored-by: mblos Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c928e83..6d41a0d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## 2026-06-29 โ€” [#990](https://github.com/cobaltcore-dev/cortex/pull/990) + +### cortex v0.2.0 (sha-124ec226) + +Breaking changes: +- FlavorGroupCapacity CRD status: removed `TotalInstances` field and replaced with `RunningInstances`, `RunningResources`, `FreeCapacity`, `ExclusivelyFreeCapacity`, `ExclusivelyFreeSlots`, `CommittedCapacityBytes`, and `SmallestFlavorName` ([#983](https://github.com/cobaltcore-dev/cortex/pull/983)) +- Committed resource capacity metric renamed from `cortex_committed_resource_reported_capacity_gib` to `cortex_committed_resource_reported_capacity` with added `unit_size` label ([#983](https://github.com/cobaltcore-dev/cortex/pull/983)) + +Non-breaking changes: +- VM usage and fair capacity split for overlapping flavor groups โ€” capacity is now split fairly across flavor groups sharing the same hypervisors via a deterministic round-robin algorithm ([#983](https://github.com/cobaltcore-dev/cortex/pull/983)) +- Allow KVM-enabled deployments to opt out of critical-severity alerting via `kvm.criticalAlerts` value ([#988](https://github.com/cobaltcore-dev/cortex/pull/988)) +- Update External dependencies ([#977](https://github.com/cobaltcore-dev/cortex/pull/977), [#982](https://github.com/cobaltcore-dev/cortex/pull/982)) +- Update `github.com/sapcc` ([#960](https://github.com/cobaltcore-dev/cortex/pull/960), [#976](https://github.com/cobaltcore-dev/cortex/pull/976)) +- Update `github.com/sapcc/go-bits` ([#984](https://github.com/cobaltcore-dev/cortex/pull/984)) +- Bump `github.com/opencontainers/runc` from 1.3.3 to 1.3.6 ([#973](https://github.com/cobaltcore-dev/cortex/pull/973)) +- Update kube-prometheus-stack Docker tag to v87 ([#965](https://github.com/cobaltcore-dev/cortex/pull/965)) +- Update postgres Dockerfile base image ([#978](https://github.com/cobaltcore-dev/cortex/pull/978), [#979](https://github.com/cobaltcore-dev/cortex/pull/979)) +- Update actions/checkout action to v7 ([#961](https://github.com/cobaltcore-dev/cortex/pull/961)) + +### cortex-nova v0.0.77 + +Includes updated chart cortex v0.2.0. + +- Add `kvm.criticalAlerts` config key to opt out of critical-severity alerting for KVM regions ([#988](https://github.com/cobaltcore-dev/cortex/pull/988)) + +### cortex-crds v0.0.77 + +Includes updated chart cortex v0.2.0. + +### cortex-cinder v0.0.77 + +Includes updated chart cortex v0.2.0. + +### cortex-pods v0.0.77 + +Includes updated chart cortex v0.2.0. + +### cortex-ironcore v0.0.77 + +Includes updated chart cortex v0.2.0. + +### cortex-manila v0.0.77 + +Includes updated chart cortex v0.2.0. + +### General + +- Update hypervisor CRD for local development ([#980](https://github.com/cobaltcore-dev/cortex/pull/980)) +- Document InFlightReservation, domain hints, and CRS metrics ([#963](https://github.com/cobaltcore-dev/cortex/pull/963)) +- Fix stale file paths, grace period behavior, and dead cross-reference in docs ([#962](https://github.com/cobaltcore-dev/cortex/pull/962)) + ## 2026-06-23 โ€” [#966](https://github.com/cobaltcore-dev/cortex/pull/966) ### cortex v0.1.2 (sha-6daa5050) From fe28493ce65fe95883a88a92f63570c882c1cd91 Mon Sep 17 00:00:00 2001 From: "cortex-ai-agents[bot]" <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:41:37 +0200 Subject: [PATCH 21/21] chore: bump cortex chart to v0.2.0 for release PR #990 (#991) Bump cortex library chart version from 0.1.3 to 0.2.0 (breaking CRD change in FlavorGroupCapacity status) and update all bundle dependencies and appVersions to reference cortex 0.2.0. This PR must be merged before #990. Assisted-by: Claude Code (claude-opus-4-20250514) [Agent] --------- Co-authored-by: cortex-ai-agents[bot] <279748396+cortex-ai-agents[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 Co-authored-by: mblos --- .claude/agents/release-bump-planner.md | 6 +----- helm/bundles/cortex-cinder/Chart.yaml | 4 ++-- helm/bundles/cortex-crds/Chart.yaml | 2 +- helm/bundles/cortex-ironcore/Chart.yaml | 2 +- helm/bundles/cortex-manila/Chart.yaml | 4 ++-- helm/bundles/cortex-nova/Chart.yaml | 4 ++-- helm/bundles/cortex-pods/Chart.yaml | 2 +- helm/library/cortex/Chart.yaml | 2 +- 8 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.claude/agents/release-bump-planner.md b/.claude/agents/release-bump-planner.md index 8dd0ee3a5..581365bfb 100644 --- a/.claude/agents/release-bump-planner.md +++ b/.claude/agents/release-bump-planner.md @@ -68,7 +68,7 @@ All bundles except `cortex-placement-shim` get a **patch bump** to their top-lev ## Step 4: Output the plan -Output exactly this format. No preamble, no closing remarks. Replace all `` / `` placeholders with actual version strings. +Output exactly this format. No preamble, no closing remarks. Replace all `` / `` placeholders with actual version strings. Only include dependency entries that actually exist in that bundle's Chart.yaml; omit alias lines for bundles that don't use them. ``` ### Library bumps @@ -90,11 +90,7 @@ Output exactly this format. No preamble, no closing remarks. Replace all `` - cortex-ironcore: cortex โ†’ - cortex-pods: cortex โ†’ - cortex-placement-shim: cortex-shim โ†’ -``` - -Only include entries that actually exist in that bundle's Chart.yaml. Omit alias lines for bundles that don't use them. -``` ### Bundle self-bumps - cortex-nova: โ†’ - cortex-cinder: โ†’ diff --git a/helm/bundles/cortex-cinder/Chart.yaml b/helm/bundles/cortex-cinder/Chart.yaml index 5d819ff38..dd05d0ba3 100644 --- a/helm/bundles/cortex-cinder/Chart.yaml +++ b/helm/bundles/cortex-cinder/Chart.yaml @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 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 b6b188d9e..1579f2a97 100644 --- a/helm/bundles/cortex-crds/Chart.yaml +++ b/helm/bundles/cortex-crds/Chart.yaml @@ -11,7 +11,7 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 # 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 910aaf815..5ba2539bc 100644 --- a/helm/bundles/cortex-ironcore/Chart.yaml +++ b/helm/bundles/cortex-ironcore/Chart.yaml @@ -11,7 +11,7 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 # 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 c66e97f15..487f6789f 100644 --- a/helm/bundles/cortex-manila/Chart.yaml +++ b/helm/bundles/cortex-manila/Chart.yaml @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 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 7ad6c9e5f..666d4a672 100644 --- a/helm/bundles/cortex-nova/Chart.yaml +++ b/helm/bundles/cortex-nova/Chart.yaml @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-pods/Chart.yaml b/helm/bundles/cortex-pods/Chart.yaml index b95d0d2ab..427ce5437 100644 --- a/helm/bundles/cortex-pods/Chart.yaml +++ b/helm/bundles/cortex-pods/Chart.yaml @@ -11,7 +11,7 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.1.3 + version: 0.2.0 # 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/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index b5241e28a..19c33d1e6 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.1.3 +version: 0.2.0 appVersion: "sha-124ec226" icon: "https://example.com/icon.png" dependencies: []