Skip to content

chore(aws): medium/low findings from the 2026-07-28 full review #53

Description

@cristim

Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.

Tracking issue for the MEDIUM and LOW findings from the 2026-07-28 review of providers/aws (all 34 non-test Go files: services/{ec2,rds,elasticache,opensearch,redshift,memorydb,savingsplans}, recommendations/, ladder/, internal/, provider.go, service_client.go). The HIGH findings from the same pass are filed as individual issues; this pass produced no CRITICAL.

Context: this module is not covered by CI (LeanerCloud/cloud-commitments-cli#1478), so nothing in this checklist has been vetted by go vet, golangci-lint, or the unit/integration suites.

Each checklist item is independently actionable. Please keep the file:line, failure scenario and fix direction with the item when splitting any of these out.


  • Savings Plan commitment is rounded to 2 decimals before purchase, changing the approved dollar amount (MEDIUM)

    • Where: providers/aws/services/savingsplans/client.go:232 (Commitment: aws.String(fmt.Sprintf("%.2f", spDetails.HourlyCommitment))); the pre-rounding positivity guard is at providers/aws/ladder/purchase.go:141-143
    • What: Cost Explorer returns HourlyCommitmentToPurchase at full precision and the AWS Savings Plans API accepts sub-cent commitments. %.2f both rounds and can floor to zero. The ladder validates HourlyCommitment > 0, but that guard runs on the pre-rounding value, so a positive commitment can still be sent as "0.00".
    • Failure scenario: a ladder ramp step or a small account produces HourlyCommitment = 0.0142/hr. CUDly buys 0.01/hr, a 30% under-commit against the approved plan, silently, for the whole 1 to 3 year term. At HourlyCommitment = 0.004 the request carries "0.00": either AWS rejects it (an opaque failure on an already-approved purchase) or a zero-value commitment is created. Rounding the other way (0.0151 -> 0.02) over-commits real money beyond what was approved.
    • Fix: format with the precision the API accepts (%.3f, or strconv.FormatFloat(v, 'f', -1, 64)) and reject a commitment that would not round-trip, rather than silently re-pricing the approved amount.
  • ElastiCache offering lookup passes an unvalidated (possibly empty) engine and accepts the first offering on the page (MEDIUM)

    • Where: providers/aws/services/elasticache/client.go:309-316 (ProductDescription: aws.String(details.Engine)) and :346-357 (scanElastiCacheOfferingPage returns the first entry after checking only OfferingType); the producing parser sets Engine only when CE supplies it, providers/aws/recommendations/parser_services.go:69-71
    • What: CacheDetails.Engine is copied verbatim from CE's ProductDescription with no validation and no non-empty guard. This is the one remaining service without the guard its siblings have: RDS refuses an empty AZConfig and ambiguous engines (rds/client.go:314-322, :573-619), and MemoryDB refuses a missing NodeType (parser_services.go:247-249). The page scanner then validates only the payment option, not node type, duration, or engine, so whatever AWS returns first for the remaining filter set is purchased.
    • Failure scenario: CE omits ProductDescription (nil pointer) for an ElastiCache recommendation, so cacheInfo.Engine stays "" and the request carries an empty ProductDescription filter. Redis and Memcached reserved nodes for the same node type, term and payment option are priced differently and cover different clusters. If AWS treats the empty value as "unfiltered", the first returned offering is bought and the customer holds a reservation for the wrong engine. If AWS instead treats it as "match nothing", the purchase fails with the misleading "no offerings found" rather than naming the missing field.
    • Fix: reject an empty details.Engine in findOfferingID with an explicit error (same shape as the RDS AZConfig guard), and have scanElastiCacheOfferingPage verify CacheNodeType, Duration and ProductDescription on the chosen offering before returning it. Note that LeanerCloud/cloud-commitments-cli#1052's 08-H3 covered the ElastiCache payment-option default, which is already fixed at :462-475; the engine and the first-match-wins scan are not covered by it.
  • The CE-supplied Savings Plans offering ID short-circuits the scoped-client plan-type guard (MEDIUM)

    • Where: providers/aws/services/savingsplans/client.go:425-428 in findOfferingID: the spDetails.OfferingID != "" early return happens before resolveSPPlanType (:430), convertTermToSeconds (:434) and convertPaymentOption (:438)
    • What: resolveSPPlanType exists specifically to "reject mismatches to prevent buying the wrong product" (:275), and the term and payment converters are the fail-loud validation for those two fields. All three are skipped whenever the recommendation carries an offering ID, which is the normal case for EC2Instance SPs (providers/aws/recommendations/parser_sp.go:259). Nothing downstream re-checks: CreateSavingsPlan is called with the offering ID and the commitment only (:230-236).
    • Failure scenario: a persisted recommendation row tagged savings-plans-compute (so provider.go:472-477 builds a Compute-scoped client) but whose JSONB Details carry plan_type: "EC2Instance" and an EC2Instance offering_id. That is exactly the shape produced by pre-split umbrella rows, which provider.go:478-488 exists to handle because such rows do persist. The scope guard that would have refused the purchase never runs, and a region-locked and family-locked EC2Instance SP is bought under a Compute SP plan and approval. Similarly, a row whose Term or PaymentOption are unparseable buys whatever the offering ID encodes instead of failing loud.
    • Fix: run resolveSPPlanType, convertTermToSeconds and convertPaymentOption before the offering-ID short-circuit so the fast path is validated identically to the lookup path.
  • The RI recommendation fetch uses the deprecated silent converters, so a bad term or payment silently gets Cost Explorer's defaults under the requested label (MEDIUM)

    • Where: providers/aws/recommendations/client.go:149-155 calls convertPaymentOption, convertTermInYears and convertLookbackPeriod, the three functions explicitly marked Deprecated: ... silently returns the empty ("") at providers/aws/recommendations/converters.go:32-42, :74-84, :102-112; the parser then labels every returned rec with the requested strings at parser_ri.go:40-47 (PaymentOption: params.PaymentOption, Term: params.Term)
    • What: an unrecognised value yields the zero-value enum, which the SDK omits from the request. Cost Explorer then applies its own server-side defaults for TermInYears, PaymentOption and LookbackPeriodInDays rather than erroring. The recommendation is stored and displayed with the caller's string while its savings figures describe a different commitment.
    • Failure scenario: the scheduler's fallback path builds Term: fmt.Sprintf("%dyr", globalCfg.DefaultTerm) and PaymentOption: globalCfg.DefaultPayment (internal/scheduler/scheduler.go:894-898). With DefaultTerm unset this is "0yr", and with DefaultPayment unset it is "". Both convert to "", CE returns its default-term and default-payment recommendations, and the rows land in the DB labelled term=0yr, payment= with savings that belong to a different commitment shape. The operator sees a savings number that will never be realised, and the purchase later fails at getDurationValue("0yr") (ec2/client.go:672-681) with no link back to the mislabelled rec.
    • Fix: switch GetRecommendations to convertPaymentOptionE / convertTermInYearsE / convertLookbackPeriodE and propagate the error; delete the deprecated wrappers once the last caller is gone. The deprecation note points at fix(aws/recommendations): protect RateLimiter against concurrent access (closes #271) cloud-commitments-cli#865 and fix(aws): payment-option/engine/term correctness gaps in AWS RI service clients cloud-commitments-cli#1075, both closed; no open issue covers the remaining caller.
  • No purchase path reconciles the resolved offering's price against the approved recommendation cost, and EC2's LimitPrice cap is unused (MEDIUM)

    • Where: providers/aws/services/ec2/client.go:151-155 (PurchaseReservedInstancesOfferingInput built with only ReservedInstancesOfferingId and InstanceCount; the SDK's LimitPrice *types.ReservedInstanceLimitPrice field is available and never set), and the equivalent inputs at rds/client.go:162-167, elasticache/client.go:163-168, memorydb/client.go:159-164, opensearch/client.go:202-206, redshift/client.go:189-192
    • What: offering selection is "first match on the filtered page", and the offering's FixedPrice / UsagePrice are never compared against rec.CommitmentCost (the CE-parsed upfront, parser_ri.go:167-173) before committing. EC2 is the one AWS RI API that offers a server-enforced ceiling on the total charge, and it is left nil.
    • Failure scenario: a recommendation is collected with UpfrontCost = $12,000 and approved under a MaxPurchaseAmount constraint evaluated on that stored figure. By execution time (recommendations are cached and plans can be scheduled days later) AWS has repriced the offering, or the filter set matched a differently-priced variant. The purchase proceeds at the real price with no ceiling and no post-hoc comparison, so the approved cap is bypassed and nothing in the result signals the divergence. result.Cost is not even populated for EC2, which is the separately filed HIGH from this pass, so the divergence is not even recorded after the fact.
    • Fix: fetch the resolved offering's price during findOfferingID, refuse (or require re-approval) when it exceeds the recommendation's cost by more than a configured tolerance, and set LimitPrice on the EC2 purchase as server-side defence in depth.
  • CE utilization is keyed by SUBSCRIPTION_ID and joined against ReservedInstancesId; if the two vocabularies differ, RI reshape silently never fires (MEDIUM, verification gap)

    • Where: providers/aws/recommendations/utilization.go:57-62 (GroupBy: SUBSCRIPTION_ID) then :162 (id := aws.ToString(detail.Key)) into RIUtilization.ReservedInstanceID. Consumers join on that string: providers/aws/ladder/layer_states.go:337-350 (utilsForConvertibleRIs intersects against ec2svc.ConvertibleRI.ReservedInstanceID, sourced from DescribeReservedInstances.ReservedInstancesId at ec2/client.go:743) and internal/api/handler_ri_exchange.go:495 into pkg/exchange/reshape.go:418,445 (utilMap[u.RIID], then util, ok := utilMap[ri.ID])
    • What: the join assumes CE's SUBSCRIPTION_ID group key is byte-identical to the EC2 ReservedInstancesId UUID. That equivalence is not established anywhere in the code, and both consumers fail silently if it does not hold: analyzeRI returns nil on a lookup miss (reshape.go:445-448) and computeRIUtilizationPct returns nil when the filtered slice is empty (layer_states.go:358-367).
    • Failure scenario: if CE returns a subscription identifier rather than the RI UUID, utilsForConvertibleRIs filters every entry out, the ladder's UtilizationPct is permanently nil ("unmeasured"), reshape triggering is disabled, and AnalyzeReshaping returns zero recommendations for an account full of 20%-utilised convertible RIs. The feature appears to work (no errors, empty result) while producing nothing.
    • Fix: verify the key vocabulary once against a live account and, either way, log or count the join miss rate so a total mismatch surfaces as a warning instead of an empty result set. Distinct from LeanerCloud/cloud-commitments-cli#1482, which is about pool-key blending in the coverage path.
  • normalizeRegionName misses the modern "Europe (...)" display names Cost Explorer uses (LOW)

    • Where: providers/aws/recommendations/converters.go:137-166 (the alias map) and :173-178 (the trailing prefix checks)
    • What: the map carries the retired EU (Ireland) / EU (Frankfurt) / EU (London) / EU (Paris) / EU (Stockholm) spellings, but the current Europe (...) spellings only for Milan, Spain and Zurich. The trailing prefix checks are dead code: both branches return region unchanged.
    • Failure scenario: if CE returns Europe (Ireland) for an RDS, EC2 or ElastiCache recommendation, rec.Region becomes the literal display string. Coverage lookups key on region:instance_type (coverage.go:84-86) and will never match the eu-west-1-keyed coverage entry, so ExistingCoveragePct stays 0 and sizing over-buys. Region filters and per-region client construction (provider.go:456-457) also misbehave.
    • Fix: add the Europe (...) aliases (and the newer ap-southeast-5 / ap-southeast-7, ca-west-1, mx-central-1 regions), and drop the dead prefix branch.
  • resolveAccountID memoises a transient STS failure for the client's lifetime, permanently disabling Redshift's idempotency guard (LOW)

    • Where: providers/aws/services/redshift/client.go:307-322 and providers/aws/services/opensearch/client.go:312-327 (c.accountOnce.Do stores c.accountErr)
    • What: a single failed GetCallerIdentity (throttle, transient network error, or a context deadline on the first call) is cached, and every later call returns the same error without retrying. On Redshift that error is fatal to the pre-purchase tag guard, which fails loud by design (:174-186).
    • Failure scenario: one throttled STS call at the start of a purchase run makes every subsequent Redshift purchase in that run return "idempotency lookup failed ... refusing to purchase", and post-purchase tagging is disabled for the client, so a later re-drive cannot recognise already-purchased nodes.
    • Fix: cache only successful lookups and retry on error.
  • Savings Plans commitment dates are silently dropped when unparseable (LOW)

    • Where: providers/aws/services/savingsplans/client.go:195-204 (toCommitment ignores the time.Parse error, leaving StartDate and EndDate at the zero time)
    • What: the ladder's own SP mapper fails loud on the same fields for exactly this reason. Its comment at providers/aws/ladder/adapters.go:195-205 says a silently zero EndDate "would drop the SP from sumExpiringSPHourlyCost and understate expiring commitment". The provider-level GetExistingCommitments has the inconsistent, silent behaviour.
    • Failure scenario: an SP with a malformed End string yields EndDate = zero, which expiringCountsByPool (providers/aws/recommendations/expiry.go:52) and sumExpiringSPHourlyCost both skip as "no expiry known". The expiring commitment is never replaced, so the account silently drops to on-demand when the SP lapses.
    • Fix: return an error (or at minimum log) instead of discarding the parse failure, matching parseSPDate.
  • GetRICoverageMap silently substitutes a 30-day window for a non-positive lookbackDays (LOW)

    • Where: providers/aws/recommendations/coverage.go:179-181; the same pattern at providers/aws/recommendations/utilization.go:45-47
    • What: a caller misconfiguration (lookbackDays = 0) is absorbed into a hardcoded 30, while the sibling Savings Plans paths reject it explicitly (sp_coverage.go:305-307, :420-422).
    • Failure scenario: an unset lookback config silently sizes purchases from a 30-day window the operator never chose, with no signal that the configured value was ignored.
    • Fix: return an error, matching GetSPCoverageSummary.
  • MemoryDB recommendations hardcode Engine: "redis" (LOW)

    • Where: providers/aws/recommendations/parser_services.go:255-258
    • What: MemoryDB now supports Valkey as well as Redis, and the parser stamps redis unconditionally. The MemoryDB purchase path does not use Engine today (memorydb/client.go:313-319 filters only on NodeType, OfferingType and Duration), so the impact is confined to display, pool keying and duplicate detection.
    • Failure scenario: a Valkey MemoryDB cluster's recommendation is labelled redis in the UI and in any engine-keyed comparison, so two clusters on different engines collapse into one pool key.
    • Fix: read the engine from MemoryDBInstanceDetails if CE exposes it; otherwise leave it empty rather than asserting a wrong value.

Verified duplicates: checked against the open backlog and deliberately not re-reported

Each of these was independently confirmed against the cited issue's body, not just its title.

  • Silent 1yr, tenancy and scope defaults (ec2/client.go:409-416, savingsplans/client.go:655-682: calculateHoursInTerm, normalizeTermString, calculatePaymentBreakdown's default branch) -> LeanerCloud/cloud-commitments-cli#1319 (EC2 getDurationValue plus tenancy), LeanerCloud/cloud-commitments-cli#1320 (the two SP helpers, quoted verbatim in that issue), fix(providers/aws): getDurationString/getDurationValue silently fallback to 1yr on unrecognized term cloud-commitments-cli#1266, LeanerCloud/cloud-commitments-cli#1383, fix(providers/aws): fail loud on unrecognized RI term in EC2 getDurationValue cloud-commitments-cli#1260, and the umbrella LeanerCloud/cloud-commitments-cli#1194.
  • RateLimiter.Wait skips ctx.Err() on the first attempt (ratelimiter.go:69-72) -> LeanerCloud/cloud-commitments-cli#1303, which quotes the exact retryCount == 0 early return.
  • parseFloat silently returns 0 for RI utilization hours (utilization.go:180-195) -> LeanerCloud/cloud-commitments-cli#1308, which names PurchasedHours / TotalActualHours / UnusedHours explicitly.
  • computeEC2CoveragePct blending non-EC2 pool keys (ladder/layer_states.go:295-329) -> LeanerCloud/cloud-commitments-cli#1482.
  • Partial per-service failure returns success with that service's recs dropped (recommendations/client.go:453-476) -> LeanerCloud/cloud-commitments-cli#1052, finding 08-H4.
  • USD-only SP, ElastiCache and MemoryDB currency assumptions (savingsplans/client.go:303,635) -> LeanerCloud/cloud-commitments-cli#1087, which cites the same USD hardcodes plus memorydb/client.go:425 and elasticache/client.go:394.
  • Untyped all-services-failed error -> LeanerCloud/cloud-commitments-cli#1316. Duplicated RI-term parsers and CE pagination helpers -> LeanerCloud/cloud-commitments-cli#1392, LeanerCloud/cloud-commitments-cli#1393. Copy-pasted purchase skeleton across the RI service clients -> LeanerCloud/cloud-commitments-cli#1191.
  • This module is not covered by CI -> ci: go vet/lint steps don't use the per-module loop convention [cli part] cloud-commitments-cli#1478.

Findings from the 2026-09-02 codebase audit

Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.

A07-033 (medium)

For the verification bar on these two bullets: the current suite cannot fail on either. Every CacheDetails literal in providers/aws/services/elasticache/client_test.go uses the already-lowercase "redis" (:251, :288, :341, :446, :560, :616, :647, :680, :714, :737, :762), with no title-cased and no empty-engine case, so the raw Cost Explorer ProductDescription reaching the offering filter is never exercised. On the Savings Plans side, the only test with a non-empty OfferingID is TestEC2InstanceSP_CEProvidedOfferingIDUsedDirectly (savingsplans/client_test.go:1512-1537), and it asserts only that the offerings API is skipped -- nothing asserts that a plan-type, term or payment-option mismatch is still rejected on that path; the plan-type rejection test at :364-385 uses a rec with no OfferingID, so it never reaches the short-circuit. Both fixes need a case that fails against current code first. (audit finding A07-033)

A09-009 (medium)

A second silent path in the same file as the utilization join-key finding. AnalyzeReshapingWithRecs (pkg/exchange/reshape.go:550-555) collapses err != nil and len(offerings) == 0 into one branch that returns recs, and err is never logged or wrapped anywhere in the function. A database outage or a permission regression in the PurchaseRecLookup closure therefore renders the reshape page with zero cross-family alternatives on every request, indistinguishable from 'AWS has not recommended anything for this region'. Splitting the branches and emitting a warning on the error arm is enough to make a persistent failure visible. Audit finding A09-009.

No activity

Activity on this issue will appear here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions