Skip to content

fix(gcp): derive CUD commitment type from machine family - #1650

Open
cristim wants to merge 2 commits into
mainfrom
fix/1538-gcp-cud-commitment-type
Open

fix(gcp): derive CUD commitment type from machine family#1650
cristim wants to merge 2 commits into
mainfrom
fix/1538-gcp-cud-commitment-type

Conversation

@cristim

@cristim cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member

What was bought vs. what was requested

buildInsertRequest pinned the GCP commitment Type to the string literal
"GENERAL_PURPOSE" on every purchase. GENERAL_PURPOSE is the N1-only
member of computepb.Commitment_Type (the SDK's own enum doc: "Type
GENERAL_PURPOSE specifies a commitment that applies only to eligible resources
of general purpose N1 machine series"
), so requested and purchased diverged on
every non-N1 recommendation:

Recommended (rec.ResourceType) Purchased Type (before) Purchased Type (after)
n2-standard-16 GENERAL_PURPOSE GENERAL_PURPOSE_N2
c3-highcpu-22 GENERAL_PURPOSE COMPUTE_OPTIMIZED_C3
m3-ultramem-32 GENERAL_PURPOSE MEMORY_OPTIMIZED_M3
a3-megagpu-8g GENERAL_PURPOSE ACCELERATOR_OPTIMIZED_A3_MEGA

Commitment.Type selects which machine series the CUD discounts. An N1
commitment discounts no N2/C3/M3/A3 instance, so the customer paid the full
1- or 3-year commitment on top of undimmed on-demand charges, while the
purchase was reported as realized savings. The recommended machine type was
already on rec.ResourceType but was only read into the Description string.

The fix

Why a silent default is unacceptable here

An unmappable family returns an explicit error naming the machine type and the
purchase is refused before any Insert reaches GCP. It does not fall back
to GENERAL_PURPOSE. On a money path a silent default spends real money on a
commitment that discounts nothing and cannot be cancelled — strictly worse than
refusing and surfacing the gap.

Deliberately unmapped (fail loud): m4 and x4, whose commitment types split
into size buckets (MEMORY_OPTIMIZED_M4 vs _M4_6TB; _X4_480_6T,
_X4_960_12T, _X4_1440_24T, …) that are not derivable from the machine-type
name; t2a; and any series GCP has not published a Commitment_Type for.

Also in this diff

The sibling ResourceCommitment VCPU/MEMORY wire values now come from
computepb.ResourceCommitment enum constants instead of string literals,
keeping the #1022 "MEMORY_MB" regression compiler-checked. termPlan already
sourced TWELVE_MONTH/THIRTY_SIX_MONTH from the SDK enum and fails loud, so
it needed no change.

Regression coverage

Asserts on the Type carried by the InsertRegionCommitmentRequest actually
handed to the SDK client (captured by the mock), not on an intermediate struct
that could be right while the wire value is wrong:

  • TestComputeEngineClient_PurchaseCommitment_TypeMatchesMachineFamily
  • TestComputeEngineClient_PurchaseCommitment_UnmappableFamilyRefuses — asserts
    an error and that insertReqs stays empty.
  • TestComputeEngineClient_PurchaseCommitment_ResourceTypesUseSDKEnums
  • TestCommitmentTypeForMachineType_MapsFamilyToSDKEnum / _UnmappableFailsLoud

Confirmed failing pre-fix. Reverting only the Type derivation:

