Skip to content

feat(azure): org-wide multi-subscription recommendation collection (closes #553) - #1520

Merged
cristim merged 11 commits into
mainfrom
feat/553-azure-multi-subscription
Aug 3, 2026
Merged

cristim merged 11 commits into
mainfrom
feat/553-azure-multi-subscription

Conversation

@cristim

@cristim cristim commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Cache the ARM subscriptions list on AzureProvider (getOrFetchAccounts, single-flight under accountsMu sync.RWMutex) so GetAccounts, GetServiceClient, and GetRecommendationsClient don't each re-issue the subscriptions.List API call within one logical run
  • Add MultiSubscriptionRecommendationsClient, which fans recommendation collection out across every subscription visible to the authenticated principal (errgroup, per-subscription error isolation, all-attempted-failed guard, typed partial-failure error)
  • GetRecommendationsClient returns the fan-out client when no subscription is pinned and 2+ subscriptions are discovered. Pinned-subscription and single-discovered-subscription paths are unchanged.

⚠️ This PR is a behavioral no-op in production today

Nothing in this 1787-line diff changes what CUDly does when deployed as it is currently wired. The fan-out is scaffolding for a follow-up that connects it; it is not a shipped feature. All three integration surfaces it adds are unreachable:

  1. MultiSubscriptionRecommendationsClient itself. It is only returned by GetRecommendationsClient on the unpinned path, and every Azure GetRecommendationsClient call site reaches it through a pinned provider:

    • internal/scheduler/scheduler.go:685 (collectAzureAmbient) — the subscription comes from AZURE_SUBSCRIPTION_ID, and the caller at :752 returns early when that is empty, so the provider is always pinned.
    • internal/scheduler/scheduler.go:799 (collectAzureForAccount) — guarded by the explicit acct.AzureSubscriptionID == "" fail-fast at :782.
    • internal/purchase/execution.go:476 builds an Azure provider from account.AzureSubscriptionID with no empty-guard, but that provider is used for purchase execution only — it never reaches GetRecommendationsClient.

    pkg/provider/registry.go:82-93 stores provider factories, not instances, so there is no shared unpinned provider a caller could pick up either.

  2. The Azure partial-failure handling in cmd/multi_service_helpers.go (:103-111 and :449-459). Dead: recClient in that file is always the AWS adapter (cmd/multi_service.go:122), and discoverRegionsForService is reachable only via an AWS DescribeRegions failure. These branches add a cmd → providers/azure import for code no input can reach, and carry no cmd-level tests.

  3. tolerateIncompleteSweep (internal/scheduler/scheduler.go:906-925). A no-op at all five of its call sites: every Azure path pins a subscription, and GCP/AWS never return *azure.PartialSubscriptionFailureError.

What would wire it up: a caller that constructs AzureProvider without AzureSubscriptionID/Profile — e.g. an org-wide collection run for a service principal holding Reader across many subscriptions, rather than today's per-registered-account loop. That change is deliberately out of scope here: it is a collection-model decision with its own persistence consequences (tracked in #1652), not a mechanical follow-on.

Reading the test suite alone would reasonably suggest the feature is live. It is not. This section exists so a future maintainer does not have to rediscover that.

Why fan-out instead of a single org-wide API call

AWS Cost Explorer's AccountScope=Linked covers an entire organization in one API call. Azure has no equivalent — the Consumption Reservation Recommendations and Advisor APIs are subscription-scoped — so achieving org-wide coverage requires calling the per-subscription RecommendationsClientAdapter once per subscription and aggregating client-side. This mirrors the design proposed in #561 (closed as stale; reimplemented fresh against current main here since that branch's diff no longer applies cleanly).

What changed (per file)

providers/azure/provider.go

  • New accountsMu sync.RWMutex / cachedAccounts / accountsGen / accountsSF fields
  • getOrFetchAccounts (single-flight cache, returns a defensive copy via cloneAccounts so a caller mutating the returned slice can't corrupt the cache) and fetchAccounts (the actual ARM call, extracted from the previous GetAccounts body)
  • GetAccounts delegates to getOrFetchAccounts — identical external behavior, now cache-backed
  • New exported InvalidateAccountsCache() for tests/long-lived callers that need a forced refresh
  • GetRecommendationsClient: pinned subscription unchanged; unpinned path resolves accounts via the cache, validates an explicitly configured AZURE_SUBSCRIPTION_ID against the discovered list before any default resolution, and returns MultiSubscriptionRecommendationsClient only when the scope is genuinely ambiguous
  • SetCredential / SetSubscriptionsClient publish the swapped field under accountsMu and invalidate in the same critical section; fetchAccounts snapshots both fields under RLock

providers/azure/recommendations_multi_subscription.go (new)

  • MultiSubscriptionRecommendationsClient implementing provider.RecommendationsClient
  • NewMultiSubscriptionRecommendationsClient rejects an empty account list and fails loud if building any per-subscription client errors
  • GetRecommendations fans out via errgroup; each subscription's error is isolated (logged, doesn't cancel siblings); explicit post-Wait ctx.Err() check propagates parent-context cancellation; if every subscription fails, returns a wrapped error instead of a silently empty result
  • PartialSubscriptionFailureError returns the successful subscriptions' data alongside a typed error naming what was missed, so an incomplete sweep is distinguishable from a complete one that found nothing
  • selectSubscriptions honours AccountFilter, and reports filter entries that match no accessible subscription (ErrSubscriptionNotAccessible) rather than silently narrowing the sweep
  • The semaphore bounding aggregate concurrent ARM calls is acquired inside each per-subscription client's own GetRecommendations; adding a bound at this layer for callers that supply no semaphore is tracked in fix(azure): bound concurrency in the multi-subscription recommendations fan-out cloud-commitments-go#58

Tests — providers/azure/provider_test.go, providers/azure/recommendations_multi_subscription_test.go

  • Cache behavior (hit, independent copies, invalidation, concurrent cold-cache single-flight, no data race under concurrent credential/client swap)
  • Client selection (multi-sub fan-out, single discovered subscription, pinned subscription never triggers discovery, discovery failure propagates, invisible AZURE_SUBSCRIPTION_ID errors)
  • Fan-out merge (all-succeed, partial-failure distinguishable from empty-but-complete, all-fail, nil params, context cancellation, service-filter passthrough, account-filter scoping / partial match / dedup)

Known follow-ups (filed, not fixed here)

Test plan

  • go build ./...
  • go vet ./providers/azure/... ./internal/scheduler/... ./cmd/
  • go test -race -count=1 ./providers/azure/...
  • go test -race -count=1 ./internal/scheduler/... ./cmd/...
  • golangci-lint run --timeout=10m at the CI-pinned v2.10.1 — 0 issues
  • gocyclo -over 10 on providers/azure internal/scheduler cmd — clean
  • gofmt -l / goimports -l clean on touched files
  • Integration: create a service principal with Reader on 2+ subscriptions, set ambient credentials, call GetRecommendationsClient without pinning a subscription — verify recommendations from both subscriptions are returned. Not run: no such path exists in the deployed product yet (see the no-op section above), so this is exercised by unit tests only.

Closes #553
Refs #473, #561, #1652, LeanerCloud/cloud-commitments-go#58, LeanerCloud/cloud-commitments-go#59

Summary by CodeRabbit

  • New Features
    • Added multi-subscription Azure recommendations with filtering and concurrent result aggregation.
    • Added subscription discovery caching with automatic invalidation and a manual refresh capability.
    • Added detailed partial-failure reporting for incomplete recommendation sweeps.
  • Bug Fixes
    • Successful recommendations are retained when only some subscriptions fail.
    • Added validation for inaccessible or invalid subscription selections.
    • Azure accounts missing a subscription ID now fail fast with a clear configuration error.

@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/all-users Affects every user effort/l Weeks type/feat New capability triaged Item has been triaged labels Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Azure subscription discovery now uses a synchronized cache with invalidation and single-flight fetching. Recommendation client resolution supports pinned, single-subscription, and multi-subscription modes. Multi-subscription results are fetched concurrently and returned with typed partial-failure errors. Scheduler and command flows retain successful partial results.

Changes

Azure subscription-aware recommendations

