From be588db5f287bc005c31a1309373379c788c5d9d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 26 May 2026 01:39:46 +0200 Subject: [PATCH 1/2] fix(scheduler): extend ambient host-account UUID tagging to Azure/GCP Issue #662: Azure and GCP lacked an ambient-credential fallback path when no cloud accounts were registered. Unlike AWS (which PR #607 already fixed), they silently returned empty rather than falling back to the host identity. This commit extends the #607 pattern to both providers: - Adds resolveAmbientAccountID: a provider-agnostic helper that looks up (provider, externalID) in cloud_accounts and returns the UUID, or "" for truly-orphan deployments. Errors are swallowed (logged at WARN) so a DB blip never fails the collection. - Adds collectAzureAmbient: ambient path via providerFactory for Azure, accepting the subscription ID explicitly to skip the API round-trip that would otherwise auto-discover subscriptions. - Adds collectGCPAmbient: ambient path via providerFactory for GCP (ADC). - Extends collectAzureRecommendations: when no accounts are registered and AZURE_SUBSCRIPTION_ID is set, falls back to ambient credentials and tags recs with the registered subscription UUID if found in cloud_accounts. - Extends collectGCPRecommendations: same pattern using GCP_PROJECT_ID. Eight new tests covering: happy path (UUID tagged), no registered row (nil preserved), store error (nil preserved, collection succeeds), env var absent (skips gracefully), plus two unit tests for resolveAmbientAccountID itself. Closes #662 --- internal/scheduler/scheduler.go | 114 +++++++++- internal/scheduler/scheduler_test.go | 315 +++++++++++++++++++++++++++ 2 files changed, 425 insertions(+), 4 deletions(-) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 1b8c0305a..0fd0b98c9 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -644,6 +644,72 @@ func (s *Scheduler) resolveAmbientHostAccountID(ctx context.Context) string { return acct.ID } +// resolveAmbientAccountID is the provider-agnostic counterpart to +// resolveAmbientHostAccountID: given the host's external identifier (subscription +// ID for Azure, project ID for GCP) it checks whether a registered cloud_accounts +// row exists for (provider, externalID) and returns its UUID. Returns "" on any +// error or when no row matches, preserving the pre-fix nil-tagging behaviour so +// truly-orphan deployments are unaffected. All errors are intentionally swallowed +// (logged at warn) — this is a best-effort UX improvement on the ambient path +// and must not break the collection. +func (s *Scheduler) resolveAmbientAccountID(ctx context.Context, provider, externalID string) string { + if externalID == "" { + return "" + } + acct, err := s.config.GetCloudAccountByExternalID(ctx, provider, externalID) + if err != nil { + logging.Warnf("ambient host-account lookup: GetCloudAccountByExternalID(%s,%s) failed: %v", provider, externalID, err) + return "" + } + if acct == nil { + // Truly orphan deployment — preserve pre-fix nil tagging. + return "" + } + return acct.ID +} + +// collectAzureAmbient collects Azure recommendations using the host's managed +// identity (DefaultAzureCredential / ambient credentials). Used by the ambient +// fallback path in collectAzureRecommendations when no accounts are registered. +// subscriptionID is passed explicitly to avoid an unnecessary Azure API round-trip +// to auto-discover subscriptions — the caller already resolved it from env. +func (s *Scheduler) collectAzureAmbient(ctx context.Context, subscriptionID string) ([]config.RecommendationRecord, error) { + prov, err := s.providerFactory.CreateAndValidateProvider(ctx, "azure", &provider.ProviderConfig{ + AzureSubscriptionID: subscriptionID, + }) + if err != nil { + return nil, fmt.Errorf("create ambient Azure provider: %w", err) + } + recClient, err := prov.GetRecommendationsClient(ctx) + if err != nil { + return nil, fmt.Errorf("get Azure recommendations client: %w", err) + } + recs, err := recClient.GetAllRecommendations(ctx) + if err != nil { + return nil, fmt.Errorf("get Azure recommendations: %w", err) + } + return s.convertRecommendations(recs, "azure"), nil +} + +// collectGCPAmbient collects GCP recommendations using Application Default +// Credentials. Used by the ambient fallback path in collectGCPRecommendations +// when no accounts are registered. +func (s *Scheduler) collectGCPAmbient(ctx context.Context) ([]config.RecommendationRecord, error) { + prov, err := s.providerFactory.CreateAndValidateProvider(ctx, "gcp", nil) + if err != nil { + return nil, fmt.Errorf("create ambient GCP provider: %w", err) + } + recClient, err := prov.GetRecommendationsClient(ctx) + if err != nil { + return nil, fmt.Errorf("get GCP recommendations client: %w", err) + } + recs, err := recClient.GetAllRecommendations(ctx) + if err != nil { + return nil, fmt.Errorf("get GCP recommendations: %w", err) + } + return s.convertRecommendations(recs, "gcp"), nil +} + func (s *Scheduler) collectAWSForAccount(ctx context.Context, globalCfg *config.GlobalConfig, acct config.CloudAccount) ([]config.RecommendationRecord, error) { // Self-account (role_arn with no role ARN) or ambient modes use ambient credentials if acct.AWSRoleARN == "" { @@ -667,11 +733,31 @@ func (s *Scheduler) collectAWSForAccount(ctx context.Context, globalCfg *config. // collectAzureRecommendations fans out across all enabled Azure accounts, // resolving per-account federated credentials via the KMS signer. +// When no accounts are registered but AZURE_SUBSCRIPTION_ID is set (CUDly +// running natively on Azure with managed identity), falls back to ambient +// credentials and tags recommendations with the registered subscription's +// UUID if found in cloud_accounts. func (s *Scheduler) collectAzureRecommendations(ctx context.Context, _ *config.GlobalConfig) ([]config.RecommendationRecord, []string, error) { accounts := s.enabledAccounts(ctx, "azure") if len(accounts) == 0 { - logging.Info("No enabled Azure accounts — skipping Azure recommendations") - return nil, nil, nil + // Issue #662: mirror the AWS ambient-path tagging fix for Azure. + // When the host subscription matches a registered cloud_accounts row, + // tag recs with that account's UUID instead of returning nil. + // Best-effort: env lookup or store errors must not fail the collection. + subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") + if subscriptionID == "" { + logging.Info("No enabled Azure accounts — skipping Azure recommendations") + return nil, nil, nil + } + recs, err := s.collectAzureAmbient(ctx, subscriptionID) + if err != nil { + return nil, nil, err + } + if acctID := s.resolveAmbientAccountID(ctx, "azure", subscriptionID); acctID != "" { + recs = s.tagAccount(recs, acctID) + return recs, []string{acctID}, nil + } + return recs, []string{""}, nil } recs, outcome := fanOutPerAccount(ctx, "Azure", accounts, s.collectAzureForAccount) @@ -708,11 +794,31 @@ func (s *Scheduler) collectAzureForAccount(ctx context.Context, acct config.Clou // collectGCPRecommendations fans out across all enabled GCP accounts, // resolving per-account federated credentials via the KMS signer. +// When no accounts are registered but GCP_PROJECT_ID is set (CUDly +// running natively on GCP with ADC), falls back to ambient credentials +// and tags recommendations with the registered project's UUID if found +// in cloud_accounts. func (s *Scheduler) collectGCPRecommendations(ctx context.Context, _ *config.GlobalConfig) ([]config.RecommendationRecord, []string, error) { accounts := s.enabledAccounts(ctx, "gcp") if len(accounts) == 0 { - logging.Info("No enabled GCP accounts — skipping GCP recommendations") - return nil, nil, nil + // Issue #662: mirror the AWS ambient-path tagging fix for GCP. + // When the host project matches a registered cloud_accounts row, + // tag recs with that account's UUID instead of returning nil. + // Best-effort: env lookup or store errors must not fail the collection. + projectID := os.Getenv("GCP_PROJECT_ID") + if projectID == "" { + logging.Info("No enabled GCP accounts — skipping GCP recommendations") + return nil, nil, nil + } + recs, err := s.collectGCPAmbient(ctx) + if err != nil { + return nil, nil, err + } + if acctID := s.resolveAmbientAccountID(ctx, "gcp", projectID); acctID != "" { + recs = s.tagAccount(recs, acctID) + return recs, []string{acctID}, nil + } + return recs, []string{""}, nil } recs, outcome := fanOutPerAccount(ctx, "GCP", accounts, s.collectGCPForAccount) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 5c0dab858..367b4fd86 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -2172,3 +2172,318 @@ func TestScheduler_ResolveAmbientHostAccountID_STSTimeout(t *testing.T) { assert.Less(t, elapsed, 4*time.Second, "resolveAmbientHostAccountID must not block beyond its internal 3s deadline") mockStore.AssertNotCalled(t, "GetCloudAccountByExternalID", mock.Anything, mock.Anything, mock.Anything) } + +// Issue #662: Azure ambient-path tagging fix — when AZURE_SUBSCRIPTION_ID +// matches a registered cloud_accounts row the ambient path must tag every +// rec with that account's UUID instead of nil. +func TestScheduler_CollectAzureRecommendations_AmbientTagging_HappyPath(t *testing.T) { + t.Setenv("AZURE_SUBSCRIPTION_ID", "sub-abc-123") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + // No enabled Azure accounts — ambient fallback fires. + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + { + Provider: common.ProviderAzure, + Service: common.ServiceCompute, + Region: "eastus", + ResourceType: "Standard_D2s_v3", + Count: 1, + Term: "1yr", + EstimatedSavings: 20.0, + }, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + // Registered host account (disabled on purpose — registration alone is the signal). + registered := &config.CloudAccount{ + ID: "az-uuid-001", + Provider: "azure", + ExternalID: "sub-abc-123", + AzureSubscriptionID: "sub-abc-123", + Enabled: false, + } + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "azure", "sub-abc-123").Return(registered, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectAzureRecommendations(ctx, nil) + require.NoError(t, err) + require.Len(t, recs, 1) + require.NotNil(t, recs[0].CloudAccountID, "rec must be tagged with registered Azure account UUID, not nil") + assert.Equal(t, "az-uuid-001", *recs[0].CloudAccountID) + assert.Equal(t, []string{"az-uuid-001"}, acctIDs, + "eviction account-keys must be the registered UUID, not the ambient sentinel") +} + +// Issue #662: Azure ambient path — subscription not in cloud_accounts table +// must keep CloudAccountID = nil (truly-orphan case preserved). +func TestScheduler_CollectAzureRecommendations_AmbientTagging_NoRegisteredAccount(t *testing.T) { + t.Setenv("AZURE_SUBSCRIPTION_ID", "sub-unregistered") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + {Provider: common.ProviderAzure, Service: common.ServiceCompute, Region: "westus", ResourceType: "Standard_B2s", Count: 1, Term: "1yr"}, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + // Store has no matching row. + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "azure", "sub-unregistered").Return(nil, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectAzureRecommendations(ctx, nil) + require.NoError(t, err) + require.Len(t, recs, 1) + assert.Nil(t, recs[0].CloudAccountID, "truly-orphan Azure deployment must keep CloudAccountID = nil") + assert.Equal(t, []string{""}, acctIDs, "eviction account-keys must keep the ambient sentinel") +} + +// Issue #662: Azure ambient path — store error must NOT fail the collection; +// recs are returned with the pre-fix nil tagging. +func TestScheduler_CollectAzureRecommendations_AmbientTagging_StoreError(t *testing.T) { + t.Setenv("AZURE_SUBSCRIPTION_ID", "sub-abc-123") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + {Provider: common.ProviderAzure, Service: common.ServiceCompute, Region: "eastus", ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr"}, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "azure", "sub-abc-123"). + Return(nil, errors.New("db unreachable")) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectAzureRecommendations(ctx, nil) + require.NoError(t, err, "store error must NOT fail the Azure collection") + require.Len(t, recs, 1) + assert.Nil(t, recs[0].CloudAccountID, "store error must leave the pre-fix nil tagging in place") + assert.Equal(t, []string{""}, acctIDs) +} + +// Issue #662: when AZURE_SUBSCRIPTION_ID is not set, collectAzureRecommendations +// must return empty without attempting an ambient provider call. +func TestScheduler_CollectAzureRecommendations_NoEnvVar_Skips(t *testing.T) { + // Ensure the env var is absent for this test. + t.Setenv("AZURE_SUBSCRIPTION_ID", "") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectAzureRecommendations(ctx, nil) + require.NoError(t, err) + assert.Empty(t, recs) + assert.Empty(t, acctIDs) + mockFactory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything) +} + +// Issue #662: GCP ambient-path tagging fix — when GCP_PROJECT_ID matches a +// registered cloud_accounts row the ambient path must tag every rec with +// that account's UUID instead of nil. +func TestScheduler_CollectGCPRecommendations_AmbientTagging_HappyPath(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "my-gcp-project") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + // No enabled GCP accounts — ambient fallback fires. + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + { + Provider: common.ProviderGCP, + Service: common.ServiceCompute, + Region: "us-central1", + ResourceType: "n2-standard-4", + Count: 1, + Term: "1yr", + EstimatedSavings: 30.0, + }, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, "gcp", mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + // Registered host account. + registered := &config.CloudAccount{ + ID: "gcp-uuid-001", + Provider: "gcp", + ExternalID: "my-gcp-project", + GCPProjectID: "my-gcp-project", + Enabled: false, + } + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "gcp", "my-gcp-project").Return(registered, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectGCPRecommendations(ctx, nil) + require.NoError(t, err) + require.Len(t, recs, 1) + require.NotNil(t, recs[0].CloudAccountID, "rec must be tagged with registered GCP account UUID, not nil") + assert.Equal(t, "gcp-uuid-001", *recs[0].CloudAccountID) + assert.Equal(t, []string{"gcp-uuid-001"}, acctIDs, + "eviction account-keys must be the registered UUID, not the ambient sentinel") +} + +// Issue #662: GCP ambient path — project not in cloud_accounts table must +// keep CloudAccountID = nil (truly-orphan case preserved). +func TestScheduler_CollectGCPRecommendations_AmbientTagging_NoRegisteredAccount(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "unregistered-project") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + {Provider: common.ProviderGCP, Service: common.ServiceCompute, Region: "us-central1", ResourceType: "n2-standard-2", Count: 1, Term: "1yr"}, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, "gcp", mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + // Store has no matching row. + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "gcp", "unregistered-project").Return(nil, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectGCPRecommendations(ctx, nil) + require.NoError(t, err) + require.Len(t, recs, 1) + assert.Nil(t, recs[0].CloudAccountID, "truly-orphan GCP deployment must keep CloudAccountID = nil") + assert.Equal(t, []string{""}, acctIDs, "eviction account-keys must keep the ambient sentinel") +} + +// Issue #662: GCP ambient path — store error must NOT fail the collection; +// recs are returned with the pre-fix nil tagging. +func TestScheduler_CollectGCPRecommendations_AmbientTagging_StoreError(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "my-gcp-project") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + mockProvider := new(MockProvider) + mockRecClient := new(MockRecommendationsClient) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + recommendations := []common.Recommendation{ + {Provider: common.ProviderGCP, Service: common.ServiceCompute, Region: "us-central1", ResourceType: "n2-standard-4", Count: 1, Term: "1yr"}, + } + mockFactory.On("CreateAndValidateProvider", mock.Anything, "gcp", mock.Anything).Return(mockProvider, nil) + mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) + mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) + + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "gcp", "my-gcp-project"). + Return(nil, errors.New("db unreachable")) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectGCPRecommendations(ctx, nil) + require.NoError(t, err, "store error must NOT fail the GCP collection") + require.Len(t, recs, 1) + assert.Nil(t, recs[0].CloudAccountID, "store error must leave the pre-fix nil tagging in place") + assert.Equal(t, []string{""}, acctIDs) +} + +// Issue #662: when GCP_PROJECT_ID is not set, collectGCPRecommendations +// must return empty without attempting an ambient provider call. +func TestScheduler_CollectGCPRecommendations_NoEnvVar_Skips(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "") + + ctx := context.Background() + mockStore := new(MockConfigStore) + mockFactory := new(MockProviderFactory) + + mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) + + sched := &Scheduler{ + config: mockStore, + providerFactory: mockFactory, + } + + recs, acctIDs, err := sched.collectGCPRecommendations(ctx, nil) + require.NoError(t, err) + assert.Empty(t, recs) + assert.Empty(t, acctIDs) + mockFactory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything) +} + +// Issue #662: resolveAmbientAccountID must return "" when externalID is empty. +func TestScheduler_ResolveAmbientAccountID_EmptyExternalID(t *testing.T) { + mockStore := new(MockConfigStore) + sched := &Scheduler{config: mockStore} + got := sched.resolveAmbientAccountID(context.Background(), "azure", "") + assert.Empty(t, got, "empty externalID must return empty without a store call") + mockStore.AssertNotCalled(t, "GetCloudAccountByExternalID", mock.Anything, mock.Anything, mock.Anything) +} + +// Issue #662: resolveAmbientAccountID must return "" and swallow the error +// when the store call fails. +func TestScheduler_ResolveAmbientAccountID_StoreError(t *testing.T) { + mockStore := new(MockConfigStore) + mockStore.On("GetCloudAccountByExternalID", mock.Anything, "gcp", "some-project"). + Return(nil, errors.New("store down")) + sched := &Scheduler{config: mockStore} + got := sched.resolveAmbientAccountID(context.Background(), "gcp", "some-project") + assert.Empty(t, got, "store error must collapse to empty (don't fail the collection)") +} From daa9af02745e01eaaaa025b0e6163ec89f1661ba Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 26 May 2026 04:33:30 +0200 Subject: [PATCH 2/2] test(scheduler): tighten Azure ambient mock matchers per CR nitpick Replace permissive mock.Anything matchers in the three Azure ambient-tagging tests with mock.MatchedBy checks that assert both the provider type ("azure") and the exact AzureSubscriptionID each test wires via AZURE_SUBSCRIPTION_ID. This locks in the field-level contract so a future regression (wrong provider name or wrong subscription routed to the factory) would be caught immediately. Resolves CodeRabbit nitpick on PR #725. --- internal/scheduler/scheduler_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 367b4fd86..04170975d 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -2199,7 +2199,9 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_HappyPath(t *testi EstimatedSavings: 20.0, }, } - mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockFactory.On("CreateAndValidateProvider", mock.Anything, "azure", mock.MatchedBy(func(cfg *provider.ProviderConfig) bool { + return cfg != nil && cfg.AzureSubscriptionID == "sub-abc-123" + })).Return(mockProvider, nil) mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) @@ -2243,7 +2245,9 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_NoRegisteredAccoun recommendations := []common.Recommendation{ {Provider: common.ProviderAzure, Service: common.ServiceCompute, Region: "westus", ResourceType: "Standard_B2s", Count: 1, Term: "1yr"}, } - mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockFactory.On("CreateAndValidateProvider", mock.Anything, "azure", mock.MatchedBy(func(cfg *provider.ProviderConfig) bool { + return cfg != nil && cfg.AzureSubscriptionID == "sub-unregistered" + })).Return(mockProvider, nil) mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil) @@ -2278,7 +2282,9 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_StoreError(t *test recommendations := []common.Recommendation{ {Provider: common.ProviderAzure, Service: common.ServiceCompute, Region: "eastus", ResourceType: "Standard_D2s_v3", Count: 1, Term: "1yr"}, } - mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything).Return(mockProvider, nil) + mockFactory.On("CreateAndValidateProvider", mock.Anything, "azure", mock.MatchedBy(func(cfg *provider.ProviderConfig) bool { + return cfg != nil && cfg.AzureSubscriptionID == "sub-abc-123" + })).Return(mockProvider, nil) mockProvider.On("GetRecommendationsClient", mock.Anything).Return(mockRecClient, nil) mockRecClient.On("GetAllRecommendations", mock.Anything).Return(recommendations, nil)