fix(azure): sanitize Azure DisplayName to [A-Za-z0-9_-] for calculatePrice (closes #685)#686
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR introduces a centralized utility to sanitize Azure Reservation display names across multiple service clients, ensuring all reservation requests conform to Azure's allowlist constraints of letters, digits, hyphens, and underscores, with a 64-character maximum length. ChangesDisplay Name Sanitization for Azure Reservations
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Possibly related issues
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
…playName allowlist Azure's Reservations API rejects DisplayName values that contain characters outside [A-Za-z0-9_-] with HTTP 400 DisplayNameInvalid. Add a shared helper in services/internal/reservations that replaces non-conforming characters with '_', collapses consecutive non-conforming chars into a single '_', and truncates to 64 characters.
…oss 5 services (closes #685) Replace space-containing displayName format strings with underscore-form prefixes and pipe through SanitizeDisplayName in all 5 Azure service clients (cache, compute, cosmosdb, database, search). Fixes HTTP 400 DisplayNameInvalid errors from Azure's Reservations API when SKU names contain unusual characters. Extends each service's PurchaseCommitment test with a body-capture assertion that guards the [A-Za-z0-9_-]{1,64} allowlist going forward. The capture mocks the calculatePrice POST so it works correctly with the two-step calculatePrice->purchase flow established by #680.
76bab30 to
35bac3c
Compare
|
Rebased onto latest feat/multicloud-web-frontend. Resolved 7 conflicts across 5 service clients: 5 client.go import-line conflicts (#680's opts.Source guard now sits alongside our displayName sanitization, both invariants apply at PurchaseCommitment entry) and 2 client_test.go conflicts (database/search). Also consolidated the new displayname.go helper into providers/azure/services/internal/reservations/ (same package as DoPurchaseTwoStep) so the displayName line can resolve the symbol without a second |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Extend the Azure DisplayName fix from #686 with a richer, parseable format that mirrors the AWS RI CSV's ReservationId column shape so operators can identify reservations in the Azure portal without cross-referencing the purchase audit log. Format (each segment separated by '-'): {svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand} vm -eastus -Standard_D2a_v4-1x-1yr-allup -20260522T190000-a1b2c3d4 redis -westeurope-Premium_P1 -1x-1yr-allup -20260522T190000-a1b2c3d4 cosmos -northeurope-EnableCassandra-1x-1yr-allup-20260522T190000-a1b2c3d4 sql -centralus -GP_Gen5_2 -1x-1yr-allup -20260522T190000-a1b2c3d4 search -westus2 -standard2 -1x-1yr-allup -20260522T190000-a1b2c3d4 Output is always sanitized to Azure's [A-Za-z0-9_-]{1,64} allowlist. When the composed string would exceed 64 chars (long region + long SKU + high count), the builder progressively drops the optional tail segments — random suffix first, then timestamp, then payment-option. The service code, region, SKU, count, and term are NEVER dropped: those are the high-signal segments operators rely on. With every optional segment dropped, the joined required segments are truncated as a last-resort safety net so the builder never emits >64 chars. Previous format was `VM_Reservation_<SKU>` (and equivalents per service) — operators reported these were indistinguishable across purchases. The new identifier carries enough context to identify the specific reservation purchase from the portal alone. Implementation lives in providers/azure/services/internal/reservations/displayname.go alongside the existing SanitizeDisplayName helper, and is wired into all 5 service clients (cache, compute, cosmosdb, database, search). Time and random sources are injectable so tests can pin them; tests cover happy-path, per-service shape, length-fit truncation at each drop priority (random → timestamp → payment), payment normalization, sanitization of dirty input, embedded-dash collapse, determinism, and UTC normalization. The 5 per-service allowlist regression tests are extended to also assert the service-code prefix and SKU presence so accidental swaps in the literal strings are caught. Refs #685
|
Extended this PR with a richer DisplayName format that mirrors the AWS RI CSV's Format (each segment separated by Example (compute / VM): Per-service codes: Length-fit strategy. When the composed string would exceed 64 chars (long region + long SKU + high count), the builder progressively drops the optional tail segments — random first → timestamp → payment-option. Service code, region, SKU, count, and term are never dropped (those are the high-signal segments). If even the joined required segments would exceed 64, they're truncated via Backward-compat note. This changes the operator-visible DisplayName from Code: Tests: helper has 14 sub-tests covering happy-path, per-service shape, three length-fit drop priorities, the pathological-input truncation fallback, payment normalization (both unit and visible-in-output), term normalization, dirty-input sanitization, embedded-dash collapse, determinism, different-rand-different-output, production crypto/rand path, empty-payment skip, and UTC normalization. Per-service @coderabbitai review |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 1580 🏁 Script executed: Length of output: 146191
🔴 Critical: Progressive-drop loop is broken — it always truncates instead of dropping whole segments
The tests still pass, but by coincidence: for every test input the required segments happen to land entirely within the first 64 bytes of the full format, and the optional segments are naturally chopped off by truncation. The structured drop the comments describe never executes. Example (germanywestcentral + Standard_NV24ads_A10_v5 + 999x): These happen to be identical here, but won't be for shorter SKUs where the timestamp fits in 64 chars (e.g. Fix — check the pre-sanitized length, then sanitize only when returning: for keep := len(tail); keep >= 0; keep-- {
segments := append([]string{}, required...)
segments = append(segments, tail[:keep]...)
candidate := joinNonEmpty(segments, "-")
if len(candidate) <= azureDisplayNameMaxLen {
return SanitizeDisplayName(candidate) // sanitize once, on the exit path
}
}
// last-resort: truncate required-only via sanitizer
return SanitizeDisplayName(joinNonEmpty(required, "-"))This also eliminates the redundant 🟡 Minor:
|
…Name Address CodeRabbit findings on PR #686. Critical: BuildDisplayName's progressive-drop loop was broken. It called SanitizeDisplayName(candidate) inside the loop before checking the length cap. Because the sanitizer hard-truncates to 64 chars, the condition len(candidate) <= 64 was vacuously true on iteration 1 and the loop exited immediately, returning a mid-string-truncated full format instead of cleanly dropping the random suffix, timestamp, and payment segments. The existing tests passed only by coincidence (all test inputs happened to land their required segments within the first 64 bytes of the full format). Fix: check the pre-sanitized length to gate segment-dropping, and sanitize only on the exit path. Each segment is already conformant via normalizeSegment, so the in-loop sanitization was both wrong and redundant. Add three regression tests that fail against the pre-fix code: - DropLoopActuallyDropsTimestamp: forces the loop to drop both random and timestamp; asserts the exact clean output and that no timestamp digits leak in. - DropLoopActuallyDropsPayment: forces required-only fallback; asserts no payment fragment survives. - ZeroNowReplacedByWallClock: asserts a zero-value Now is replaced by time.Now() rather than emitting the placeholder "00010101T000000". Also address three minor doc/safety findings on the same file: - Clarify isAllowedDisplayNameChar godoc so the [A-Za-z0-9-] description matches reality (underscores are passed through by the caller). - Guard the zero-value Now case so production callers that forget to set it get a real timestamp instead of "00010101T000000". - Document generateRandSuffix's silent fallback for non-nil src shorter than 4 bytes. Verification: stash-revert the production code change with the new tests in place; all three regression tests fail with exactly the expected mid-truncated outputs. Restore the fix; all 68 reservations tests pass and all 620 azure-package tests pass.
|
Addressed in 1e3fabe:
All 68 reservations tests and all 620 azure-package tests pass. @coderabbitai review |
|
Triggering a fresh review of the rebased branch now. 🐇 ✨ ✅ Actions performedReview triggered.
|
1 similar comment
|
Triggering a fresh review of the rebased branch now. 🐇 ✨ ✅ Actions performedReview triggered.
|
Add a shared builder that composes self-describing identifiers for AWS reservation purchases. The format mirrors the Azure DisplayName format introduced in #686 (cross-cloud symmetry for ops dashboards): {svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand} Output is always sanitized via SanitizeReservationID for the AWS reservation-name allowlist ([a-zA-Z0-9-], dots in SKUs become hyphens) and capped at 60 characters — the tightest length cap across the five AWS services that accept a customer-supplied reservation ID/name (RDS ReservedDBInstanceId, ElastiCache ReservedCacheNodeId, MemoryDB ReservationId, OpenSearch ReservationName). When composed length exceeds the cap, optional tail segments drop in priority order: random suffix first, then timestamp, then payment option. The service code, region, SKU, count, and term are never dropped. In the pathological worst case the builder truncates the SKU itself so count and term still survive the final assembly. Time and random sources are injectable for deterministic tests. Refs #687 pkg/common/reservation_name.go | +201 pkg/common/reservation_name_test.go | +281
…closes #687) (#689) * feat(common): add BuildReservationName for rich AWS reservation IDs Add a shared builder that composes self-describing identifiers for AWS reservation purchases. The format mirrors the Azure DisplayName format introduced in #686 (cross-cloud symmetry for ops dashboards): {svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand} Output is always sanitized via SanitizeReservationID for the AWS reservation-name allowlist ([a-zA-Z0-9-], dots in SKUs become hyphens) and capped at 60 characters — the tightest length cap across the five AWS services that accept a customer-supplied reservation ID/name (RDS ReservedDBInstanceId, ElastiCache ReservedCacheNodeId, MemoryDB ReservationId, OpenSearch ReservationName). When composed length exceeds the cap, optional tail segments drop in priority order: random suffix first, then timestamp, then payment option. The service code, region, SKU, count, and term are never dropped. In the pathological worst case the builder truncates the SKU itself so count and term still survive the final assembly. Time and random sources are injectable for deterministic tests. Refs #687 pkg/common/reservation_name.go | +201 pkg/common/reservation_name_test.go | +281 * feat(aws): rich self-describing reservation names matching Azure (closes #687) Wire common.BuildReservationName into the five AWS reservation purchase paths. On the no-token CLI fallback path (i.e. when opts.IdempotencyToken is empty), each service now composes a self-describing identifier of shape: {svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand} For example, an OpenSearch RI purchase that previously yielded the opaque `opensearch-Standard_X-1779468234` now becomes `opensearch-us-east-1-r6g-large-search-3x-1yr-allup-20260521T002019-a1b2c3d4` (truncated to 60 chars per service allowlist; see #687). Per service: * rds: deriveReservationID's fmt.Sprintf fallback -> BuildReservationName ("rds-" prefix). opts.ReservationID still wins when set; idempotency token still wins above both. * elasticache: ReservedCacheNodeId fallback -> BuildReservationName ("cache-" prefix). * memorydb: ReservationId fallback -> BuildReservationName ("memdb-"). * opensearch: ReservationName fallback -> BuildReservationName ("opensearch-"). * redshift: PurchaseReservedNodeOffering doesn't accept a customer- supplied node ID, so the rich descriptors travel as tags instead. tagReservedNode now writes Count / Term / PaymentOption alongside the pre-existing NodeType / Region / PurchaseDate / Tool tags so the reserved node is identifiable from the AWS console alone. The token-based path (issue #641) is unchanged: IdempotentReservationID still derives the deterministic ID from the token so a re-drive sends the identical name and AWS rejects the duplicate server-side. The rich builder applies ONLY to the no-token fallback. Tests: one new per-service capture test asserts ^{svc-code}- prefix, region presence, SKU (dots->hyphens) presence, count+term embedded, and the 60-char cap. Redshift's test asserts the new tag keys (Count/Term/PaymentOption) appear in the CreateTags input. Closes #687 * fix(common): default zero Now in reservation name + assert payment drop in length test Default f.Now to time.Now() when IsZero() so a missed caller field produces a current timestamp rather than a year-0001 artifact. Deterministic tests are unaffected because they set Now: testFixedNow explicitly. Add assert.NotContains(t, got, "allup", ...) to TestBuildReservationName_LengthFit_DropsPaymentLast so the test actually verifies the payment segment is dropped in the tight-length regime, not just that timestamp and random were removed. Addresses CodeRabbit findings on PR #689.
Summary
DisplayNamevalues containing characters outside[A-Za-z0-9_-]with HTTP 400DisplayNameInvalid— blocking every approve for affected SKUs (e.g.Standard_D2a_v4)SanitizeDisplayNamehelper inproviders/azure/internal/reservations/that replaces non-allowlisted characters with_, collapses runs, and truncates to 64 charsTest plan
go test ./... -count=1passes (362 tests, all green)SanitizeDisplayNamecovering: spaces, special chars, run-collapsing, exact-64/65/100-char truncation, already-conformant input, empty inputPurchaseCommitment_DisplayNameConformsToAzureAllowlisttests capture the PUT body and assert^[A-Za-z0-9_-]{1,64}$— regression guard for future format changesgofmt,go vet,gocycloall pass via pre-commit hooksAfter merge + deploy, the user can retry the previously-failing execution
d73b0ffe-8ada-42e0-902b-27a63ce54088(Standard_D2a_v4 VM).Summary by CodeRabbit
Bug Fixes
Tests