Skip to content

Commit 6a8155f

Browse files
committed
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
1 parent b83c5d1 commit 6a8155f

4 files changed

Lines changed: 615 additions & 13 deletions

File tree

‎providers/azure/provider.go‎

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ type AzureProvider struct {
9696
region string // Default region for operations
9797
subscriptionsClient SubscriptionsClient
9898
credProvider CredentialProvider
99+
100+
// accountsMu guards cachedAccounts. GetAccounts, GetServiceClient, and
101+
// GetRecommendationsClient all resolve the subscription list on the hot
102+
// path; without caching, a single logical operation (e.g. a
103+
// multi-subscription recommendations sweep) would re-issue the ARM
104+
// subscriptions.List call once per internal caller. cachedAccounts is
105+
// nil until the first successful fetch; InvalidateAccountsCache resets
106+
// it so tests (and long-lived callers that expect subscription
107+
// membership to change) can force a refresh.
108+
accountsMu sync.RWMutex
109+
cachedAccounts []common.Account
99110
}
100111

101112
// NewAzureProvider creates a new Azure provider instance.
@@ -253,7 +264,71 @@ func (p *AzureProvider) GetAccounts(ctx context.Context) ([]common.Account, erro
253264
if !p.IsConfigured() {
254265
return nil, fmt.Errorf("azure provider is not configured")
255266
}
267+
return p.getOrFetchAccounts(ctx)
268+
}
256269