--- FAIL: TestComputeEngineClient_PurchaseCommitment_TypeMatchesMachineFamily
    --- PASS: .../n1-standard-4
    --- FAIL: .../n2-standard-16
        Error: Not equal:
            expected: "GENERAL_PURPOSE_N2"
            actual  : "GENERAL_PURPOSE"
        Messages: n2-standard-16 must be committed as GENERAL_PURPOSE_N2;
                  buying GENERAL_PURPOSE instead discounts nothing (issue #1538)
    --- FAIL: .../c3-highcpu-22   expected "COMPUTE_OPTIMIZED_C3",   actual "GENERAL_PURPOSE"
    --- FAIL: .../m3-ultramem-32  expected "MEMORY_OPTIMIZED_M3",    actual "GENERAL_PURPOSE"
    --- FAIL: .../a3-megagpu-8g   expected "ACCELERATOR_OPTIMIZED_A3_MEGA", actual "GENERAL_PURPOSE"

--- FAIL: TestComputeEngineClient_PurchaseCommitment_UnmappableFamilyRefuses
    --- FAIL: .../x4-megamem-960-metal
        Error: An error is expected but got nil.
        Error: Should be empty, but was [commitment_resource:{
                 description:"CUD for x4-megamem-960-metal" plan:"TWELVE_MONTH"
                 resources:{amount:8 type:"VCPU"} resources:{amount:65536 type:"MEMORY"}
                 type:"GENERAL_PURPOSE"} project:"test-project" region:"us-central1"]
        Messages: no Insert may reach GCP for an unmappable machine family (issue #1538)

n1-standard-4 passing pre-fix is the control: GENERAL_PURPOSE was already
correct for N1, which is exactly why the bug was invisible.

Gates

providers/gcp is a separate Go module, so its commands were run from inside
providers/gcp/.

Gate Exit
go build ./... (root) 0
go vet ./... (root) 0
go build ./... (providers/gcp) 0
go vet ./... (providers/gcp) 0
go test ./... (providers/gcp) 0
go test -race ./services/computeengine/ 0
gocyclo -over 10 providers/gcp/ 0 (no output)
gofmt -l providers/gcp/services/computeengine/ 0 (no output)
golangci-lint run ./... (v2.10.1, CI-pinned, whole providers/gcp module) 1
golangci-lint run ./services/computeengine/ (v2.10.1) 1

The golangci-lint exit 1 is pre-existing debt in this module, which is not
covered by CI (#1478).

Corrected measurement (an earlier revision of this body quoted 684, which was
the raw output line count -- each finding prints with ~3 lines of source
context -- not a finding count; scope was also unstated):

Scope, golangci-lint v2.10.1 Findings
providers/gcp/... before this PR 224
providers/gcp/... after this PR 222
providers/gcp/services/computeengine after this PR 50

Zero new findings: the finding sets with line numbers stripped diff to empty in
the new-findings direction. The two-finding drop is incidental -- both are
pre-existing godot "comment should end in a period" findings on doc comments
this PR rewrote. Baseline was produced by re-running the same pinned binary with
this diff reverted in place.

Closes #1538


Review follow-up (commit f5e645c6f)

Input side: the machine-type operation is now selected explicitly

extractResourceTypeFromOperations took the last / segment of the first
operation with a non-empty Resource, with no check that the path named a
machine type. A CUD recommendation's operations reference the commitment
resource itself, so a commitment-only payload set
rec.ResourceType = "cud-001" (the commitment name), which the new
derivation then rejected as machine family cud. The refusal was correct; the
stated cause was invented.

Now: only a resource path containing /machineTypes/ yields a machine type.
When none exists, the extractor returns an error naming the segment it looked
for and the resource paths it actually found, and ResourceType is left
empty rather than filled from an unrelated path segment. The purchase then
refuses with no machine type on the recommendation.

The recommendation is not dropped. Six existing tests depend on
operation-less payloads still converting, and a listed-but-unpurchasable
recommendation surfaces the problem where a silently discarded one would not.

This surfaces a pre-existing bug; it does not create one

The commitment-name-as-machine-type defect is already live on main, not
introduced here. The pre-fix run below emits, from the old extractor:

computeengine: pricing unavailable for cud-001 in us-central1

That is enrichRecWithPricing calling getComputePricing(rec.ResourceType, ...)
with the commitment name. The extractor has been returning a commitment name
as a machine type all along, and the pricing lookup has been silently failing on
it. Nothing consumed ResourceType strictly enough to make that visible.

So the change is not "a risky new refusal". It adds the first consumer that
cannot tolerate a non-machine-type value, which is what makes the pre-existing
data-shape bug observable, and then fixes the extractor that produced it.

Pre-fix proof, restoring only the old "first non-empty Resource, last segment"
extractor while keeping the new tests:

--- FAIL: TestExtractResourceTypeFromRecommendation_CommitmentOnlyOpsFailLoud
    Error:    An error is expected but got nil.
    Messages: a payload with no machineTypes operation must fail loud

--- FAIL: TestPurchaseCommitment_CommitmentOnlyRecommendationRefuses
    Error:    Should be empty, but was cud-001
    Messages: a commitment-only payload must not yield a machine type (issue #1538)
    (log)     computeengine: pricing unavailable for cud-001 in us-central1

--- FAIL: TestMachineTypeFromResourcePath_SelectsOnlyMachineTypePaths
    Error:    Should be false
    Messages: "//compute.googleapis.com/projects/test/regions/us-central1/commitments/cud-001"
              does not name a machine type
    Error:    Should be empty, but was cud-001

Accelerator / graphics / storage families now refuse

buildInsertRequest sends only VCPU and MEMORY. GCP requires an
ACCELERATOR resource in an ACCELERATOR_OPTIMIZED* / GRAPHICS_OPTIMIZED*
commitment and a LOCAL_SSD resource in STORAGE_OPTIMIZED_Z3, so the first
revision newly declared types whose required resources were not being sent.
Best case GCP rejects the insert; worst case it creates a commitment covering
the cheap vCPU/RAM and none of the GPUs or local SSDs that dominate the spend.

a2, a3, a4, g2, g4, z3 now take the same "not derivable, do not
guess" refusal as m4/x4, with an error naming the missing resource kind.
The a3 sub-series map is gone with them.

f1 / g1

Legacy shared-core members of the N1 lineage, correct under GENERAL_PURPOSE
before this mapping existed. Mapped alongside custom-* so the mapping does not
turn a working purchase into a refusal.

GroupCommitments and one vacuous assertion

Deliberately not done

The commitment recommender's operation value for path / carries the
commitment's own type, which would remove the family-mapping guess entirely.
Carrying it to buildInsertRequest requires a new field on
pkg/common.Recommendation -- a separate Go module shared by all three
providers, with persistence implications. Out of scope here; filed as #1663.

Also filed: #1664GroupCommitments groups by account+region+term only,
ignoring ResourceType, and its CommitmentRequest has no Type field at all,
so a future caller would sum n2 and c3 vCPUs into one untyped commitment. No
non-test caller today, which is exactly when it is cheap to fix.

Gates after the follow-up

Gate Exit
go build ./... (root) 0
go vet ./... (root) 0
go build ./... (providers/gcp) 0
go vet ./... (providers/gcp) 0
go test ./... (providers/gcp) 0 (119 tests in computeengine)
go test -race ./services/computeengine/ 0
gocyclo -over 10 providers/gcp/ 0 (no output)
gofmt -l providers/gcp/services/computeengine/ 0 (no output)
golangci-lint run ./... (v2.10.1, providers/gcp) 1 -- 222 findings, zero new vs the 224-finding baseline

buildInsertRequest pinned the GCP commitment Type to the string literal
"GENERAL_PURPOSE" for every purchase. GENERAL_PURPOSE is the N1-only
member of computepb.Commitment_Type, so what the customer requested and
what CUDly bought diverged on every non-N1 recommendation:

  requested n2-standard-16  ->  bought GENERAL_PURPOSE (N1)
  requested c3-highcpu-22   ->  bought GENERAL_PURPOSE (N1)
  requested m3-ultramem-32  ->  bought GENERAL_PURPOSE (N1)
  requested a3-megagpu-8g   ->  bought GENERAL_PURPOSE (N1)

Commitment.Type selects which machine series the discount applies to. An
N1 commitment discounts no N2/C3/M3/A3 instance, so the customer paid the
full 1- or 3-year commitment on top of undimmed on-demand charges while
the purchase was booked as realized savings. The recommended machine type
was already available on rec.ResourceType but was only read into the
Description string.

The Type is now derived from the machine-family segment of
rec.ResourceType and mapped onto the SDK's own computepb.Commitment_Type
constants, so a wrong member is a compile error rather than a wire-level
surprise. Legacy N1 "custom-<vCPUs>-<MB>" names, which carry no family
segment, map to GENERAL_PURPOSE; family-prefixed custom types resolve on
their own family.

An unmappable family returns an explicit error naming the machine type
and refuses the purchase. It does not fall back to GENERAL_PURPOSE:
on a money path a silent default spends real money on a commitment that
discounts nothing and cannot be cancelled, which is strictly worse than
refusing and surfacing the gap. This deliberately covers m4 and x4, whose
commitment types split into size buckets not derivable from the machine
type name, t2a, and any series GCP has not published a Commitment_Type
for.

The sibling ResourceCommitment VCPU/MEMORY wire values now also come from
computepb.ResourceCommitment enum constants rather than string literals,
keeping the issue #1022 "MEMORY_MB" regression compiler-checked.

Regression coverage asserts on the Type carried by the
InsertRegionCommitmentRequest handed to the SDK client, not on an
intermediate struct, and asserts that an unmappable family produces an
error with no Insert reaching GCP at all.

Closes #1538
@cristim cristim added effort/m Days impact/many Affects most users priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things labels Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97d654d3-7148-449a-986d-e3e325904d1d

📥 Commits

Reviewing files that changed from the base of the PR and between 887d51f and f5e645c.

📒 Files selected for processing (2)
  • providers/gcp/services/computeengine/client.go
  • providers/gcp/services/computeengine/client_test.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1538-gcp-cud-commitment-type

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Review follow-up on the #1538 commitment-type derivation.

Input side. extractResourceTypeFromOperations took the last "/" segment of
the FIRST operation carrying a non-empty Resource, with no check that the
path named a machine type. A CUD recommendation's operations reference the
commitment resource itself
("//compute.googleapis.com/projects/p/regions/r/commitments/cud-001"), so
on a commitment-only payload rec.ResourceType became the commitment NAME
"cud-001", which the new derivation would then reject as machine family
"cud". The purchase refusal was correct but the stated cause was invented.

The machine-type operation is now selected explicitly: only a resource path
containing "/machineTypes/" yields a machine type. When no such operation
exists the extractor returns an error naming the segment it looked for and
the resource paths it actually found. ResourceType is left EMPTY rather
than filled from an unrelated path segment, the recommendation stays
visible, and the purchase refuses with "no machine type on the
recommendation" instead of a fabricated family. The recommendation is not
dropped: six existing tests depend on operation-less payloads still
converting, and a listed-but-unpurchasable recommendation surfaces the
problem where a silently discarded one would not.

Accelerator, graphics and storage families. buildInsertRequest sends only
VCPU and MEMORY resources. GCP requires an ACCELERATOR resource in an
ACCELERATOR_OPTIMIZED*/GRAPHICS_OPTIMIZED* commitment and a LOCAL_SSD
resource in STORAGE_OPTIMIZED_Z3, so mapping a2/a3/a4/g2/g4/z3 newly
DECLARED types whose required resources were not sent. Best case GCP
rejects the insert; worst case it creates a commitment covering the cheap
vCPU and RAM and none of the GPUs or local SSDs that dominate the spend.
Those families now take the same "not derivable, do not guess" refusal as
m4/x4, with an error naming the missing resource kind.

f1-micro and g1-small are legacy shared-core members of the N1 lineage and
resolved correctly before this mapping existed; they are mapped to
GENERAL_PURPOSE alongside custom-* so the mapping does not turn a working
purchase into a refusal.

GroupCommitments now takes its VCPU/MEMORY values from the SDK enum too.
It has no non-test caller, but it is exactly where a future caller would
reintroduce the invalid "MEMORY_MB" spelling of issue #1022.

TestComputeEngineClient_PurchaseCommitment_ResourceTypesUseSDKEnums
compared an SDK-derived expected against an SDK-derived actual, so it
passed on the pre-fix literal-string code and pinned nothing. It now
asserts the literal wire strings "VCPU" and "MEMORY".

Reading the commitment's own type field out of the recommender operation
value would remove the family-mapping guess entirely, but carrying it to
buildInsertRequest requires a new field on pkg/common.Recommendation, a
separate module shared by all three providers. Left out of this fix
deliberately.

Refs #1538
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed f5e645c6f addressing the review findings. Summary (details in the updated PR description):

  • Input side (HIGH): extractResourceTypeFromOperations took the last / segment of the first operation with a non-empty Resource, so a commitment-only payload yielded the commitment name (cud-001), which the new derivation then rejected as machine family cud. The machine-type operation is now selected explicitly (path must contain /machineTypes/); when none exists the extractor errors naming what it looked for and what it found, ResourceType stays empty, and the purchase refuses with no machine type on the recommendation. The recommendation is not dropped -- six existing tests depend on operation-less payloads still converting, and a listed-but-unpurchasable recommendation is more visible than a silently discarded one.
  • Accelerator/graphics/storage (MEDIUM): buildInsertRequest sends only VCPU+MEMORY, but GCP requires ACCELERATOR in ACCELERATOR_OPTIMIZED*/GRAPHICS_OPTIMIZED* and LOCAL_SSD in STORAGE_OPTIMIZED_Z3. a2/a3/a4/g2/g4/z3 now take the same fail-loud refusal as m4/x4, naming the missing resource kind. The A3 sub-series map is removed.
  • f1/g1 (LOW): mapped to GENERAL_PURPOSE alongside custom-* so the mapping does not turn a previously-working purchase into a refusal.
  • GroupCommitments + vacuous test (LOW): literals converted to SDK enums; the resource-type test now asserts the literal wire strings "VCPU"/"MEMORY" instead of comparing SDK-derived to SDK-derived (it passed on the pre-fix code).
  • Lint figure corrected: the earlier "684" was the raw output line count, not findings. Re-measured at the CI pin (v2.10.1): 224 findings on providers/gcp/... before, 222 after, 50 in the computeengine package. Zero new; the two-finding drop is pre-existing godot findings on doc comments this PR rewrote.

Not done, deliberately: reading the commitment's own type from the recommender operation value would remove the family-mapping guess entirely, but carrying it to buildInsertRequest needs a new field on pkg/common.Recommendation, a separate module shared by all three providers. Worth a follow-up issue.

Pre-fix failure output for the new commitment-only regression is in the PR description.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Changed files ==='
git diff --name-only HEAD^ HEAD

echo '=== Diff stat ==='
git diff --stat HEAD^ HEAD

echo '=== Relevant symbol locations ==='
rg -n -C 3 \
  'func (extractResourceTypeFromOperations|machineTypeFromResourcePath|commitmentTypeForMachineType|buildInsertRequest|GroupCommitments)\b|extractResourceTypeFromOperations\s*\(|buildInsertRequest\s*\(' \
  providers/gcp 2>/dev/null || true

echo '=== Go file outlines in computeengine ==='
fd -e go . providers/gcp/services/computeengine -x ast-grep outline {} --items all --type function --match 'extractResourceTypeFromOperations|machineTypeFromResourcePath|commitmentTypeForMachineType|buildInsertRequest|GroupCommitments' --view expanded 2>/dev/null || true

echo '=== Updated patch ==='
git diff --find-renames HEAD^ HEAD -- providers/gcp

Length of output: 34365


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Commitment mapping and request construction ==='
sed -n '40,220p' providers/gcp/services/computeengine/client.go
sed -n '720,840p' providers/gcp/services/computeengine/client.go

echo '=== Recommendation conversion and path extraction ==='
sed -n '1040,1255p' providers/gcp/services/computeengine/client.go

echo '=== All extractor and selector call sites ==='
rg -n -C 5 \
  '\bextractResourceTypeFromRecommendation\s*\(|\bmachineTypeFromResourcePath\s*\(|\.ResourceType\b' \
  providers/gcp --glob '*.go'

echo '=== Recommender operation fixtures and resource paths ==='
rg -n -C 5 \
  'OperationGroups|machineTypes|commitments/|ResourceType:\s*"compute.googleapis.com/Commitment"' \
  providers/gcp --glob '*_test.go'

echo '=== Purchase mocks and relevant regression-test range ==='
rg -n -C 4 \
  'type MockCommitmentsService|func \(.*MockCommitmentsService.*Insert|insertReqs|lastInsertReq' \
  providers/gcp/services/computeengine --glob '*_test.go'
sed -n '1580,1970p' providers/gcp/services/computeengine/client_test.go

Length of output: 139120


@cristim I found one blocking issue in the follow-up:

  • High — the supported recommender payload is now universally unpurchasable. extractResourceTypeFromRecommendation (Line 1179) only accepts an operation resource containing /machineTypes/, but the new commitmentOnlyCUDRecommendation fixture (Line 1831) is documented as the expected commitment-recommender shape and has only /commitments/... resources. Conversion consequently leaves ResourceType empty (Line 1102), and buildInsertRequest refuses every such recommendation when it derives the commitment type (Line 755). The regression test currently codifies that refusal rather than preserving a viable purchase path.

    Refusing an ambiguous purchase is correct, but this changes the normal Compute CUD recommender flow from “wrong commitment” to “no commitment can be bought.” Please propagate the commitment operation’s authoritative type through common.Recommendation (or otherwise obtain an authoritative commitment type) before exposing these recommendations as purchasable. Add an end-to-end fixture for the real commitment-only payload that verifies the correct Commitment.Type reaches Insert.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(gcp): every CUD is purchased as Type GENERAL_PURPOSE regardless of the recommended machine family

1 participant