Layer / File(s) Summary
Cached subscription discovery
providers/azure/provider.go, providers/azure/accounts_cache.go, providers/azure/provider_test.go
GetAccounts caches and clones subscriptions, resolves defaults, invalidates stale generations, synchronizes credential and client access, and tests concurrent refresh behavior.
Multi-subscription recommendation aggregation
providers/azure/recommendations_multi_subscription.go, providers/azure/recommendations_multi_subscription_test.go
Per-subscription clients are built and queried concurrently. Results are merged with filtering, cancellation handling, typed partial failures, and total-failure behavior.
Provider client selection
providers/azure/provider.go, providers/azure/provider_test.go
GetRecommendationsClient selects pinned, single-subscription, or multi-subscription behavior from configuration and discovered subscriptions.
Partial sweep integration and validation
internal/scheduler/*, cmd/multi_service_helpers.go
Partial subscription failures retain successful recommendations and emit logs or warnings. Azure accounts without subscription IDs fail before provider creation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant AzureProvider
  participant SubscriptionAPI
  participant MultiSubscriptionRecommendationsClient
  participant RecommendationClients
  Caller->>AzureProvider: GetRecommendationsClient
  AzureProvider->>SubscriptionAPI: Discover accessible subscriptions
  SubscriptionAPI-->>AzureProvider: Subscription accounts
  AzureProvider-->>Caller: Single- or multi-subscription client
  Caller->>MultiSubscriptionRecommendationsClient: GetRecommendations
  MultiSubscriptionRecommendationsClient->>RecommendationClients: Query subscriptions concurrently
  RecommendationClients-->>MultiSubscriptionRecommendationsClient: Recommendations and errors
  MultiSubscriptionRecommendationsClient-->>Caller: Merged recommendations and partial-failure status
Loading

Possibly related issues

  • LeanerCloud/CUDly#1654: Related to duplicate-subscription fan-out behavior in MultiSubscriptionRecommendationsClient.
  • LeanerCloud/CUDly#1653: Related to concurrency behavior in MultiSubscriptionRecommendationsClient.GetRecommendations; this change does not add a concurrency bound.

Possibly related PRs

  • LeanerCloud/CUDly#269: Modifies scheduler recommendation collection and preserves successful results from partial failures.
  • LeanerCloud/CUDly#591: Modifies Azure subscription discovery, default selection, and recommendation client construction.
  • LeanerCloud/CUDly#259: Modifies Azure recommendation retrieval used by the per-subscription clients.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation covers most #553 requirements, but fan-out is not reachable in deployed wiring, leaving operational support and integration acceptance incomplete. Wire multi-subscription fan-out into the active recommendation flow and add integration coverage for discovery and operations.
Docstring Coverage ⚠️ Warning Docstring coverage is 62.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: org-wide Azure recommendation collection across multiple subscriptions.
Out of Scope Changes check ✅ Passed The scheduler and CLI changes directly support partial-failure handling and subscription scoping described in the PR objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/553-azure-multi-subscription

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
providers/azure/provider.go (1)

280-373: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid holding accountsMu during the ARM list call
fetchAccountsLocked calls pager.NextPage(ctx) under the write lock, so any slow or hung subscription lookup blocks every concurrent caller that reaches account discovery. Add an internal timeout here, or move the fetch outside the mutex and only lock around cache population.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider.go` around lines 280 - 373, Update
getOrFetchAccounts and fetchAccountsLocked so the ARM subscription listing does
not run while accountsMu is held. Fetch accounts outside the mutex, then
reacquire the lock to re-check cachedAccounts and populate it only if still
empty, returning the existing cached result otherwise; preserve cloneAccounts
behavior and propagate fetch errors unchanged.
🧹 Nitpick comments (1)
providers/azure/provider.go (1)

99-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the subscription-cache logic into its own file.

getOrFetchAccounts, cloneAccounts, InvalidateAccountsCache, and fetchAccountsLocked form a cohesive, self-contained unit (~100 lines) added to an already large file (content in this diff runs past line 635). As per coding guidelines, Go/TS files should be kept under 500 lines; extracting this caching subsystem into a sibling file (e.g. providers/azure/accounts_cache.go) would restore compliance with low mechanical risk and improve cohesion.

Also applies to: 270-373

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider.go` around lines 99 - 109, The subscription-cache
subsystem should be moved out of the large provider file into a sibling Go file.
Extract getOrFetchAccounts, cloneAccounts, InvalidateAccountsCache,
fetchAccountsLocked, and the accountsMu/cachedAccounts state together,
preserving their behavior and existing access from GetAccounts,
GetServiceClient, and GetRecommendationsClient.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@providers/azure/provider.go`:
- Around line 280-373: Update getOrFetchAccounts and fetchAccountsLocked so the
ARM subscription listing does not run while accountsMu is held. Fetch accounts
outside the mutex, then reacquire the lock to re-check cachedAccounts and
populate it only if still empty, returning the existing cached result otherwise;
preserve cloneAccounts behavior and propagate fetch errors unchanged.

---

Nitpick comments:
In `@providers/azure/provider.go`:
- Around line 99-109: The subscription-cache subsystem should be moved out of
the large provider file into a sibling Go file. Extract getOrFetchAccounts,
cloneAccounts, InvalidateAccountsCache, fetchAccountsLocked, and the
accountsMu/cachedAccounts state together, preserving their behavior and existing
access from GetAccounts, GetServiceClient, and GetRecommendationsClient.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9598f7d5-1fb8-4c30-8e08-d7e1d1739c53

📥 Commits

Reviewing files that changed from the base of the PR and between b90b420 and 6a8155f.

📒 Files selected for processing (4)
  • providers/azure/provider.go
  • providers/azure/provider_test.go
  • providers/azure/recommendations_multi_subscription.go
  • providers/azure/recommendations_multi_subscription_test.go

@cristim
cristim force-pushed the feat/553-azure-multi-subscription branch from 6a8155f to bdd3260 Compare July 27, 2026 15:24
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

Force-pushed a fix for the concurrency finding on getOrFetchAccounts: the previous implementation held accountsMu.Lock() across the ARM subscriptions.List network round-trip via fetchAccountsLocked. Replaced with golang.org/x/sync/singleflight so concurrent cold-cache callers coalesce into a single in-flight fetch, with accountsMu held only briefly to read/populate the cache -- never across the network call. Added a deterministic regression test (channel-gated staged launch, not time-based) asserting exactly one ARM call under 10 concurrent cold-cache callers, verified under go test -race.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I’ll perform a full re-review of the updated PR, including the revised account-cache concurrency behavior and its regression coverage.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 35 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
providers/azure/provider_test.go (1)

1398-1407: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The runtime.Gosched() nudge doesn't guarantee the followers are on the cold-cache path.

Nothing blocks until the followers actually reach getOrFetchAccounts, so close(gated.release) can fire while some goroutines haven't started. Those late callers then hit an already-warm cache, and a naive per-caller-fetch implementation would still count 1 — the regression this test exists to catch can slip through. Gating each follower on an explicit "entered" signal (e.g. a second counter/channel incremented before GetAccounts, plus a barrier) makes it deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider_test.go` around lines 1398 - 1407, Replace the
runtime.Gosched-based coordination in the concurrent GetAccounts test with an
explicit follower-entry barrier: have each follower signal an entered
counter/channel immediately before invoking GetAccounts, wait until all
followers have entered the cold-cache path, then close gated.release. Keep the
existing worker synchronization and assertions unchanged so late goroutines
cannot observe a warmed cache.
providers/azure/accounts_cache.go (1)

43-68: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Detach the shared cache fill from the caller’s context. getOrFetchAccounts passes the singleflight leader’s ctx into fetchAccounts, so one canceled request can abort the shared ARM list call for every waiter and leave the cache cold. Use context.WithoutCancel(ctx) here, or retry once on context.Canceled. providers/azure/accounts_cache.go:43-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/accounts_cache.go` around lines 43 - 68, Update the shared
fetch in getOrFetchAccounts so fetchAccounts receives a context detached from
caller cancellation, using context.WithoutCancel(ctx). Keep the singleflight
cache population and error propagation unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers/azure/provider.go`:
- Around line 457-470: Update the recommendation client resolution around
p.subscriptionID and getOrFetchAccounts to honor the subscription ID resolved by
the provider’s default-subscription logic, including AZURE_SUBSCRIPTION_ID,
before falling back to all visible accounts. Reuse the existing
resolveSubscriptionIDFromCtx or equivalent resolved-default path so
recommendations remain scoped consistently with GetServiceClient and GetRegions,
while preserving explicit p.subscriptionID handling and multi-account fan-out
only when no default is resolved.

In `@providers/azure/recommendations_multi_subscription_test.go`:
- Around line 43-47: Update the doc comment for withFakeSubscriptionClients to
state that it provides fake clients by subscriptionID from the supplied map,
rather than describing ordered allocation by account order. Keep the
cleanup/restoration behavior documentation accurate.

---

Nitpick comments:
In `@providers/azure/accounts_cache.go`:
- Around line 43-68: Update the shared fetch in getOrFetchAccounts so
fetchAccounts receives a context detached from caller cancellation, using
context.WithoutCancel(ctx). Keep the singleflight cache population and error
propagation unchanged.

In `@providers/azure/provider_test.go`:
- Around line 1398-1407: Replace the runtime.Gosched-based coordination in the
concurrent GetAccounts test with an explicit follower-entry barrier: have each
follower signal an entered counter/channel immediately before invoking
GetAccounts, wait until all followers have entered the cold-cache path, then
close gated.release. Keep the existing worker synchronization and assertions
unchanged so late goroutines cannot observe a warmed cache.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94f3473b-66c2-4c25-83fe-3c5fefbdfe43

📥 Commits

Reviewing files that changed from the base of the PR and between b90b420 and bdd3260.

📒 Files selected for processing (5)
  • providers/azure/accounts_cache.go
  • providers/azure/provider.go
  • providers/azure/provider_test.go
  • providers/azure/recommendations_multi_subscription.go
  • providers/azure/recommendations_multi_subscription_test.go

Comment thread providers/azure/provider.go
Comment thread providers/azure/recommendations_multi_subscription_test.go
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Adversarial review pass: 6 confirmed findings, all fixed

Reviewed with a focus on cross-subscription data bleed, since this PR is the first Azure path that can return more than one subscription's data from a single client. Six confirmed issues, each with a regression test verified to fail with its fix reverted and pass with it in place. Commits: 20d3e53 (azure), 671ced2 (scheduler).

1. AZURE_SUBSCRIPTION_ID no longer scoped the recommendations client (scope widening)

GetRecommendationsClient previously resolved an unpinned provider through resolveSubscriptionIDFromCtx, which honours AZURE_SUBSCRIPTION_ID and hard-errors when the scope is ambiguous. The fan-out path replaced that with "2+ visible subscriptions means fan out", ignoring IsDefault entirely. A service principal that can see 20 subscriptions but pinned one via the environment silently began receiving all 20.

Restored the precedence: a resolvable default still returns a single-subscription client; only a genuinely unnamed scope fans out. Test: TestAzureProvider_GetRecommendationsClient_MultiSubscriptionFanOut/AZURE_SUBSCRIPTION_ID_still_scopes_to_one_subscription.

2. A configured AZURE_SUBSCRIPTION_ID naming an invisible subscription silently fanned out

Same class, second door: when the configured target isn't in the visible list, resolveDefaultSubscription falls through and no default resolves, so the caller got org-wide coverage in response to a request that named one subscription. That is a misconfiguration and now errors, as it did before fan-out existed. Test: .../AZURE_SUBSCRIPTION_ID_naming_an_invisible_subscription_errors.

3. MultiSubscriptionRecommendationsClient ignored params.AccountFilter

AccountFilter is an honoured contract on the AWS side (filterByAccounts, providers/aws/service_client.go:90-91). It was moot while an Azure client could only cover its own subscription, but a client covering every visible subscription has to honour it or a request scoped to one account returns others'. Now filtered before the fan-out, so out-of-scope subscriptions are never queried; a filter matching nothing errors rather than returning an empty slice (indistinguishable from "no savings available"). Tests: ..._AccountFilterScopesFanOut, ..._AccountFilterMatchesNothing, ..._EmptyAccountFilterFansOutToAll.

4. Scheduler could file other subscriptions' recommendations under one account

collectAzureForAccount builds the provider from acct.AzureSubscriptionID and then tags every returned recommendation with that account's UUID. validateCloudAccountRequest does not require the field, so an azure row created without one yields an unpinned provider. Pre-PR that errored; post-PR it collects org-wide and files the lot under this one account, where anyone authorised for it can read it. Now rejected by name. Test: TestScheduler_CollectAzureForAccount_MissingSubscriptionIDFailsLoud.

5. InvalidateAccountsCache was a no-op against an in-flight fetch

Interleaving: fetch starts (holding snapshot R1) -> InvalidateAccountsCache() nils an already-nil cache -> the fetch publishes R1. The next read is served from cache and handed back exactly the data the caller asked to discard, with no second ARM call. A caller arriving in that window also joined the pre-invalidation call through the constant singleflight key. Closed with an accountsGen counter that both keys the singleflight call and gates the cache write. Test: TestAzureProvider_InvalidateAccountsCache_DuringInFlightFetch.

Also in the same commit: singleflight handed the leader's error to every waiter, so a caller with a live context inherited a cancellation raised by whichever unrelated caller won the leader election; it now retries once as leader when its own context is healthy (own cancellation stays terminal, so it cannot spin). And the unchecked type assertion on singleflight's untyped result returns an error rather than panicking.

6. The context-cancellation test passed with the guard it named deleted

..._PropagatesContextCancellation used fakes that return ctx.Err(), so every subscription failed and the all-subscriptions-failed guard produced a context.Canceled error whether or not the post-Wait ctx.Err() check existed. Deleting recommendations_multi_subscription.go's check kept it green. The fakes now ignore the cancelled context, so only that check can produce the error (verified: deleting it fails the test).

Other test hardening in the same commit: the new cache tests read AZURE_SUBSCRIPTION_ID from the ambient environment and now neutralise it; the concurrent cold-cache test waits for every follower to enter GetAccounts instead of relying on a runtime.Gosched() nudge (previously a no-singleflight impl could still report one call); the constructor test now asserts the built client is actually stored.

Checked and dismissed

  • Semaphore placement — the doc comment's claim holds: concurrency.Acquire(gctx) is taken around the outbound calls inside each per-subscription adapter (recommendations.go:136), so the fan-out layer correctly adds none. Matches the project's acquire-at-the-API-call convention.
  • Partial-failure semantics — 3 of 50 subscriptions failing yields partial results with a warning log and a nil error. Left as is: this matches mergeServiceResults and the scheduler's fanOutPerAccount (all-failed is an error, partial is a warning). Changing it needs a RecommendationsClient interface change across all three providers, which is out of scope here.
  • Azure HTTP client hygiene — no bare &http.Client{} anywhere in providers/azure/; clean.
  • PII in logs — only subscription IDs are logged, no tokens or credentials.
  • Result attribution — the per-subscription adapter stamps Account: r.subscriptionID (recommendations.go:424), so merged rows stay attributable.
  • Race safety — results/errs are pre-sized with disjoint per-goroutine indices; gctx to children, parent ctx.Err() after Wait. go test -race -count=5 clean.

Verification

  • providers/azure: go build, go vet, go test -race -count=1 187 pass, gocyclo -over 10 -ignore "_test\.go" clean, gofmt -l clean
  • root: go build ./..., go test ./... -short 5944 pass / 38 packages, gocyclo -over 10 -ignore "_test\.go" . clean
  • golangci-lint pinned to CI's v2.10.1 at the repo root (where the CI action runs): 0 issues

One note on the PR description: it still describes getOrFetchAccounts as "double-checked locking under a new accountsMu sync.RWMutex", which the singleflight commit and this one have since superseded.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Thanks for the thorough adversarial pass. I’ll review the latest revision with particular focus on the restored scope precedence, AccountFilter isolation, scheduler attribution safety, generation-aware cache invalidation, and cancellation semantics. I’ll also check whether the PR description should be updated to describe the current singleflight-based design.

🐇🔍


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1520

Timestamp: 2026-07-27T17:44:18.407Z
Learning: For Azure multi-subscription recommendation collection, an unpinned `AzureProvider` must preserve explicit subscription scope: `AZURE_SUBSCRIPTION_ID` (or another resolved default subscription) selects a single-subscription client, an explicitly configured but inaccessible subscription is an error, and fan-out is allowed only when no subscription scope is configured or resolved.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1520

Timestamp: 2026-07-27T17:44:18.407Z
Learning: `providers/azure/MultiSubscriptionRecommendationsClient` must apply `common.RecommendationParams.AccountFilter` before fan-out so out-of-scope Azure subscriptions are never queried; an active filter that matches no accessible subscription must return an error rather than an empty successful result.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1520

Timestamp: 2026-07-27T17:44:18.407Z
Learning: Scheduler Azure collection must reject cloud-account records without `AzureSubscriptionID` before creating an Azure provider, because an unpinned provider can fan out across subscriptions and recommendations must never be stored under an unrelated cloud account UUID.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (7b18bfdef, #1529) to inherit the grpc v1.82.1 bump that clears GO-2026-6061. The two reds on the previous head (Security Scanning and the CI Success aggregate) were that repo-wide advisory, not this diff: the job log traced it through internal/oidc/gcp_signer.go:33, internal/credentials/cipher.go:71 and internal/secrets/gcp_resolver.go:145, all GCP/KMS paths with nothing Azure in them. Nothing was suppressed or allowlisted.

History was rewritten, so this is a force-push: 31b45cbd3 -> 3e07ed267. Five commits, no conflicts. Re-verified after the rebase: root build OK, providers/azure 190 tests pass under -race, internal/scheduler 107 pass, gocyclo -over 10 -ignore "_test\.go" clean at the root.

Since every commit SHA changed, the previous review no longer corresponds to anything on this branch.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I’ll perform a full review of the rebased branch, including the Azure multi-subscription behavior and the inherited dependency update.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 39 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Status update: head is 3e07ed267, all 19 checks are green (Security Scanning included, via the inherited grpc v1.82.1 bump) and merge state is CLEAN.

The two nitpicks from the 16:17Z review were both addressed in 7269018c7: the test now uses a sync.WaitGroup barrier where each follower signals immediately before GetAccounts and the test waits on it before releasing the gate, and getOrFetchAccounts retries the shared fill once when the singleflight leader's error is a context error inherited from an unrelated caller, while keeping the caller's own cancellation terminal.

The full review requested at 18:46Z was acknowledged but no review has landed since 16:17Z, so re-requesting. Please do a full pass, not an incremental one, since the branch was rebased after that review.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I’ll perform a full review of the rebased branch, including the cache-concurrency fixes and Azure cross-subscription isolation behavior.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
providers/azure/recommendations_multi_subscription.go (1)

189-202: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚖️ Poor tradeoff

Partial failures are only visible in logs.

A sweep where 9 of 10 subscriptions fail returns nil error, so downstream aggregation/reporting treats the result as complete org-wide coverage. Consider returning the covered/failed subscription counts (or a partial-error type like internal/purchase's multiAccountPartialError) so callers can annotate reports as incomplete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/recommendations_multi_subscription.go` around lines 189 -
202, Update the multi-subscription recommendation aggregation around the errs
loop and its failures check so any mix of successful and failed subscriptions
returns an explicit partial-failure signal, not a nil error. Include covered and
failed subscription counts, or reuse the existing multiAccountPartialError
pattern from internal/purchase, while preserving the all-failed error behavior
and collected successful results.
providers/azure/provider_test.go (1)

1614-1624: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Coverage gap: env target invisible with exactly one visible subscription.

This subtest pins the two-subscription case, where no default resolves and the fail-loud branch fires. The one-visible-subscription variant takes the opposite path (resolveDefaultSubscription marks the lone sub default, so Step 1 returns a client for a subscription the caller never named). Adding that case would pin the behavior either way — see the related comment on providers/azure/provider.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider_test.go` around lines 1614 - 1624, Add a subtest
alongside the existing invisible-subscription test that sets
AZURE_SUBSCRIPTION_ID to an unseen value while configuring exactly one visible
subscription, then call GetRecommendationsClient and assert the intended
behavior from resolveDefaultSubscription: it must not return a client for the
unnamed visible subscription, and should preserve the explicit-target error
contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers/azure/provider.go`:
- Around line 504-520: The subscription target validation occurs too late
because resolveDefaultSubscription can select the sole visible subscription
first. In providers/azure/provider.go lines 504-520, validate
AZURE_SUBSCRIPTION_ID against accounts before calling getDefaultSubscriptionID
and return the existing misconfiguration error when it is invisible; in
providers/azure/provider_test.go lines 1614-1624, add coverage for an invisible
target with exactly one visible subscription and assert the error outcome.

---

Nitpick comments:
In `@providers/azure/provider_test.go`:
- Around line 1614-1624: Add a subtest alongside the existing
invisible-subscription test that sets AZURE_SUBSCRIPTION_ID to an unseen value
while configuring exactly one visible subscription, then call
GetRecommendationsClient and assert the intended behavior from
resolveDefaultSubscription: it must not return a client for the unnamed visible
subscription, and should preserve the explicit-target error contract.

In `@providers/azure/recommendations_multi_subscription.go`:
- Around line 189-202: Update the multi-subscription recommendation aggregation
around the errs loop and its failures check so any mix of successful and failed
subscriptions returns an explicit partial-failure signal, not a nil error.
Include covered and failed subscription counts, or reuse the existing
multiAccountPartialError pattern from internal/purchase, while preserving the
all-failed error behavior and collected successful results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9bc56d4d-e406-434b-a35f-2a232a3bfe7a

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and 3e07ed2.

📒 Files selected for processing (7)
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_test.go
  • providers/azure/accounts_cache.go
  • providers/azure/provider.go
  • providers/azure/provider_test.go
  • providers/azure/recommendations_multi_subscription.go
  • providers/azure/recommendations_multi_subscription_test.go

Comment thread providers/azure/provider.go Outdated
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

CodeRabbit's Major finding: confirmed and fixed in 4b35eda99

Verified against the code rather than taken on trust, and it is real.

GetRecommendationsClient consulted getDefaultSubscriptionID(accounts) before validating the configured target. resolveDefaultSubscription's rule 3 marks a lone visible subscription as the default even when a target was configured and did not match it (the fall-through is documented in its own comment at accounts_cache.go:165-168). So with AZURE_SUBSCRIPTION_ID naming a subscription the credential cannot see and exactly one subscription visible, that one subscription came back as the default and the fail-loud check below it was unreachable.

Impact is exactly the silent-fallback class this PR exists around: an operator who typo'd the variable, or whose credential lost access to the intended subscription, silently collected against the wrong subscription instead of getting an error. My earlier test only covered the two-visible-subscription case, where rule 3 never fires, so it did not catch this.

Fix: validate the target against the discovered accounts first, via a direct membership check (accountsContain) rather than IsDefault — precisely because IsDefault may have been set on a different subscription by rule 3. A target that is visible is honoured directly; one that is not errors, regardless of how many subscriptions are visible. Resolution order is now:

  1. explicit AZURE_SUBSCRIPTION_ID — validated, then honoured or errored;
  2. no explicit target but a default resolves (notably the single-visible-subscription case);
  3. genuinely ambiguous — org-wide fan-out.

Tests: added the subtest CR asked for (invisible target, exactly one visible subscription) plus the happy path (visible target is honoured and scopes to that subscription). Verified the new subtest fails against the pre-fix ordering by reordering the two blocks and re-running:

Error: An error is expected but got nil.
Test:  ...MultiSubscriptionFanOut/AZURE_SUBSCRIPTION_ID_invisible_with_one_visible_subscription_errors
Messages: an invisible configured subscription must not silently resolve to the one visible subscription

and passes with the fix in place.

Gates on 4b35eda99: providers/azure 193 tests pass under -race, internal/scheduler 107 pass, root build OK, gocyclo -over 10 -ignore "_test\.go" clean in both modules, gofmt -l clean.

Good catch — this was a genuine gap in my own review pass, not a nitpick.

@cristim

cristim commented Jul 27, 2026 •

Copy link
Copy Markdown
Member Author

Response to the review on 3e07ed267

Actionable (1): fixed in 4b35eda99.

GetRecommendationsClient consulted getDefaultSubscriptionID before
validating the configured target. resolveDefaultSubscription's rule 3 marks a
lone visible subscription as the default even when a target was configured and
did not match it, so with AZURE_SUBSCRIPTION_ID naming an invisible
subscription and exactly one subscription visible, that one subscription won at
step 1 and the fail-loud check below it was unreachable. The caller silently
received another subscription's recommendations. The finding was correct, and
the function's own doc comment already documented the opposite contract, so
this was an ordering defect against documented intent.

The target is now validated against the discovered accounts first, via a direct
membership check rather than IsDefault, so it fails loud regardless of how
many subscriptions are visible. A target that is visible is honoured directly.

Nitpick, provider_test.go: fixed in the same commit. The previous coverage
only exercised the two-visible-subscription case, which takes the opposite
branch and stayed green while the bug was live. The new subtest pins the
single-visible-subscription branch, and a happy-path subtest pins a visible
target. Verified the regression test genuinely reproduces the bug rather than
merely passing after the fix: against the pre-fix provider.go and
accounts_cache.go it fails with An error is expected but got nil, and it
passes on the fixed code.

Nitpick, recommendations_multi_subscription.go:189-202 (partial failures
only visible in logs): valid, deferred to LeanerCloud/cloud-commitments-platform#87.

Agreed on the substance. mergeSubscriptionResults returns (out, nil) when a
strict subset of subscriptions fails, so a partial sweep is presented as
complete org-wide coverage. Not fixed here because the fix is not local to this
provider: RecommendationsClient is declared in pkg/provider/interface.go,
which is a separate Go module, and at least 7 call sites currently treat a
non-nil error as total failure (6 in internal/scheduler/scheduler.go, plus
cmd/multi_service_helpers.go:441), with the AWS and GCP clients implementing
the same interface.

Introducing the partial-failure error without migrating those callers would
convert today's under-reporting into discarded partial success, since callers
that bail on err != nil would throw away the subscriptions that did succeed.
That is strictly worse than the current behaviour, so it needs to land together
with its callers rather than as a follow-on commit here.

LeanerCloud/cloud-commitments-platform#87 captures the suggested shape (typed error carrying succeeded/failed
counts and per-subscription causes, discoverable via errors.As, with an
all-failed sweep staying a plain error), records the module-boundary constraint,
and notes that internal/purchase's multiAccountPartialError is a template
for the shape but is unexported and therefore not directly reusable.


Update. Work implementing this nitpick in-PR is now in progress, so the
deferral above may not be the final outcome. Treating both paths as open:

Either way the call-site constraint in the following comment applies before
merge: the partial-failure error has to reach internal/scheduler/scheduler.go
as well, or the scheduled collection path drops the subscriptions that did
succeed.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Pre-merge check for any partial-failure change to mergeSubscriptionResults

Flagging a call-site constraint that is easy to miss, since work in this area
is in flight. Purely informational, and it applies to whoever lands the change.

If mergeSubscriptionResults starts returning a non-nil error alongside
valid partial results (the PartialSubscriptionFailureError shape), then every
consumer of provider.RecommendationsClient has to be migrated in the same
change. Otherwise the scheduled collection path silently regresses from
under-reporting to discarding everything.

The code shape that breaks

internal/scheduler/scheduler.go:911-918:

recs, recErr = recClient.GetRecommendations(ctx, &params)
if recErr != nil {
    // Fail loud: a misconfigured DefaultPayment/DefaultTerm or a CE
    // failure on this fallback must surface to the operator instead
    // of silently presenting as "zero recommendations".
    return nil, fmt.Errorf("failed to get %s recommendations with default term/payment fallback (term=%s, payment=%s, lookback=%s): %w",
        providerName, params.Term, params.PaymentOption, params.LookbackPeriod, recErr)
}

recs is discarded entirely on any non-nil error. The moment a partial sweep
returns (partialResults, PartialSubscriptionFailureError), this path throws
away every healthy subscription's recommendations and fails the whole
collection. That is strictly worse than the current behaviour: today a partial
sweep at least records the subscriptions that answered.

Note the existing comment is a deliberate fail-loud, added so a genuine
misconfiguration cannot present as "zero recommendations". A partial-failure
error trips that same branch, which is exactly why it needs an errors.As
carve-out rather than being left to fall through.

All the affected call sites

Same recs, err := ...; if err != nil { return nil, ... } shape, all discarding
recs, in internal/scheduler/scheduler.go:

Line Path
689 Azure org-wide GetAllRecommendations
708 GCP org-wide GetAllRecommendations
801 Azure per-account GetAllRecommendations
869 GCP per-account GetAllRecommendations
896 generic per-provider GetAllRecommendations
911 generic default term/payment fallback GetRecommendations

Lines 689, 801, and the 896/911 generic path are the routes that actually reach
the Azure multi-subscription client, so they are not hypothetical.

cmd/multi_service_helpers.go:441 is already handled in the in-flight work via
asPartialSubscriptionFailure, but that call site alone is not sufficient
coverage
. The scheduler is the path that runs unattended, which makes it the
one where silently dropped data is least likely to be noticed.

Suggested verification before merge

  • Every call site above either inspects the error with errors.As and keeps the
    partial results, or is deliberately left failing closed with a stated reason.
  • A regression test on the scheduler path: a sweep where a strict subset of
    subscriptions fails still records the successful subscriptions'
    recommendations, rather than returning zero rows.

Also worth confirming the AWS and GCP recommendation clients, which implement
the same interface, are unaffected by the new error semantics.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Partial-failure signal added (e551a02a3)

Confirmed and fixed. mergeSubscriptionResults returned a nil error whenever at least one subscription succeeded, so 3 of 50 failing handed back 47 subscriptions' recommendations indistinguishably from a complete sweep. The only signal was a logging.Warnf line, which no programmatic caller reads.

Approach: the typed-error option, not the counts-on-result option, because the interface returns ([]common.Recommendation, error) and adding counts would mean changing provider.RecommendationsClient for all three clouds.

PartialSubscriptionFailureError is returned alongside the successful subscriptions' recommendations and carries Attempted, Succeeded, and every Failed subscription with its cause. Unwrap() []error exposes the per-subscription causes so errors.Is still matches through it (e.g. checking for a throttling error).

Failing the whole sweep on one transient error would be worse than a partial result, so the data is still returned. Callers opt in to keeping it via errors.As; a caller that treats any non-nil error as fatal now fails loud rather than silently under-reporting. Both beat the previous silent success.

Call sites. Only the CLI paths construct an unpinned Azure provider and can therefore reach the fan-out, so those two are updated to keep the partial data and report the gap (cmd/multi_service_helpers.go). The scheduler's Azure paths always pin a subscription — collectAzureAmbient via AZURE_SUBSCRIPTION_ID (guarded non-empty at scheduler.go:732-736) and collectAzureForAccount via the account's subscription (now guarded non-empty) — so they never build the fan-out client. I did not add errors.As handling there, because it would be unreachable code; if a future caller unpins the provider, the typed error is already the contract it will meet.

Test. Asserts the distinguishing property directly rather than the mechanism: a sweep where one subscription was never queried and a complete sweep that found nothing both produce an empty slice, and only the error tells them apart. Plus a second test that a partial sweep still returns the successful subscription's data. Verified failing against the old nil-error contract:

--- FAIL: TestMultiSubscriptionRecommendationsClient_PartialFailureKeepsSuccessfulResults
    Error: An error is expected but got nil.
    expected: *azure.PartialSubscriptionFailureError

The pre-existing PartialFailureStillSucceeds test asserted the old contract (require.NoError on a partial sweep) and is renamed to PartialFailureStillReturnsData, now asserting the results survive and the error is raised.

Note on the previous head

CI on 4b35eda99 came back all green, 19/19 including Security Scanning, confirming the rebase onto 7b18bfdef cleared the grpc advisory. The AZURE_SUBSCRIPTION_ID Major from CodeRabbit was already fixed in that same commit.

Gates on e551a02a3: root build OK; providers/azure 196 pass under -race, stable across -count=3 (588) and -shuffle=on; cmd/... + internal/scheduler 923 pass; gocyclo -over 10 -ignore "_test\.go" clean in both modules; gofmt -l clean.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Second-order regression from the partial-failure error, fixed (66bcbfa31)

The typed error made partial sweeps visible, but every scheduler fetch site has the shape recs, err := ...; if err != nil { return nil, ... }, which discards recs. So a partial sweep went from "silently under-collected" to "entire provider collection fails" — worse than the problem it fixed. The diff read as a strict improvement in isolation and only misbehaved at callers nobody re-read.

Fix: tolerateIncompleteSweep(providerName, err), applied at all six recommendation-fetch sites in internal/scheduler/scheduler.go, so the policy lives in one place rather than depending on each call site remembering it. A partial sweep logs a warning naming the subscriptions that were not queried and returns nil, so the collected data is kept and persisted; any other error is returned unchanged, preserving fail-loud.

Also exported AsPartialSubscriptionFailure from the azure provider so the scheduler and CLI share one definition of the check instead of each hand-rolling errors.As, and added FailedSubscriptionIDs() for the log lines. The CLI's local helper is removed in favour of the shared one.

Tests drive fetchAndConvert with a client returning recommendations plus a partial-failure error and assert the data survives; a companion test asserts a genuine error still fails loud (guarding against over-tolerance); plus direct coverage of the helper. Verified failing with the tolerance neutered:

--- FAIL: TestScheduler_FetchAndConvert_KeepsPartialSweepData
    Messages: a partial multi-subscription sweep must not fail the whole collection
--- FAIL: TestTolerateIncompleteSweep/partial_failure_is_swallowed_so_the_caller_keeps_its_data

One note on the fixture: the first version of this test passed for the wrong reason — convertRecommendations deliberately drops rows with an unparseable Term, so a common.Recommendation without Term/PaymentOption produced an empty result either way. The fixture now carries a canonical term and payment option, which is what makes the assertion meaningful.

Reachability, stated precisely

fetchAndConvert is called only with "aws" (three sites), and the two Azure scheduler paths pin a subscription — collectAzureAmbient via AZURE_SUBSCRIPTION_ID, collectAzureForAccount via the account's subscription — so neither builds the fan-out client today. The regression was therefore latent rather than live. It is still worth closing: this PR exists to enable org-wide Azure collection, and the natural next step is a scheduler path that stops pinning, at which point the discard would become real.

Deliberately not done

Recording the incompleteness in last_collection_error, so the dashboard shows a sweep as partial rather than merely smaller, needs a partial-note threaded through collectProviderRecommendations and its three per-provider implementations — a five-signature change. I left it as follow-up rather than bolting it on; the warning names the failed subscriptions in the meantime. Happy to file it as an issue.

Gates

CI on the previous head e551a02a3 was all green, 19/19. On 66bcbfa31: root build OK; providers/azure 196 pass under -race; cmd/... + internal/scheduler 929 pass; gocyclo -over 10 -ignore "_test\.go" clean in both modules; gofmt -l clean.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I’ll perform a full review of the current PR revision, including the Azure multi-subscription flow, partial-failure handling, cache concurrency, and scheduler propagation paths.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
providers/azure/recommendations_multi_subscription.go (2)

1-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate package doc comment. providers/azure/provider.go already carries the Package azure doc; a second one in this file means tooling picks an arbitrary winner. Demote it to a plain file comment (// Org-wide (multi-subscription) recommendations fan-out client.).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/recommendations_multi_subscription.go` around lines 1 - 3,
Replace the package doc comment in the multi-subscription recommendations file
with a plain file comment, leaving the existing Package azure documentation in
provider.go as the sole package-level doc.

79-84: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Error() panics on an empty Failed slice.

e.Failed[0].Err is unguarded. mergeSubscriptionResults never produces an empty Failed, but the type and its fields are exported, so a zero-value or externally constructed instance panics the moment it is formatted — typically inside a log/error-wrapping path.

🛡️ Guard the first-cause read
 func (e *PartialSubscriptionFailureError) Error() string {
+	if len(e.Failed) == 0 {
+		return fmt.Sprintf("azure recommendations incomplete: %d of %d subscriptions succeeded",
+			e.Succeeded, e.Attempted)
+	}
 	return fmt.Sprintf(
 		"azure recommendations incomplete: %d of %d subscriptions succeeded; %d failed (%s): %v",
 		e.Succeeded, e.Attempted, len(e.Failed),
 		strings.Join(e.FailedSubscriptionIDs(), ", "), e.Failed[0].Err)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/recommendations_multi_subscription.go` around lines 79 - 84,
Update PartialSubscriptionFailureError.Error to guard access to e.Failed[0].Err
when Failed is empty. Preserve the existing first-failure detail for non-empty
slices, while returning a safe error message for zero-value or externally
constructed instances without panicking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers/azure/provider.go`:
- Around line 169-197: Synchronize credential and subscriptions-client swaps
with the accounts cache mutex to eliminate races and mixed snapshots. Update
SetCredential and SetSubscriptionsClient to assign their fields while holding
accountsMu, and update fetchAccounts to snapshot cred and subscriptionsClient
under RLock before using them; retain cache invalidation after the synchronized
publication.

---

Nitpick comments:
In `@providers/azure/recommendations_multi_subscription.go`:
- Around line 1-3: Replace the package doc comment in the multi-subscription
recommendations file with a plain file comment, leaving the existing Package
azure documentation in provider.go as the sole package-level doc.
- Around line 79-84: Update PartialSubscriptionFailureError.Error to guard
access to e.Failed[0].Err when Failed is empty. Preserve the existing
first-failure detail for non-empty slices, while returning a safe error message
for zero-value or externally constructed instances without panicking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 70fdd710-c9da-42f4-9388-8eedd2dcbff2

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and 2398334.

📒 Files selected for processing (8)
  • cmd/multi_service_helpers.go
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_test.go
  • providers/azure/accounts_cache.go
  • providers/azure/provider.go
  • providers/azure/provider_test.go
  • providers/azure/recommendations_multi_subscription.go
  • providers/azure/recommendations_multi_subscription_test.go

Comment thread providers/azure/provider.go
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed 30d578114 addressing an independent adversarial review of this PR (verdict: NO BLOCKER, 5 findings, cross-subscription data bleed explicitly ruled out). Three findings fixed, one disclosed, three deferred as filed issues.

Fixed

1. A partially-satisfied AccountFilter silently narrowed the sweep (recommendations_multi_subscription.go). A filter matching no accessible subscription already errored loudly, but one matching some entries and missing others dropped the misses and returned a nil error. The scenario that makes this dangerous: an operator scopes a sweep to sub-A and sub-B, the principal has since lost Reader on sub-B so it is absent from the discovered list, and the sweep returns only sub-A's rows with err == nil — sub-B then reads as "no savings available", indistinguishable from a genuine empty result. That is precisely the confusion PartialSubscriptionFailureError exists to prevent, and selectSubscriptions' own doc comment said it must not happen.

selectSubscriptions now iterates the filter rather than the discovered subscriptions, so every requested entry is accounted for as matched or missed, and the misses are folded into the partial-failure error as ErrSubscriptionNotAccessible (a distinct sentinel, so "the principal cannot see this" is distinguishable from a transient ARM failure a retry might clear). They count towards both Attempted and Failed. Chose the partial-error route over failing outright because a stored filter covering many subscriptions must not become a total collection outage the moment one is deleted — the same trade-off this type already makes for per-subscription API failures. The filter is also deduplicated now, so a repeated entry is neither queried twice nor double-counted.

2. PartialSubscriptionFailureError.Error() panicked on an empty Failed. It indexed Failed[0] unguarded; the type and its fields are exported, so a zero-value or externally-constructed instance crashed when formatted with %v — an error type that panics when logged fails at exactly the moment the operator needs the message.

3. Unsynchronized cred / subscriptionsClient swaps — CodeRabbit's thread on provider.go. Applied, with detail and the verbatim pre-fix -race output on the thread itself. Short version: both setters now publish under accountsMu and invalidate in the same critical section, fetchAccounts snapshots both fields together under RLock, and the remaining in-package reads go through the same accessors so the guarantee is not half-true. The race was latent, not live (no concurrent production caller, and p.cred was already unlocked before this PR — not a regression), but the fix is correct and cheap.

Both behavioral fixes carry regression tests confirmed to fail on the pre-fix code, not merely to pass on the fixed code:

  • partial-filter test, pre-fix: An error is expected but got nil. expected: *azure.PartialSubscriptionFailureError
  • credential-swap test, pre-fix: WARNING: DATA RACE naming SetCredential's write against credential()'s read

Disclosed (not "fixed" — the code stays as scaffolding)

The PR body now states plainly that this diff is a behavioral no-op in production today, naming all three unreachable integration surfaces rather than just the first: the fan-out client itself, the Azure partial-handling in cmd/multi_service_helpers.go, and tolerateIncompleteSweep. Previously only the first was hinted at, and in terms ("activates when a caller constructs an AzureProvider without pinning") that read as reachable in some configuration rather than reachable nowhere.

I verified each claim against the code rather than taking the review's word for it — including the one place the reviewer's summary was imprecise: internal/purchase/execution.go:476 does build an Azure provider with no empty-subscription guard, but that provider never reaches GetRecommendationsClient, so it is not a live entry point. The two scheduler paths are guarded at scheduler.go:752 and scheduler.go:782.

Not deleting the code: it is scaffolding for the follow-up that wires it up. But it should not read as shipped either.

Deferred (filed, not fixed here)

Gates

All run locally, exit codes reported rather than inferred from empty output:

Gate Exit
go build ./... 0
go vet ./providers/azure/... ./internal/scheduler/... ./cmd/ 0
go test -race -count=1 ./providers/azure/... 0
go test -race -count=1 ./internal/scheduler/... ./cmd/... 0
gocyclo -over 10 providers/azure internal/scheduler cmd 0
golangci-lint run --timeout=10m at CI-pinned v2.10.1 0 (0 issues.)

The golangci run used the exact CI invocation (repo root, no path arguments) at the version ci.yml pins, not the newer v2.11.4 on this machine — passing explicit package paths produces a different, misleading finding set.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR diff stat ==='
git diff --stat origin/main...HEAD || git diff --stat HEAD~1..HEAD

echo '=== Changed files ==='
git diff --name-only origin/main...HEAD || git diff --name-only HEAD~1..HEAD

echo '=== Azure symbol outlines ==='
for f in \
  providers/azure/recommendations_multi_subscription.go \
  providers/azure/accounts_cache.go \
  providers/azure/provider.go \
  internal/scheduler/scheduler.go \
  cmd/multi_service_helpers.go \
  internal/purchase/execution.go
do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all 2>/dev/null || true
  fi
done

echo '=== Recommendation-client construction and calls ==='
rg -n -C 3 --glob '*.go' \
  '(NewAzureProvider|GetRecommendationsClient\s*\(|GetAllRecommendations\s*\(|GetRecommendations\s*\(|tolerateIncompleteSweep|AsPartialSubscriptionFailure|selectSubscriptions|ErrSubscriptionNotAccessible|SetCredential\s*\(|SetSubscriptionsClient\s*\()' \
  providers internal cmd pkg

Length of output: 173995


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Multi-subscription implementation ==='
sed -n '35,395p' providers/azure/recommendations_multi_subscription.go

echo '=== Account cache implementation ==='
sed -n '40,305p' providers/azure/accounts_cache.go

echo '=== Azure provider synchronization and client resolution ==='
sed -n '90,255p' providers/azure/provider.go
sed -n '525,615p' providers/azure/provider.go

echo '=== Scheduler partial-error policy and Azure construction ==='
sed -n '670,825p' internal/scheduler/scheduler.go
sed -n '890,965p' internal/scheduler/scheduler.go

echo '=== Regression tests for new filter/error behavior ==='
sed -n '135,455p' providers/azure/recommendations_multi_subscription_test.go

echo '=== Regression tests for concurrency / credential swaps ==='
sed -n '1360,1675p' providers/azure/provider_test.go

echo '=== Full diff, compact context ==='
git diff --unified=20 origin/main...HEAD -- \
  providers/azure/recommendations_multi_subscription.go \
  providers/azure/accounts_cache.go \
  providers/azure/provider.go \
  internal/scheduler/scheduler.go \
  cmd/multi_service_helpers.go \
  | sed -n '1,2200p'

Length of output: 149404


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== All AzureProvider mutable-field accesses ==='
rg -n -C 4 --glob '*.go' \
  'p\.(cred|credErr|credOnce|credProvider|subscriptionsClient|subscriptionID|cachedAccounts|accountsGen)\b' \
  providers/azure

echo '=== IsConfigured and initialization tests ==='
sed -n '250,360p' providers/azure/provider.go
sed -n '115,245p' providers/azure/provider_test.go
rg -n -C 5 --glob '*test.go' \
  '(IsConfigured|SetCredentialProvider|SetCredential).*([Cc]oncurrent|[Rr]ace)|([Cc]oncurrent|[Rr]ace).*(IsConfigured|SetCredentialProvider|SetCredential)' \
  providers/azure

echo '=== PR body and current head ==='
git rev-parse --short HEAD
gh pr view 1520 --repo LeanerCloud/CUDly --json title,body,headRefOid,mergeStateStatus,statusCheckRollup \
  --jq '{title, headRefOid, mergeStateStatus, checks: [.statusCheckRollup[] | {name, conclusion: .conclusion}], body}'

Length of output: 26961


@cristim Full pass complete. The partial-filter accounting, zero-value error handling, cache-generation invalidation, and normal credential/client swap synchronization all look correct.

One issue remains:

  • AzureProvider.IsConfigured can overwrite a concurrently injected credential. After Line 274 observes p.cred == nil, it may enter credOnce.Do. If another goroutine calls SetCredential while ambient credential creation is in progress, Line 290 unconditionally publishes the ambient credential and replaces the injected credential. If ambient creation instead fails, Line 292 can return false despite the injected credential now being available. This defeats the stated concurrent handoff guarantee and can make a provider use the ambient Azure principal rather than the intended per-account credential.

    Make ambient publication conditional on p.cred still being nil under accountsMu, and re-check the installed credential after credOnce.Do before returning. SetCredentialProvider should use the same synchronization (or otherwise be documented as initialization-only), since Line 280 reads it without a lock.

I did not identify additional blockers in the reported AccountFilter or cache fixes.

🐇🔍


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1520

Timestamp: 2026-07-28T19:30:09.221Z
Learning: For Azure multi-subscription recommendation collection, `RecommendationParams.AccountFilter` is a requested coverage set: when some requested subscription IDs are inaccessible while others are reachable, preserve the reachable results but return `PartialSubscriptionFailureError` with each inaccessible ID and `ErrSubscriptionNotAccessible`; never silently narrow the sweep.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
providers/azure/provider.go (1)

272-293: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

IsConfigured can overwrite a credential installed by a concurrent SetCredential.

Line 274 reads the credential, and line 290 publishes a lazily created one. The two steps are separate critical sections. If SetCredential lands between them, publishCredential replaces the injected credential with the ambient one. publishCredential also skips cache invalidation, so the accounts cache then belongs to a credential that is no longer installed.

SetCredential is a production path (scheduler and purchase execution install per-account federated credentials), so this is the same class of hazard the rest of this PR closed.

Make the publish conditional on the field still being nil under one lock.

🔒 Publish only when no credential was installed meanwhile
-// publishCredential installs cred under accountsMu without touching the
-// accounts cache.
+// publishCredentialIfUnset installs cred under accountsMu only when no
+// credential is installed, and reports whether it published.
 //
 // Used by IsConfigured's lazy ambient-credential resolution, which only runs
 // when no credential was installed at all. Unlike SetCredential it does not
 // invalidate: there is no previous credential whose subscription list could
-// have been cached under it.
-func (p *AzureProvider) publishCredential(cred azcore.TokenCredential) {
+// have been cached under it. The nil re-check under the write lock closes the
+// window in which a concurrent SetCredential would otherwise be overwritten.
+func (p *AzureProvider) publishCredentialIfUnset(cred azcore.TokenCredential) bool {
 	p.accountsMu.Lock()
 	defer p.accountsMu.Unlock()
+	if p.cred != nil {
+		return false
+	}
 	p.cred = cred
+	return true
 }
-		p.publishCredential(cred)
+		p.publishCredentialIfUnset(cred)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider.go` around lines 272 - 293, Update
AzureProvider.IsConfigured and the credential publication path so the lazily
created credential is assigned only while holding the same lock that guards the
credential field, and only if the field remains nil. Preserve any credential
installed concurrently by SetCredential, including its cache state, instead of
unconditionally calling publishCredential after credential creation.
♻️ Duplicate comments (1)
providers/azure/recommendations_multi_subscription_test.go (1)

56-59: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The doc comment still contradicts the implementation.

withFakeSubscriptionClients resolves each fake by subscriptionID from the fakes map and returns an error for an unregistered ID. It does not hand out fakes in account order. A previous review raised this and the thread is marked addressed, but the comment text is unchanged in this revision.

📝 Reword the doc comment
 // withFakeSubscriptionClients overrides newSubscriptionRecommendationsClientFn
-// to hand out the given fakes in order (one per NewMultiSubscriptionRecommendationsClient
-// account, in the order accounts are passed) and restores the original on
-// cleanup.
+// to return the fake registered for each requested subscription ID, erroring
+// on an unregistered ID, and restores the original factory on cleanup.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/recommendations_multi_subscription_test.go` around lines 56 -
59, Update the doc comment for withFakeSubscriptionClients to describe that it
resolves fake subscription clients by subscriptionID from the fakes map and
returns an error when an ID is unregistered, rather than handing them out in
account order.
🧹 Nitpick comments (5)
cmd/multi_service_helpers.go (1)

103-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Split these oversized Go files.

Each final file exceeds the 500-line limit. Split coherent helpers and test groups into necessary non-root files. Keep each resulting Go file below 500 lines.

  • cmd/multi_service_helpers.go#L103-L109: move region-discovery and recommendation-fetch helpers into focused command helper files.
  • internal/scheduler/scheduler.go#L894-L935: move recommendation collection or partial-sweep handling into focused scheduler files.
  • internal/scheduler/scheduler_test.go#L707-L804: split scheduler tests by collection domain.

As per coding guidelines, “keep files under 500 lines.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/multi_service_helpers.go` around lines 103 - 109, Split the oversized
files into focused non-root files while preserving behavior: in
cmd/multi_service_helpers.go, move region-discovery and recommendation-fetch
helpers; in internal/scheduler/scheduler.go, move recommendation collection or
partial-sweep handling; and in internal/scheduler/scheduler_test.go, split tests
by collection domain. Keep every resulting Go file below 500 lines and update
package-level references as needed.

Source: Coding guidelines

providers/azure/provider_test.go (2)

1483-1504: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

The swapper goroutines spin without a yield.

Both loops call SetCredential or SetSubscriptionsClient in a tight loop with no pause. Each call takes the write lock and bumps the generation, so the readers re-fetch on every round and the two swappers burn a core each until stop closes. Under -race the instrumentation multiplies that cost.

Add a runtime.Gosched() at the end of each loop body. The race window stays open, and the test stops saturating two cores.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider_test.go` around lines 1483 - 1504, Update both
swapper goroutines in the test to call runtime.Gosched() at the end of each loop
iteration after SetCredential or SetSubscriptionsClient, preserving the existing
stop checks while yielding execution and avoiding tight-loop CPU saturation.

1385-1386: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the inherited-cancellation retry.

getOrFetchAccounts in providers/azure/accounts_cache.go (Lines 54-68) retries once when the singleflight leader returns a context error and the caller's own context is still live. No test in this file drives that branch. The branch decides whether an unrelated caller's cancellation aborts a healthy caller, so a regression there would be silent.

A gated subscriptions client whose first call returns context.Canceled and whose second call returns the fixture list would cover it. I can generate the test if you want.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider_test.go` around lines 1385 - 1386, Add a test in
TestAzureProvider_GetAccounts_ConcurrentColdCache_SingleARMCall covering
getOrFetchAccounts’ inherited-cancellation retry: use a gated subscriptions
client whose first request returns context.Canceled and whose second returns the
fixture accounts, then verify a caller with a live context succeeds and the
fixture is returned after the retry.
providers/azure/provider.go (2)

92-126: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

provider.go now exceeds the 500-line file limit.

The coding guidelines require Go files under 500 lines. This file runs past 610 lines after the change. The cache subsystem was already split into providers/azure/accounts_cache.go. Consider moving the recommendation-client selection logic (Lines 507-612) into a separate file in the same package, for example providers/azure/recommendations_client_selection.go.

As per coding guidelines: "Follow Domain-Driven Design with bounded contexts, keep files under 500 lines, and use typed interfaces for public APIs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider.go` around lines 92 - 126, Reduce provider.go below
the 500-line limit by moving the recommendation-client selection logic from the
recommendation client methods into a new same-package file such as
recommendations_client_selection.go. Preserve the existing AzureProvider
behavior and method APIs, moving only the relevant selection implementation
while retaining access to the provider’s existing fields and helpers.

Source: Coding guidelines


564-591: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Env-variable resolution is duplicated between two files.

resolveDefaultSubscription in providers/azure/accounts_cache.go (Lines 247-273) already reads azureSubscriptionIDEnv and applies the priority rules. Step 1 here reads the same variable again and re-implements the "target must be visible" rule that resolveDefaultSubscription deliberately does not enforce. The two rule sets must stay consistent by hand.

Consider moving the visibility check into a single helper in accounts_cache.go, for example resolveScopedSubscription(accounts, explicitSubID) (string, error), and calling it from both sites. This is optional; the current behavior is correct and well covered by tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/provider.go` around lines 564 - 591, Centralize explicit
subscription resolution and visibility validation instead of duplicating it in
the provider flow. Add a helper near resolveDefaultSubscription in
accounts_cache.go, such as resolveScopedSubscription, that accepts accounts and
the explicit subscription ID, returns the validated ID or the existing error,
and reuse it from both resolveDefaultSubscription and the Step 1 logic around
getDefaultSubscriptionID in the provider. Preserve the current precedence and
fail-loud behavior for configured IDs that are not visible.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers/azure/accounts_cache.go`:
- Around line 257-273: Update resolveSubscriptionIDFromCtx to validate a
configured AZURE_SUBSCRIPTION_ID against visible subscriptions using the same
check as GetRecommendationsClient. If the target is not visible, return the
appropriate error instead of allowing resolveDefaultSubscription’s
single-subscription fallback; preserve fallback behavior only when no target is
configured.

In `@providers/azure/provider.go`:
- Around line 186-188: Synchronize the credProvider write in
SetCredentialProvider with accountsMu, matching the publication pattern used by
other swappable fields. Update IsConfigured to obtain the provider through the
existing credentialProvider() accessor inside credOnce.Do, preserving the
fallback to realCredentialProvider when no provider is configured.

---

Outside diff comments:
In `@providers/azure/provider.go`:
- Around line 272-293: Update AzureProvider.IsConfigured and the credential
publication path so the lazily created credential is assigned only while holding
the same lock that guards the credential field, and only if the field remains
nil. Preserve any credential installed concurrently by SetCredential, including
its cache state, instead of unconditionally calling publishCredential after
credential creation.

---

Duplicate comments:
In `@providers/azure/recommendations_multi_subscription_test.go`:
- Around line 56-59: Update the doc comment for withFakeSubscriptionClients to
describe that it resolves fake subscription clients by subscriptionID from the
fakes map and returns an error when an ID is unregistered, rather than handing
them out in account order.

---

Nitpick comments:
In `@cmd/multi_service_helpers.go`:
- Around line 103-109: Split the oversized files into focused non-root files
while preserving behavior: in cmd/multi_service_helpers.go, move
region-discovery and recommendation-fetch helpers; in
internal/scheduler/scheduler.go, move recommendation collection or partial-sweep
handling; and in internal/scheduler/scheduler_test.go, split tests by collection
domain. Keep every resulting Go file below 500 lines and update package-level
references as needed.

In `@providers/azure/provider_test.go`:
- Around line 1483-1504: Update both swapper goroutines in the test to call
runtime.Gosched() at the end of each loop iteration after SetCredential or
SetSubscriptionsClient, preserving the existing stop checks while yielding
execution and avoiding tight-loop CPU saturation.
- Around line 1385-1386: Add a test in
TestAzureProvider_GetAccounts_ConcurrentColdCache_SingleARMCall covering
getOrFetchAccounts’ inherited-cancellation retry: use a gated subscriptions
client whose first request returns context.Canceled and whose second returns the
fixture accounts, then verify a caller with a live context succeeds and the
fixture is returned after the retry.

In `@providers/azure/provider.go`:
- Around line 92-126: Reduce provider.go below the 500-line limit by moving the
recommendation-client selection logic from the recommendation client methods
into a new same-package file such as recommendations_client_selection.go.
Preserve the existing AzureProvider behavior and method APIs, moving only the
relevant selection implementation while retaining access to the provider’s
existing fields and helpers.
- Around line 564-591: Centralize explicit subscription resolution and
visibility validation instead of duplicating it in the provider flow. Add a
helper near resolveDefaultSubscription in accounts_cache.go, such as
resolveScopedSubscription, that accepts accounts and the explicit subscription
ID, returns the validated ID or the existing error, and reuse it from both
resolveDefaultSubscription and the Step 1 logic around getDefaultSubscriptionID
in the provider. Preserve the current precedence and fail-loud behavior for
configured IDs that are not visible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce53246c-c2dd-4654-b5d6-2771aa0a751f

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and 30d5781.

📒 Files selected for processing (8)
  • cmd/multi_service_helpers.go
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_test.go
  • providers/azure/accounts_cache.go
  • providers/azure/provider.go
  • providers/azure/provider_test.go
  • providers/azure/recommendations_multi_subscription.go
  • providers/azure/recommendations_multi_subscription_test.go

Comment thread providers/azure/accounts_cache.go
Comment thread providers/azure/provider.go
cristim added a commit that referenced this pull request Aug 3, 2026
Addresses both CodeRabbit findings from the review round on PR #1520.
Both harden invariants that the previous commit established on one path
and left off the others, which reads as a global guarantee but is not.

1. Silent subscription retarget (providers/azure/provider.go)

resolveSubscriptionIDFromCtx -- which backs GetRegions and
GetServiceClient -- called getDefaultSubscriptionID without first
validating an explicitly configured subscription against the discovered
list. resolveDefaultSubscription deliberately falls through to its
single-visible-subscription rule when the configured target is not
found, so with an unreachable AZURE_SUBSCRIPTION_ID (or ProviderConfig
subscription) and exactly one visible subscription, the request was
silently retargeted to whichever subscription happened to be visible.
GetRecommendationsClient already guarded this via accountsContain under
a comment reading "This ordering is load-bearing"; the same
misconfiguration therefore errored on the recommendations path and
silently retargeted on the regions/service path.

GetServiceClient drives the resource enumeration recommendations are
computed from, and is also the purchase-execution path's service-client
lookup, so the operator could believe they were scoped to subscription A
while data came from B.

The guard is extracted into validateConfiguredSubscription and reused by
GetRecommendationsClient, so the two paths cannot drift apart again. It
covers both configuration axes (ProviderConfig field and the env var),
mirroring resolveDefaultSubscription's own priority order. Production
Azure flows pin AzureSubscriptionID (scheduler, registration and account
handlers) and short-circuit before this path, so the guard engages only
on the unpinned/ambient path where the retarget lived.

GetRegions' error wrapper no longer claims "no Azure subscriptions
found": the new inner error fires precisely when subscriptions were
found but the configured one is not among them.

2. Unsynchronized credProvider (providers/azure/provider.go)

SetCredentialProvider wrote p.credProvider outside accountsMu while
IsConfigured read it inside credOnce.Do -- the one swappable field left
outside the locking discipline the rest of the provider follows. Latent
today (SetCredentialProvider has no production callers), but a half-kept
invariant reads as a kept one. Published and read under accountsMu now,
matching cred and subscriptionsClient. It deliberately does not
invalidate the accounts cache: credProvider only feeds the lazy
resolution that runs when no credential was installed at all, so no
subscription list resolved under a different credential can exist yet.

Regression tests, both confirmed failing against the pre-fix code:

- TestAzureProvider_ResolveSubscription_InvisibleConfiguredTargetErrors
  uses an invisible configured target with exactly ONE visible
  subscription -- the case the fall-through hides. With two or more
  visible subscriptions getDefaultSubscriptionID returns "" and the
  pre-existing "multiple Azure subscriptions" error fires either way, so
  such a test would pass with or without the guard. Covers GetRegions
  and GetServiceClient, both configuration axes, and the honoured
  visible-target case. Pre-fix: 3 of 4 subtests fail with "An error is
  expected but got nil".
- TestAzureProvider_ConcurrentCredentialProviderSwapAndIsConfigured_NoDataRace
  loops over fresh providers because credOnce makes the read one-shot
  per instance. Pre-fix: WARNING: DATA RACE, write at provider.go:187
  against read at provider.go:280.

TestAzureProvider_GetRegions and
TestAzureProvider_GetServiceClient_WithSubscriptionLookup now clear
AZURE_SUBSCRIPTION_ID via the existing helper: their fixtures resolve
through the newly-guarded path, so an exported value on a developer
machine would fail them for an unrelated reason.
@cristim
cristim force-pushed the feat/553-azure-multi-subscription branch from 30d5781 to 4bc85e5 Compare August 3, 2026 12:33
cristim added 11 commits August 3, 2026 15:04
Cache the ARM subscriptions list on AzureProvider (getOrFetchAccounts,
double-checked locking, InvalidateAccountsCache for tests) so repeated
GetAccounts/GetServiceClient/GetRecommendationsClient calls in one run
do not each re-issue the subscriptions.List API call.

Add MultiSubscriptionRecommendationsClient, which fans recommendation
collection out across every subscription visible to the authenticated
principal via errgroup, isolating per-subscription errors and only
failing when every subscription fails. GetRecommendationsClient now
returns this fan-out client when no subscription is pinned and 2+ are
discovered, bringing Azure to parity with the AWS provider's automatic
whole-organization recommendation coverage. Pinned-subscription and
single-subscription paths are unchanged.

Closes #553
… the ARM fetch

getOrFetchAccounts held accountsMu.Lock() for the entire subscriptions.List
network round-trip via fetchAccountsLocked, so every reader of the cached
subscription list blocked behind a slow or hung ARM call. Move the fetch
into golang.org/x/sync/singleflight so concurrent cold-cache callers
coalesce into one in-flight request, taking accountsMu only to read or
populate cachedAccounts.

Add a deterministic regression test that stages N goroutines through a
channel-gated fake fetch and asserts exactly one underlying ARM call.
Adversarial review of the org-wide fan-out. Scoping first.

GetRecommendationsClient ignored the resolved default subscription. Before
fan-out existed, an unpinned provider resolved the default via
resolveSubscriptionIDFromCtx, which honours AZURE_SUBSCRIPTION_ID and errors
when the scope is ambiguous. The fan-out path replaced that with "2+ visible
subscriptions means fan out", so a caller that pinned a subscription through
the environment silently started receiving every other visible subscription's
recommendations. Restore the precedence: a resolvable default still yields a
single-subscription client, a configured AZURE_SUBSCRIPTION_ID naming a
subscription this principal cannot see is an error, and only a genuinely
unnamed scope fans out.

MultiSubscriptionRecommendationsClient ignored params.AccountFilter. The AWS
provider applies that filter to every recommendation it returns
(filterByAccounts in providers/aws/service_client.go). It was moot while an
Azure client could only ever cover its own subscription, but a client
covering every visible subscription has to honour it or a request scoped to
one account comes back carrying others'. Filter before the fan-out so
subscriptions outside the filter are never queried, and error rather than
return an empty slice when the filter matches nothing.

Then the subscription-list cache.

InvalidateAccountsCache was a no-op against an in-flight fetch: a fetch that
started before the invalidation still published its pre-invalidation snapshot
afterwards, so the next read was served from cache and handed back exactly
the data the caller asked to discard, with no second ARM call. A caller
arriving in that window also joined the still in-flight pre-invalidation call
through the constant singleflight key. Both are closed by an accountsGen
counter that keys the singleflight call and gates the cache write.

singleflight hands the leader's error to every waiter, so a caller whose own
context was still live inherited a cancellation raised by whichever unrelated
caller won the leader election. Retry once as leader when our own context is
still healthy; our own cancellation stays terminal, so this cannot spin on a
dead context. The unchecked type assertion on singleflight's untyped result
now returns an error instead of panicking.

Test fixes: the context-cancellation test passed with the guard it named
deleted, because fakes that return ctx.Err() make every subscription fail and
the all-failed guard produces the same error either way; its fakes now ignore
the cancelled context so only the post-Wait ctx.Err() check can produce the
error. The new cache tests read AZURE_SUBSCRIPTION_ID from the ambient
environment and now neutralise it, the concurrent cold-cache test waits for
every follower to enter GetAccounts rather than relying on a runtime.Gosched
nudge, and the constructor test asserts the built client is actually stored.

Each behaviour has a regression test verified to fail with its fix reverted.

Refs #553
collectAzureForAccount tags every recommendation it returns with the
registered account's UUID, so the provider has to be pinned to that account's
subscription. It built the provider from acct.AzureSubscriptionID without
checking it was set, and validateCloudAccountRequest does not require the
field, so an azure row created without one produced an unpinned provider.

That was harmless while an unpinned provider resolved a single subscription
or errored. With the org-wide fan-out it instead collects from every
subscription the credential can see, and those results are then filed under
this one account, where anyone authorised for it can read them. Reject the
misconfigured row by name instead.
SetCredential is used in production, not only in tests: the scheduler and
purchase-execution paths construct the provider and then install per-account
federated credentials. The subscription-list cache added in this PR records
what the previous credential could see, so leaving it in place across a swap
would report subscriptions the new principal may have no access to, and
GetRecommendationsClient would fan out across them.

Every caller today installs the credential before the first accounts fetch,
so this is a guard against a future reordering rather than a live leak.
SetSubscriptionsClient gets the same treatment for the same reason.
CodeRabbit finding. GetRecommendationsClient consulted
getDefaultSubscriptionID before validating the configured target, and
resolveDefaultSubscription's rule 3 marks a lone visible subscription as the
default even when a target was configured and did not match it.

So with AZURE_SUBSCRIPTION_ID naming a subscription the credential cannot see
and exactly one subscription visible, that one subscription was returned as
the default and the fail-loud check further down was unreachable. An operator
who typo'd the variable, or whose credential lost access to the intended
subscription, silently collected against the wrong subscription instead of
getting an error: a misconfiguration answered with a plausible wrong result.

Validate the target against the discovered accounts first, via a direct
membership check rather than IsDefault, so it fails loud regardless of how
many subscriptions are visible. A target that IS visible is honoured directly.

The previous test only covered two visible subscriptions, where rule 3 never
fires; the new subtest pins the single-visible-subscription case and fails
against the old ordering.
mergeSubscriptionResults returned a nil error whenever at least one
subscription succeeded, so 3 of 50 subscriptions failing handed back 47
subscriptions' recommendations indistinguishably from a complete sweep. The
only signal was a log line, which no programmatic caller reads. On a path
whose output is persisted and rendered as a savings opportunity, an
under-collected sweep then reads as a shrinking opportunity rather than a
failed one.

Add PartialSubscriptionFailureError, returned ALONGSIDE the successful
subscriptions' recommendations, carrying the attempted/succeeded counts and
every failed subscription with its cause. Unwrap exposes the per-subscription
causes so errors.Is still matches through it.

Failing the whole sweep on one transient error would be worse than a partial
result, so the data is still returned; callers opt in to keeping it by
inspecting the error with errors.As, and a caller that treats any non-nil
error as fatal now fails loud instead of silently under-reporting. Both
outcomes beat the previous silent success.

Updated the two call sites that can reach the fan-out (the CLI paths, which
are the only ones that construct an unpinned Azure provider) to keep the
partial data and report the gap. The scheduler's Azure paths always pin a
subscription -- collectAzureAmbient via AZURE_SUBSCRIPTION_ID and
collectAzureForAccount via the account's subscription -- so they never build
the fan-out client and need no change.

Tests assert the distinguishing property directly: a sweep where a
subscription was never queried and a complete sweep that found nothing both
produce an empty slice, and only the error tells them apart. Verified failing
against the old nil-error contract.
Second-order effect of returning a typed partial-failure error. Every
recommendation-fetch site in the scheduler has the shape

	recs, err := recClient.GetAllRecommendations(ctx)
	if err != nil { return nil, ... }

which discards recs. Correct for a real error, but for a partial sweep it
turned one flaky subscription out of fifty into a total collection outage:
strictly worse than the silent under-collection the typed error exists to
prevent. The diff that introduced it looked like a pure improvement in
isolation and only misbehaved at callers that were not re-read.

Add tolerateIncompleteSweep and route every fetch site through it, so the
policy lives in one place instead of relying on each call site to remember
it. A partial sweep logs a warning naming the subscriptions that were not
queried and returns nil, so the data that was collected is kept and
persisted. Any other error is returned unchanged.

Also export AsPartialSubscriptionFailure from the azure provider so the
scheduler and the CLI share one definition of the check rather than each
hand-rolling errors.As, and add FailedSubscriptionIDs for the log lines.

Recording the incompleteness in the state table's last_collection_error, so
the dashboard shows the sweep as partial rather than merely smaller, needs a
partial-note threaded through collectProviderRecommendations and its three
per-provider implementations. Left as follow-up rather than bolted on here;
the warning names the failed subscriptions in the meantime.

Tests drive fetchAndConvert with a client returning recommendations plus a
partial-failure error and assert the data survives, that a genuine error
still fails loud, and that the helper is narrow. Verified failing with the
tolerance removed.
The Lint Code job pins golangci-lint v2.10.1 and .golangci.yml sets
misspell locale to US, so the British spelling in these two comments
failed the lint gate and cascaded to the CI Success aggregate:

  internal/scheduler/scheduler.go:910
  internal/scheduler/scheduler_test.go:766

Both are comment prose, so this changes no identifier, no database
column name and no string any test asserts on.

Scoped deliberately to the two lines the linter flagged. 49 further
occurrences of the British spelling remain elsewhere in the tree and
were left alone: they are pre-existing, unrelated to this branch, and
a repo-wide rewrite here would bury a two-word fix under a diff
touching dozens of files.
Three review findings on the org-wide multi-subscription fan-out.

1. A partially-satisfied AccountFilter silently narrowed the sweep. A
   filter matching NO accessible subscription already errored, but one
   matching some and missing others returned the matched subscriptions'
   recommendations with a nil error. An operator scoping a sweep to
   sub-A and sub-B whose principal has lost Reader on sub-B therefore
   read sub-B as "no savings available" rather than "never queried" --
   the exact confusion PartialSubscriptionFailureError exists to prevent,
   and which selectSubscriptions' own doc comment says must not happen.

   selectSubscriptions now iterates the filter rather than the discovered
   subscriptions, so every requested entry is accounted for as matched or
   missed, and the misses are folded into the partial-failure error as
   ErrSubscriptionNotAccessible. They count towards Attempted and Failed;
   the subscriptions that did answer still return their data, so one
   revoked subscription does not become a total collection outage. The
   filter is also deduplicated, so a repeated entry is neither queried
   twice nor double-counted.

2. PartialSubscriptionFailureError.Error() indexed Failed[0] unguarded.
   The type and its fields are exported, so a zero-value or
   externally-constructed instance panicked when formatted -- an error
   that crashes when logged fails exactly when the message is needed.

3. SetCredential and SetSubscriptionsClient wrote cred/subscriptionsClient
   as plain assignments and only then took accountsMu to invalidate, while
   fetchAccounts read both from a goroutine running on behalf of every
   caller that joined the single-flight. Both fields are now published
   inside one accountsMu critical section (via invalidateAccountsCacheLocked,
   since RWMutex is not reentrant) and snapshotted together under RLock,
   which also closes the window where a fetch pairs the old client with the
   new credential. The remaining in-package reads go through the same
   accessors so the guarantee holds rather than covering only one path.
   No production caller is concurrent today, so this was latent.

Both behavioral fixes are covered by tests confirmed to fail on the
pre-fix code: the partial-filter test with "An error is expected but got
nil", and the swap test with a data race reported by -race.

Refs #1652, #1653, #1654
Addresses both CodeRabbit findings from the review round on PR #1520.
Both harden invariants that the previous commit established on one path
and left off the others, which reads as a global guarantee but is not.

1. Silent subscription retarget (providers/azure/provider.go)

resolveSubscriptionIDFromCtx -- which backs GetRegions and
GetServiceClient -- called getDefaultSubscriptionID without first
validating an explicitly configured subscription against the discovered
list. resolveDefaultSubscription deliberately falls through to its
single-visible-subscription rule when the configured target is not
found, so with an unreachable AZURE_SUBSCRIPTION_ID (or ProviderConfig
subscription) and exactly one visible subscription, the request was
silently retargeted to whichever subscription happened to be visible.
GetRecommendationsClient already guarded this via accountsContain under
a comment reading "This ordering is load-bearing"; the same
misconfiguration therefore errored on the recommendations path and
silently retargeted on the regions/service path.

GetServiceClient drives the resource enumeration recommendations are
computed from, and is also the purchase-execution path's service-client
lookup, so the operator could believe they were scoped to subscription A
while data came from B.

The guard is extracted into validateConfiguredSubscription and reused by
GetRecommendationsClient, so the two paths cannot drift apart again. It
covers both configuration axes (ProviderConfig field and the env var),
mirroring resolveDefaultSubscription's own priority order. Production
Azure flows pin AzureSubscriptionID (scheduler, registration and account
handlers) and short-circuit before this path, so the guard engages only
on the unpinned/ambient path where the retarget lived.

GetRegions' error wrapper no longer claims "no Azure subscriptions
found": the new inner error fires precisely when subscriptions were
found but the configured one is not among them.

2. Unsynchronized credProvider (providers/azure/provider.go)

SetCredentialProvider wrote p.credProvider outside accountsMu while
IsConfigured read it inside credOnce.Do -- the one swappable field left
outside the locking discipline the rest of the provider follows. Latent
today (SetCredentialProvider has no production callers), but a half-kept
invariant reads as a kept one. Published and read under accountsMu now,
matching cred and subscriptionsClient. It deliberately does not
invalidate the accounts cache: credProvider only feeds the lazy
resolution that runs when no credential was installed at all, so no
subscription list resolved under a different credential can exist yet.

Regression tests, both confirmed failing against the pre-fix code:

- TestAzureProvider_ResolveSubscription_InvisibleConfiguredTargetErrors
  uses an invisible configured target with exactly ONE visible
  subscription -- the case the fall-through hides. With two or more
  visible subscriptions getDefaultSubscriptionID returns "" and the
  pre-existing "multiple Azure subscriptions" error fires either way, so
  such a test would pass with or without the guard. Covers GetRegions
  and GetServiceClient, both configuration axes, and the honoured
  visible-target case. Pre-fix: 3 of 4 subtests fail with "An error is
  expected but got nil".
- TestAzureProvider_ConcurrentCredentialProviderSwapAndIsConfigured_NoDataRace
  loops over fresh providers because credOnce makes the read one-shot
  per instance. Pre-fix: WARNING: DATA RACE, write at provider.go:187
  against read at provider.go:280.

TestAzureProvider_GetRegions and
TestAzureProvider_GetServiceClient_WithSubscriptionLookup now clear
AZURE_SUBSCRIPTION_ID via the existing helper: their fixtures resolve
through the newly-guarded path, so an exported value on a developer
machine would fail them for an unrelated reason.
@cristim
cristim force-pushed the feat/553-azure-multi-subscription branch from 4bc85e5 to c8f13b3 Compare August 3, 2026 13:11
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (218f3858e) to pick up the hadolint pin from #1697.

Run pre-commit hooks was the only failing check here, and it was not this PR's doing: the hadolint-docker hook entry carries no image tag, so Docker resolved it to :latest independently of the repo's rev: v2.14.0 pin. When upstream published 2.15.1 as latest, CI silently started linting with a newer ruleset and began failing DL3066 on an unchanged Dockerfile, blocking every open PR including ones touching zero Docker files. #1697 pins the image by digest; this rebase inherits it.

No content change from the rebase, just a new base. Verified after rebasing: go build ./... clean, go test green on providers/azure/... and internal/scheduler/....

One note for the record: a stale worktree for this branch still held a pre-rebase copy of the history, which looked like 9 unpushed commits. It was not — the remote already contained all of that work plus two newer commits (219bd5d95 locking credential swaps in the fan-out, and 4bc85e5ba validating the configured subscription on the regions/service path). Pushing the local copy would have reverted both. The remote was authoritative and is what was rebased.

@cristim
cristim merged commit b117634 into main Aug 3, 2026
20 checks passed
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review record (merged)

Independent reviewer, distinct from the author. Two reports.

Freshness handled properly rather than trusted. This PR's green checks were six days old, predating four merges that landed the same day, so they were not evidence about current main. Instead of either trusting them or assuming breakage, the merge was computed with git merge-tree --write-tree and materialised into a scratch dir without touching the repo, then built and tested: go build ./... exit 0, go vet exit 0, go test -race -count=1 across 13 azure + scheduler packages ok, grep -c "DATA RACE" → 0. That is the only freshness evidence worth acting on.

Only two files overlapped main's new commits, via a pointer-only DatabaseDetails/CacheDetails refactor — the classic merges-clean-then-fails-to-compile shape. Disproved twice: the PR never references those types, and the merged tree builds and passes.

The headline feature is genuinely constructed in production, not test-only: GetRecommendationsClient reaches NewMultiSubscriptionRecommendationsClient when nothing is pinned and ≥2 subscriptions are visible, and the partial-failure signal is handled at two real call sites rather than swallowed. Another PR in the same batch failed exactly this check.

Fan-out concurrency: the doc's claim that unbounded goroutines are fine because the ARM semaphore is acquired inside each per-subscription client is true — verified that it is a shared semaphore carried on the context and set by the scheduler, not per-client. Carve-out the comment states but does not weigh: when no semaphore is attached (CLI, tests) Acquire/Release are no-ops, and the CLI is a reachable consumer. Tracked as LeanerCloud/cloud-commitments-go#58.

The two CodeRabbit findings, both real and both fixed before merge

CR-1, money-adjacent: resolveSubscriptionIDFromCtx never called accountsContain, while GetRecommendationsClient did — so with AZURE_SUBSCRIPTION_ID set to an invisible subscription and exactly one visible, the same misconfiguration errored on the recommendations path and silently retargeted on the regions/service path. Since GetServiceClient drives the resource enumeration recommendations are computed from, an operator could believe they were scoped to subscription A while data came from B. Pre-existing on main (all three paths retargeted there), but this PR hardened one and left two, creating an asymmetry that reads as a global guard. Fixed in c8f13b3de.

CR-2: credProvider was the one swappable field left outside the synchronization invariant this PR established for every other one. Latent (SetCredentialProvider has no production callers) but a half-done guarantee reads as done.

Consequence of this merge, tracked

The partial-sweep eviction hazard (#1652) becomes structurally closer with the fan-out client shipped: eviction is keyed on (provider, account_key) — the account UUID — while a fan-out introduces a finer failure unit, the subscription. A later trace established the path is not yet reachable (both scheduler Azure paths pin a subscription, and PartialSubscriptionFailureError has a single construction site inside the fan-out client), so #1652 remains latent. It is being fixed pre-emptively, which is the cheap ordering.

Note the existing tests encode that hazard as intended behaviour — TestTolerateIncompleteSweep asserts NoError on a partial sweep — so a fix needs a test at subscription granularity or it merges green having demonstrated nothing.

@cristim
cristim deleted the feat/553-azure-multi-subscription branch August 25, 2026 23:48
cristim added a commit that referenced this pull request Sep 27, 2026
…loses #553) (#1520)

* feat(azure): org-wide multi-subscription recommendation collection

Cache the ARM subscriptions list on AzureProvider (getOrFetchAccounts,
double-checked locking, InvalidateAccountsCache for tests) so repeated
GetAccounts/GetServiceClient/GetRecommendationsClient calls in one run
do not each re-issue the subscriptions.List API call.

Add MultiSubscriptionRecommendationsClient, which fans recommendation
collection out across every subscription visible to the authenticated
principal via errgroup, isolating per-subscription errors and only
failing when every subscription fails. GetRecommendationsClient now
returns this fan-out client when no subscription is pinned and 2+ are
discovered, bringing Azure to parity with the AWS provider's automatic
whole-organization recommendation coverage. Pinned-subscription and
single-subscription paths are unchanged.

Closes #553

* fix(azure): singleflight the accounts cache instead of locking across the ARM fetch

getOrFetchAccounts held accountsMu.Lock() for the entire subscriptions.List
network round-trip via fetchAccountsLocked, so every reader of the cached
subscription list blocked behind a slow or hung ARM call. Move the fetch
into golang.org/x/sync/singleflight so concurrent cold-cache callers
coalesce into one in-flight request, taking accountsMu only to read or
populate cachedAccounts.

Add a deterministic regression test that stages N goroutines through a
channel-gated fake fetch and asserts exactly one underlying ARM call.

* fix(azure): keep recommendation scope pinned and fix cache invalidation

Adversarial review of the org-wide fan-out. Scoping first.

GetRecommendationsClient ignored the resolved default subscription. Before
fan-out existed, an unpinned provider resolved the default via
resolveSubscriptionIDFromCtx, which honours AZURE_SUBSCRIPTION_ID and errors
when the scope is ambiguous. The fan-out path replaced that with "2+ visible
subscriptions means fan out", so a caller that pinned a subscription through
the environment silently started receiving every other visible subscription's
recommendations. Restore the precedence: a resolvable default still yields a
single-subscription client, a configured AZURE_SUBSCRIPTION_ID naming a
subscription this principal cannot see is an error, and only a genuinely
unnamed scope fans out.

MultiSubscriptionRecommendationsClient ignored params.AccountFilter. The AWS
provider applies that filter to every recommendation it returns
(filterByAccounts in providers/aws/service_client.go). It was moot while an
Azure client could only ever cover its own subscription, but a client
covering every visible subscription has to honour it or a request scoped to
one account comes back carrying others'. Filter before the fan-out so
subscriptions outside the filter are never queried, and error rather than
return an empty slice when the filter matches nothing.

Then the subscription-list cache.

InvalidateAccountsCache was a no-op against an in-flight fetch: a fetch that
started before the invalidation still published its pre-invalidation snapshot
afterwards, so the next read was served from cache and handed back exactly
the data the caller asked to discard, with no second ARM call. A caller
arriving in that window also joined the still in-flight pre-invalidation call
through the constant singleflight key. Both are closed by an accountsGen
counter that keys the singleflight call and gates the cache write.

singleflight hands the leader's error to every waiter, so a caller whose own
context was still live inherited a cancellation raised by whichever unrelated
caller won the leader election. Retry once as leader when our own context is
still healthy; our own cancellation stays terminal, so this cannot spin on a
dead context. The unchecked type assertion on singleflight's untyped result
now returns an error instead of panicking.

Test fixes: the context-cancellation test passed with the guard it named
deleted, because fakes that return ctx.Err() make every subscription fail and
the all-failed guard produces the same error either way; its fakes now ignore
the cancelled context so only the post-Wait ctx.Err() check can produce the
error. The new cache tests read AZURE_SUBSCRIPTION_ID from the ambient
environment and now neutralise it, the concurrent cold-cache test waits for
every follower to enter GetAccounts rather than relying on a runtime.Gosched
nudge, and the constructor test asserts the built client is actually stored.

Each behaviour has a regression test verified to fail with its fix reverted.

Refs #553

* fix(scheduler): reject Azure accounts with no subscription ID

collectAzureForAccount tags every recommendation it returns with the
registered account's UUID, so the provider has to be pinned to that account's
subscription. It built the provider from acct.AzureSubscriptionID without
checking it was set, and validateCloudAccountRequest does not require the
field, so an azure row created without one produced an unpinned provider.

That was harmless while an unpinned provider resolved a single subscription
or errored. With the org-wide fan-out it instead collects from every
subscription the credential can see, and those results are then filed under
this one account, where anyone authorised for it can read them. Reject the
misconfigured row by name instead.

* fix(azure): drop the accounts cache when the credential is swapped

SetCredential is used in production, not only in tests: the scheduler and
purchase-execution paths construct the provider and then install per-account
federated credentials. The subscription-list cache added in this PR records
what the previous credential could see, so leaving it in place across a swap
would report subscriptions the new principal may have no access to, and
GetRecommendationsClient would fan out across them.

Every caller today installs the credential before the first accounts fetch,
so this is a guard against a future reordering rather than a live leak.
SetSubscriptionsClient gets the same treatment for the same reason.

* fix(azure): validate AZURE_SUBSCRIPTION_ID before default resolution

CodeRabbit finding. GetRecommendationsClient consulted
getDefaultSubscriptionID before validating the configured target, and
resolveDefaultSubscription's rule 3 marks a lone visible subscription as the
default even when a target was configured and did not match it.

So with AZURE_SUBSCRIPTION_ID naming a subscription the credential cannot see
and exactly one subscription visible, that one subscription was returned as
the default and the fail-loud check further down was unreachable. An operator
who typo'd the variable, or whose credential lost access to the intended
subscription, silently collected against the wrong subscription instead of
getting an error: a misconfiguration answered with a plausible wrong result.

Validate the target against the discovered accounts first, via a direct
membership check rather than IsDefault, so it fails loud regardless of how
many subscriptions are visible. A target that IS visible is honoured directly.

The previous test only covered two visible subscriptions, where rule 3 never
fires; the new subtest pins the single-visible-subscription case and fails
against the old ordering.

* fix(azure): report partial multi-subscription sweeps as partial

mergeSubscriptionResults returned a nil error whenever at least one
subscription succeeded, so 3 of 50 subscriptions failing handed back 47
subscriptions' recommendations indistinguishably from a complete sweep. The
only signal was a log line, which no programmatic caller reads. On a path
whose output is persisted and rendered as a savings opportunity, an
under-collected sweep then reads as a shrinking opportunity rather than a
failed one.

Add PartialSubscriptionFailureError, returned ALONGSIDE the successful
subscriptions' recommendations, carrying the attempted/succeeded counts and
every failed subscription with its cause. Unwrap exposes the per-subscription
causes so errors.Is still matches through it.

Failing the whole sweep on one transient error would be worse than a partial
result, so the data is still returned; callers opt in to keeping it by
inspecting the error with errors.As, and a caller that treats any non-nil
error as fatal now fails loud instead of silently under-reporting. Both
outcomes beat the previous silent success.

Updated the two call sites that can reach the fan-out (the CLI paths, which
are the only ones that construct an unpinned Azure provider) to keep the
partial data and report the gap. The scheduler's Azure paths always pin a
subscription -- collectAzureAmbient via AZURE_SUBSCRIPTION_ID and
collectAzureForAccount via the account's subscription -- so they never build
the fan-out client and need no change.

Tests assert the distinguishing property directly: a sweep where a
subscription was never queried and a complete sweep that found nothing both
produce an empty slice, and only the error tells them apart. Verified failing
against the old nil-error contract.

* fix(scheduler): keep partial multi-subscription sweep data

Second-order effect of returning a typed partial-failure error. Every
recommendation-fetch site in the scheduler has the shape

	recs, err := recClient.GetAllRecommendations(ctx)
	if err != nil { return nil, ... }

which discards recs. Correct for a real error, but for a partial sweep it
turned one flaky subscription out of fifty into a total collection outage:
strictly worse than the silent under-collection the typed error exists to
prevent. The diff that introduced it looked like a pure improvement in
isolation and only misbehaved at callers that were not re-read.

Add tolerateIncompleteSweep and route every fetch site through it, so the
policy lives in one place instead of relying on each call site to remember
it. A partial sweep logs a warning naming the subscriptions that were not
queried and returns nil, so the data that was collected is kept and
persisted. Any other error is returned unchanged.

Also export AsPartialSubscriptionFailure from the azure provider so the
scheduler and the CLI share one definition of the check rather than each
hand-rolling errors.As, and add FailedSubscriptionIDs for the log lines.

Recording the incompleteness in the state table's last_collection_error, so
the dashboard shows the sweep as partial rather than merely smaller, needs a
partial-note threaded through collectProviderRecommendations and its three
per-provider implementations. Left as follow-up rather than bolted on here;
the warning names the failed subscriptions in the meantime.

Tests drive fetchAndConvert with a client returning recommendations plus a
partial-failure error and assert the data survives, that a genuine error
still fails loud, and that the helper is narrow. Verified failing with the
tolerance removed.

* chore(scheduler): use US spelling of "behavior" in two comments

The Lint Code job pins golangci-lint v2.10.1 and .golangci.yml sets
misspell locale to US, so the British spelling in these two comments
failed the lint gate and cascaded to the CI Success aggregate:

  internal/scheduler/scheduler.go:910
  internal/scheduler/scheduler_test.go:766

Both are comment prose, so this changes no identifier, no database
column name and no string any test asserts on.

Scoped deliberately to the two lines the linter flagged. 49 further
occurrences of the British spelling remain elsewhere in the tree and
were left alone: they are pre-existing, unrelated to this branch, and
a repo-wide rewrite here would bury a two-word fix under a diff
touching dozens of files.

* fix(azure): report filter misses and lock credential swaps in fan-out

Three review findings on the org-wide multi-subscription fan-out.

1. A partially-satisfied AccountFilter silently narrowed the sweep. A
   filter matching NO accessible subscription already errored, but one
   matching some and missing others returned the matched subscriptions'
   recommendations with a nil error. An operator scoping a sweep to
   sub-A and sub-B whose principal has lost Reader on sub-B therefore
   read sub-B as "no savings available" rather than "never queried" --
   the exact confusion PartialSubscriptionFailureError exists to prevent,
   and which selectSubscriptions' own doc comment says must not happen.

   selectSubscriptions now iterates the filter rather than the discovered
   subscriptions, so every requested entry is accounted for as matched or
   missed, and the misses are folded into the partial-failure error as
   ErrSubscriptionNotAccessible. They count towards Attempted and Failed;
   the subscriptions that did answer still return their data, so one
   revoked subscription does not become a total collection outage. The
   filter is also deduplicated, so a repeated entry is neither queried
   twice nor double-counted.

2. PartialSubscriptionFailureError.Error() indexed Failed[0] unguarded.
   The type and its fields are exported, so a zero-value or
   externally-constructed instance panicked when formatted -- an error
   that crashes when logged fails exactly when the message is needed.

3. SetCredential and SetSubscriptionsClient wrote cred/subscriptionsClient
   as plain assignments and only then took accountsMu to invalidate, while
   fetchAccounts read both from a goroutine running on behalf of every
   caller that joined the single-flight. Both fields are now published
   inside one accountsMu critical section (via invalidateAccountsCacheLocked,
   since RWMutex is not reentrant) and snapshotted together under RLock,
   which also closes the window where a fetch pairs the old client with the
   new credential. The remaining in-package reads go through the same
   accessors so the guarantee holds rather than covering only one path.
   No production caller is concurrent today, so this was latent.

Both behavioral fixes are covered by tests confirmed to fail on the
pre-fix code: the partial-filter test with "An error is expected but got
nil", and the swap test with a data race reported by -race.

Refs #1652, #1653, #1654

* fix(azure): validate configured subscription on the regions/service path

Addresses both CodeRabbit findings from the review round on PR #1520.
Both harden invariants that the previous commit established on one path
and left off the others, which reads as a global guarantee but is not.

1. Silent subscription retarget (providers/azure/provider.go)

resolveSubscriptionIDFromCtx -- which backs GetRegions and
GetServiceClient -- called getDefaultSubscriptionID without first
validating an explicitly configured subscription against the discovered
list. resolveDefaultSubscription deliberately falls through to its
single-visible-subscription rule when the configured target is not
found, so with an unreachable AZURE_SUBSCRIPTION_ID (or ProviderConfig
subscription) and exactly one visible subscription, the request was
silently retargeted to whichever subscription happened to be visible.
GetRecommendationsClient already guarded this via accountsContain under
a comment reading "This ordering is load-bearing"; the same
misconfiguration therefore errored on the recommendations path and
silently retargeted on the regions/service path.

GetServiceClient drives the resource enumeration recommendations are
computed from, and is also the purchase-execution path's service-client
lookup, so the operator could believe they were scoped to subscription A
while data came from B.

The guard is extracted into validateConfiguredSubscription and reused by
GetRecommendationsClient, so the two paths cannot drift apart again. It
covers both configuration axes (ProviderConfig field and the env var),
mirroring resolveDefaultSubscription's own priority order. Production
Azure flows pin AzureSubscriptionID (scheduler, registration and account
handlers) and short-circuit before this path, so the guard engages only
on the unpinned/ambient path where the retarget lived.

GetRegions' error wrapper no longer claims "no Azure subscriptions
found": the new inner error fires precisely when subscriptions were
found but the configured one is not among them.

2. Unsynchronized credProvider (providers/azure/provider.go)

SetCredentialProvider wrote p.credProvider outside accountsMu while
IsConfigured read it inside credOnce.Do -- the one swappable field left
outside the locking discipline the rest of the provider follows. Latent
today (SetCredentialProvider has no production callers), but a half-kept
invariant reads as a kept one. Published and read under accountsMu now,
matching cred and subscriptionsClient. It deliberately does not
invalidate the accounts cache: credProvider only feeds the lazy
resolution that runs when no credential was installed at all, so no
subscription list resolved under a different credential can exist yet.

Regression tests, both confirmed failing against the pre-fix code:

- TestAzureProvider_ResolveSubscription_InvisibleConfiguredTargetErrors
  uses an invisible configured target with exactly ONE visible
  subscription -- the case the fall-through hides. With two or more
  visible subscriptions getDefaultSubscriptionID returns "" and the
  pre-existing "multiple Azure subscriptions" error fires either way, so
  such a test would pass with or without the guard. Covers GetRegions
  and GetServiceClient, both configuration axes, and the honoured
  visible-target case. Pre-fix: 3 of 4 subtests fail with "An error is
  expected but got nil".
- TestAzureProvider_ConcurrentCredentialProviderSwapAndIsConfigured_NoDataRace
  loops over fresh providers because credOnce makes the read one-shot
  per instance. Pre-fix: WARNING: DATA RACE, write at provider.go:187
  against read at provider.go:280.

TestAzureProvider_GetRegions and
TestAzureProvider_GetServiceClient_WithSubscriptionLookup now clear
AZURE_SUBSCRIPTION_ID via the existing helper: their fixtures resolve
through the newly-guarded path, so an exported value on a developer
machine would fail them for an unrelated reason.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(providers/azure/provider): add multi-account (Subscriptions) support

1 participant