270+
// getOrFetchAccounts returns the cached subscription list, populating it via
271+
// fetchAccountsLocked on first use. Safe for concurrent callers: a read lock
272+
// guards the fast path (cache already populated); a write lock guards the
273+
// fetch-and-populate path, with a re-check after acquiring it so concurrent
274+
// callers that lost the race to the lock don't issue a redundant ARM call.
275+
//
276+
// Callers must have already verified IsConfigured(); this method assumes a
277+
// usable credential is present (mirrors GetAccounts, its only production
278+
// caller alongside GetServiceClient/GetRecommendationsClient which check
279+
// IsConfigured() themselves before resolving accounts).
280+
func (p *AzureProvider) getOrFetchAccounts(ctx context.Context) ([]common.Account, error) {
281+
p.accountsMu.RLock()
282+
cached := p.cachedAccounts
283+
p.accountsMu.RUnlock()
284+
if cached != nil {
285+
return cloneAccounts(cached), nil
286+
}
287+
288+
p.accountsMu.Lock()
289+
defer p.accountsMu.Unlock()
290+
// Re-check: another goroutine may have populated the cache while this
291+
// one was waiting on the write lock.
292+
if p.cachedAccounts != nil {
293+
return cloneAccounts(p.cachedAccounts), nil
294+
}
295+
296+
accounts, err := p.fetchAccountsLocked(ctx)
297+
if err != nil {
298+
return nil, err
299+
}
300+
p.cachedAccounts = accounts
301+
return cloneAccounts(accounts), nil
302+
}
303+
304+
// cloneAccounts returns a shallow copy of accounts backed by a fresh array.
305+
// common.Account has no nested slices/maps, so a shallow per-element copy is
306+
// sufficient to stop a caller mutating a returned slice (e.g. flipping
307+
// IsDefault) from corrupting the shared cache -- the same class of bug
308+
// flagged for getters returning nested state.
309+
func cloneAccounts(accounts []common.Account) []common.Account {
310+
out := make([]common.Account, len(accounts))
311+
copy(out, accounts)
312+
return out
313+
}
314+
315+
// InvalidateAccountsCache clears the cached subscription list so the next
316+
// getOrFetchAccounts call re-fetches from the ARM subscriptions API. Exposed
317+
// for tests that need to assert cache-miss behavior; production callers
318+
// currently rely on the cache living for the lifetime of the AzureProvider
319+
// instance (one instance is constructed per collection/purchase run).
320+
func (p *AzureProvider) InvalidateAccountsCache() {
321+
p.accountsMu.Lock()
322+
defer p.accountsMu.Unlock()
323+
p.cachedAccounts = nil
324+
}
325+
326+
// fetchAccountsLocked performs the actual ARM subscriptions.List call and
327+
// resolves the default subscription. Must only be called while holding
328+
// accountsMu for writing (via getOrFetchAccounts) -- it does not lock itself
329+
// so getOrFetchAccounts can do its cache-populate-and-return in one critical
330+
// section.
331+
func (p *AzureProvider) fetchAccountsLocked(ctx context.Context) ([]common.Account, error) {
257332
// Use injected client if available (for testing)
258333
var subClient SubscriptionsClient
259334
if p.subscriptionsClient != nil {
@@ -501,27 +576,49 @@ func (p *AzureProvider) newServiceClientForSubscription(service common.ServiceTy
501576
}
502577
}
503578

504-
// GetRecommendationsClient returns a recommendations client for the default
505-
// subscription.
579+
// GetRecommendationsClient returns a recommendations client.
506580
//
507-
// When operating across multiple subscriptions (fan-out), prefer
508-
// GetRecommendationsClientForAccount.
581+
// When a subscription is pinned (p.subscriptionID set, e.g. by the scheduler
582+
// or purchase-execution paths that always operate on one registered
583+
// account), the returned client is scoped to that single subscription --
584+
// unchanged from previous behavior.
585+
//
586+
// When no subscription is pinned, GetRecommendationsClient discovers every
587+
// subscription accessible to the authenticated principal (via the cached
588+
// getOrFetchAccounts) and, when 2+ are visible, fans recommendation
589+
// collection out across all of them via
590+
// MultiSubscriptionRecommendationsClient. Azure has no organization-wide
591+
// equivalent of AWS Cost Explorer's AccountScope=Linked -- the Consumption
592+
// Reservation Recommendations and Advisor APIs are subscription-scoped -- so
593+
// this client-side fan-out is what brings Azure to parity with the AWS
594+
// provider's automatic whole-organization coverage. A single discovered
595+
// subscription still returns the plain single-subscription client; no
596+
// fan-out machinery is needed for one subscription.
509597
func (p *AzureProvider) GetRecommendationsClient(ctx context.Context) (provider.RecommendationsClient, error) {
510598
if !p.IsConfigured() {
511599
return nil, fmt.Errorf("azure provider is not configured")
512600
}
513601

514-
// Use explicit subscription ID if configured; otherwise resolve from accounts.
515-
subscriptionID := p.subscriptionID
516-
if subscriptionID == "" {
517-
var err error
518-
subscriptionID, err = p.resolveSubscriptionIDFromCtx(ctx)
519-
if err != nil {
520-
return nil, err
521-
}
602+
if p.subscriptionID != "" {
603+
return NewRecommendationsClient(p.cred, p.subscriptionID)
522604
}
523605

524-
return NewRecommendationsClient(p.cred, subscriptionID)
606+
accounts, err := p.getOrFetchAccounts(ctx)
607+
if err != nil {
608+
return nil, fmt.Errorf("failed to resolve Azure subscriptions: %w", err)
609+
}
610+
if len(accounts) == 0 {
611+
return nil, fmt.Errorf("no Azure subscriptions found")
612+
}
613+
if len(accounts) == 1 {
614+
return NewRecommendationsClient(p.cred, accounts[0].ID)
615+
}
616+
617+
client, err := NewMultiSubscriptionRecommendationsClient(p.cred, accounts)
618+
if err != nil {
619+
return nil, err
620+
}
621+
return client, nil
525622
}
526623

527624
// GetRecommendationsClientForAccount returns a recommendations client scoped to

‎providers/azure/provider_test.go‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,3 +1241,148 @@ func TestAzureProvider_GetRecommendationsClientForAccount(t *testing.T) {
12411241
assert.Contains(t, err.Error(), "azure provider is not configured")
12421242
})
12431243
}
1244+
1245+
// countingSubscriptionsClient wraps mockSubscriptionsClient and counts how
1246+
// many times NewListPager is invoked, so cache-hit tests can assert the
1247+
// underlying ARM API is only called once.
1248+
type countingSubscriptionsClient struct {
1249+
*mockSubscriptionsClient
1250+
calls int
1251+
}
1252+
1253+
func (c *countingSubscriptionsClient) NewListPager(options *armsubscriptions.ClientListOptions) SubscriptionsPager {
1254+
c.calls++
1255+
return c.mockSubscriptionsClient.NewListPager(options)
1256+
}
1257+
1258+
// twoSubscriptionPages returns a mockSubscriptionsClient listing the same
1259+
// two fixed subscriptions ("sub-1"/"sub-2") every test in this file needs;
1260+
// none of the cache/fan-out tests care about the actual subscription
1261+
// identifiers, so a fixed pair keeps call sites short.
1262+
func twoSubscriptionPages() *mockSubscriptionsClient {
1263+
sub1ID, sub1Name := "sub-1", "Subscription 1"
1264+
sub2ID, sub2Name := "sub-2", "Subscription 2"
1265+
return &mockSubscriptionsClient{
1266+
listPagerFunc: func(options *armsubscriptions.ClientListOptions) SubscriptionsPager {
1267+
return &mockSubscriptionsPager{
1268+
pages: []armsubscriptions.ClientListResponse{
1269+
{
1270+
SubscriptionListResult: armsubscriptions.SubscriptionListResult{
1271+
Value: []*armsubscriptions.Subscription{
1272+
{SubscriptionID: &sub1ID, DisplayName: &sub1Name},
1273+
{SubscriptionID: &sub2ID, DisplayName: &sub2Name},
1274+
},
1275+
},
1276+
},
1277+
},
1278+
}
1279+
},
1280+
}
1281+
}
1282+
1283+
func TestAzureProvider_GetAccounts_CacheHit(t *testing.T) {
1284+
counting := &countingSubscriptionsClient{mockSubscriptionsClient: twoSubscriptionPages()}
1285+
1286+
p := &AzureProvider{cred: &mockTokenCredential{}}
1287+
p.SetSubscriptionsClient(counting)
1288+
1289+
first, err := p.GetAccounts(context.Background())
1290+
require.NoError(t, err)
1291+
require.Len(t, first, 2)
1292+
assert.Equal(t, 1, counting.calls, "first GetAccounts call should hit the API once")
1293+
1294+
second, err := p.GetAccounts(context.Background())
1295+
require.NoError(t, err)
1296+
require.Len(t, second, 2)
1297+
assert.Equal(t, 1, counting.calls, "second GetAccounts call should be served from cache, not the API")
1298+
assert.Equal(t, first, second)
1299+
}
1300+
1301+
func TestAzureProvider_GetAccounts_CacheHit_ReturnsIndependentCopies(t *testing.T) {
1302+
p := &AzureProvider{cred: &mockTokenCredential{}}
1303+
p.SetSubscriptionsClient(twoSubscriptionPages())
1304+
1305+
first, err := p.GetAccounts(context.Background())
1306+
require.NoError(t, err)
1307+
first[0].IsDefault = true // mutate the caller's copy
1308+
1309+
second, err := p.GetAccounts(context.Background())
1310+
require.NoError(t, err)
1311+
assert.False(t, second[0].IsDefault, "mutating a returned slice must not corrupt the cache")
1312+
}
1313+
1314+
func TestAzureProvider_InvalidateAccountsCache(t *testing.T) {
1315+
counting := &countingSubscriptionsClient{mockSubscriptionsClient: twoSubscriptionPages()}
1316+
1317+
p := &AzureProvider{cred: &mockTokenCredential{}}
1318+
p.SetSubscriptionsClient(counting)
1319+
1320+
_, err := p.GetAccounts(context.Background())
1321+
require.NoError(t, err)
1322+
assert.Equal(t, 1, counting.calls)
1323+
1324+
p.InvalidateAccountsCache()
1325+
1326+
_, err = p.GetAccounts(context.Background())
1327+
require.NoError(t, err)
1328+
assert.Equal(t, 2, counting.calls, "GetAccounts after InvalidateAccountsCache should re-hit the API")
1329+
}
1330+
1331+
func TestAzureProvider_GetRecommendationsClient_MultiSubscriptionFanOut(t *testing.T) {
1332+
t.Run("multi-subscription returns MultiSubscriptionRecommendationsClient", func(t *testing.T) {
1333+
p := &AzureProvider{cred: &mockTokenCredential{}}
1334+
p.SetSubscriptionsClient(twoSubscriptionPages())
1335+
1336+
client, err := p.GetRecommendationsClient(context.Background())
1337+
require.NoError(t, err)
1338+
require.IsType(t, &MultiSubscriptionRecommendationsClient{}, client)
1339+
assert.Len(t, client.(*MultiSubscriptionRecommendationsClient).subscriptions, 2)
1340+
})
1341+
1342+
t.Run("single discovered subscription returns RecommendationsClientAdapter", func(t *testing.T) {
1343+
subID, subName := "sub-solo", "Solo Subscription"
1344+
p := &AzureProvider{cred: &mockTokenCredential{}}
1345+
p.SetSubscriptionsClient(&mockSubscriptionsClient{
1346+
listPagerFunc: func(options *armsubscriptions.ClientListOptions) SubscriptionsPager {
1347+
return &mockSubscriptionsPager{
1348+
pages: []armsubscriptions.ClientListResponse{
1349+
{SubscriptionListResult: armsubscriptions.SubscriptionListResult{
1350+
Value: []*armsubscriptions.Subscription{{SubscriptionID: &subID, DisplayName: &subName}},
1351+
}},
1352+
},
1353+
}
1354+
},
1355+
})
1356+
1357+
client, err := p.GetRecommendationsClient(context.Background())
1358+
require.NoError(t, err)
1359+
require.IsType(t, &RecommendationsClientAdapter{}, client)
1360+
assert.Equal(t, subID, client.(*RecommendationsClientAdapter).subscriptionID)
1361+
})
1362+
1363+
t.Run("pinned subscription always returns single adapter regardless of discovered count", func(t *testing.T) {
1364+
p := &AzureProvider{cred: &mockTokenCredential{}, subscriptionID: "pinned-sub"}
1365+
// Deliberately do not set a subscriptions client: a pinned subscription
1366+
// must never trigger subscription discovery.
1367+
client, err := p.GetRecommendationsClient(context.Background())
1368+
require.NoError(t, err)
1369+
require.IsType(t, &RecommendationsClientAdapter{}, client)
1370+
assert.Equal(t, "pinned-sub", client.(*RecommendationsClientAdapter).subscriptionID)
1371+
})
1372+
1373+
// The zero-subscription "no Azure subscriptions found" case is already
1374+
// covered by TestAzureProvider_GetRecommendationsClient_WithSubscriptionLookup.
1375+
1376+
t.Run("subscription discovery failure is propagated", func(t *testing.T) {
1377+
p := &AzureProvider{cred: &mockTokenCredential{}}
1378+
p.SetSubscriptionsClient(&mockSubscriptionsClient{
1379+
listPagerFunc: func(options *armsubscriptions.ClientListOptions) SubscriptionsPager {
1380+
return &mockSubscriptionsPager{nextErr: errors.New("boom")}
1381+
},
1382+
})
1383+
1384+
_, err := p.GetRecommendationsClient(context.Background())
1385+
assert.Error(t, err)
1386+
assert.Contains(t, err.Error(), "failed to resolve Azure subscriptions")
1387+
})
1388+
}

0 commit comments

Comments
 (0)