diff --git a/go.mod b/go.mod index e3735f4df..152b2310a 100644 --- a/go.mod +++ b/go.mod @@ -86,6 +86,7 @@ require ( cloud.google.com/go/kms v1.29.0 cloud.google.com/go/secretmanager v1.16.0 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 github.com/LeanerCloud/CUDly/pkg v0.0.0 github.com/LeanerCloud/CUDly/providers/aws v0.0.0 @@ -123,7 +124,6 @@ require ( cloud.google.com/go/storage v1.56.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v2 v2.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 // indirect diff --git a/internal/commitmentopts/probe_azure.go b/internal/commitmentopts/probe_azure.go new file mode 100644 index 000000000..d982c80ab --- /dev/null +++ b/internal/commitmentopts/probe_azure.go @@ -0,0 +1,212 @@ +package commitmentopts + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits" +) + +// azureSpService is the service name written into Combo.Service for Azure +// Savings Plans. Matches the key used in the Options map. +const azureSpService = "savingsplans" + +// azureSpProvider is the provider string for Azure commitment combos. +const azureSpProvider = "azure" + +// azureProbeSubscriptionID is a placeholder subscription ID used in probe +// requests. ValidatePurchase only verifies term/billingPlan shape; it does +// not require a real billing scope to evaluate the (term, payment) tuple as +// structurally valid. +const azureProbeSubscriptionID = "00000000-0000-0000-0000-000000000000" + +// azureProbeHourlyCommitment is the minimum non-zero hourly commitment used +// in ValidatePurchase probe requests. The value itself is irrelevant for +// structural validation (the API checks term + payment shape, not the dollar +// amount), but zero is rejected server-side so we use the documented minimum. +const azureProbeHourlyCommitment float64 = 0.001 + +// azureCandidateCombos lists every (term, billingPlan) pair that the Azure +// Savings Plans API publishes. The probe calls ValidatePurchase for each one +// and retains only those the API accepts as structurally valid. +// +// Azure SP payment model: +// - nil BillingPlan = full upfront ("all-upfront") +// - BillingPlanP1M = monthly installments ("monthly") +// +// Azure currently offers 1-year and 3-year terms for Compute SPs; P5Y is +// defined in the SDK constants but is not sold in practice. The probe +// includes P5Y and drops it if ValidatePurchase rejects it, so the persisted +// combos always reflect live API reality rather than the SDK enum. +var azureCandidateCombos = []struct { + termYears int + azureTerm armbillingbenefits.Term + azurePlan *armbillingbenefits.BillingPlan + paymentName string +}{ + {1, armbillingbenefits.TermP1Y, nil, "all-upfront"}, + {1, armbillingbenefits.TermP1Y, billingPlanP1M(), "monthly"}, + {3, armbillingbenefits.TermP3Y, nil, "all-upfront"}, + {3, armbillingbenefits.TermP3Y, billingPlanP1M(), "monthly"}, + {5, armbillingbenefits.TermP5Y, nil, "all-upfront"}, + {5, armbillingbenefits.TermP5Y, billingPlanP1M(), "monthly"}, +} + +// billingPlanP1M returns a pointer to BillingPlanP1M. Using a function avoids +// taking the address of an unaddressable constant. +func billingPlanP1M() *armbillingbenefits.BillingPlan { + p := armbillingbenefits.BillingPlanP1M + return &p +} + +// AzureSPValidateAPI is the minimal Azure Billing Benefits surface the probe +// needs. It matches the ValidatePurchase method on *armbillingbenefits.RPClient +// so tests can substitute a mock without importing the concrete SDK client. +// +// NOTE: providers/azure/services/savingsplans.RPValidateAPI defines the same +// one-method interface. The duplication is intentional: importing the provider +// package from internal/commitmentopts would create a circular dependency. The +// interface is tiny enough that duplicating it here is the cleanest option. +type AzureSPValidateAPI interface { + ValidatePurchase( + ctx context.Context, + body armbillingbenefits.SavingsPlanPurchaseValidateRequest, + options *armbillingbenefits.RPClientValidatePurchaseOptions, + ) (armbillingbenefits.RPClientValidatePurchaseResponse, error) +} + +// AzureSPProber probes the Azure Billing Benefits ValidatePurchase endpoint for +// each candidate (term, payment) combination and returns those the API accepts +// as live Combos. +// +// Design notes: +// - Azure has no "list offerings" catalog endpoint comparable to AWS +// Describe*Offerings. ValidatePurchase is the closest live signal that a +// given (term, billingPlan) configuration is currently accepted. +// - The probe uses a zero-valued (placeholder) subscription and the minimum +// non-zero hourly commitment so no real resource or billing impact occurs. +// - A 422/invalid response for a given combo means it is not available; any +// other error (network, auth, 5xx) is treated as a probe failure and +// bubbled up so the Service can apply its all-or-nothing policy. +type AzureSPProber struct { + // NewClient builds an AzureSPValidateAPI from a credential. Override + // in tests to return a mock. + NewClient func(cred azcore.TokenCredential) (AzureSPValidateAPI, error) +} + +// Service returns "savingsplans". +func (p *AzureSPProber) Service() string { return azureSpService } + +// ProbeAzure probes the Azure ValidatePurchase endpoint for each candidate +// (term, billingPlan) combo and returns Combos for those the API accepts. +// +// The method signature differs from the AWS Prober interface because Azure +// authentication uses azcore.TokenCredential rather than aws.Config. Callers +// use this method directly; Service.probeAndPersistAzure wires it up. +func (p *AzureSPProber) ProbeAzure(ctx context.Context, cred azcore.TokenCredential) ([]Combo, error) { + client, err := p.client(cred) + if err != nil { + return nil, fmt.Errorf("savingsplans: create validate client: %w", err) + } + + var combos []Combo + for _, c := range azureCandidateCombos { + ok, err := p.probeCombo(ctx, client, c.azureTerm, c.azurePlan) + if err != nil { + // Treat non-validation errors (auth, network, 5xx) as probe + // failures — they prevent us from knowing whether the combo + // is valid, so we must not silently drop it. + return nil, fmt.Errorf("savingsplans: probe %dy %s: %w", c.termYears, c.paymentName, err) + } + if ok { + combos = append(combos, Combo{ + Provider: azureSpProvider, + Service: azureSpService, + TermYears: c.termYears, + Payment: c.paymentName, + }) + } + } + return combos, nil +} + +// probeCombo calls ValidatePurchase for a single (term, billingPlan) pair and +// returns true if the API considers it a valid offering. +// +// A response where any benefit has Valid=false is treated as "not offered" +// (returns false, nil). Any other API error is returned as-is so the caller +// can decide whether it is a transient failure or a configuration problem. +func (p *AzureSPProber) probeCombo( + ctx context.Context, + client AzureSPValidateAPI, + term armbillingbenefits.Term, + billingPlan *armbillingbenefits.BillingPlan, +) (bool, error) { + subscriptionID := azureProbeSubscriptionID + billingScopeID := fmt.Sprintf("/subscriptions/%s", subscriptionID) + grain := armbillingbenefits.CommitmentGrainHourly + appliedScope := armbillingbenefits.AppliedScopeTypeShared + hourlyAmount := azureProbeHourlyCommitment + displayName := "cudly-probe" + currencyCode := "USD" + planType := "Compute" + + props := &armbillingbenefits.SavingsPlanOrderAliasProperties{ + DisplayName: &displayName, + BillingScopeID: &billingScopeID, + Term: &term, + AppliedScopeType: &appliedScope, + Commitment: &armbillingbenefits.Commitment{ + Amount: &hourlyAmount, + CurrencyCode: ¤cyCode, + Grain: &grain, + }, + } + if billingPlan != nil { + props.BillingPlan = billingPlan + } + + body := armbillingbenefits.SavingsPlanPurchaseValidateRequest{ + Benefits: []*armbillingbenefits.SavingsPlanOrderAliasModel{ + { + SKU: &armbillingbenefits.SKU{Name: &planType}, + Properties: props, + }, + }, + } + + resp, err := client.ValidatePurchase(ctx, body, nil) + if err != nil { + return false, err + } + + // The API returns a per-benefit valid flag. If all reported benefits are + // valid (or the slice is empty, which can't happen for a single-item + // request in practice), the combo is available. + for _, b := range resp.Benefits { + if b != nil && b.Valid != nil && !*b.Valid { + return false, nil + } + } + return true, nil +} + +func (p *AzureSPProber) client(cred azcore.TokenCredential) (AzureSPValidateAPI, error) { + if p.NewClient != nil { + return p.NewClient(cred) + } + c, err := armbillingbenefits.NewRPClient(cred, nil) + if err != nil { + return nil, err + } + return c, nil +} + +// DefaultAzureProbers returns one prober instance for the Azure Savings Plans +// service. The Service wires these up by default for Azure probe runs. +func DefaultAzureProbers() []*AzureSPProber { + return []*AzureSPProber{ + {}, + } +} diff --git a/internal/commitmentopts/probe_azure_test.go b/internal/commitmentopts/probe_azure_test.go new file mode 100644 index 000000000..2ba15f03e --- /dev/null +++ b/internal/commitmentopts/probe_azure_test.go @@ -0,0 +1,304 @@ +package commitmentopts + +import ( + "context" + "errors" + "sort" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAzureSPValidate is a test double for AzureSPValidateAPI. The fn +// field receives the full request body so tests can assert that the prober +// passes the expected term / billing plan fields. +type fakeAzureSPValidate struct { + fn func(body armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) +} + +func (f *fakeAzureSPValidate) ValidatePurchase( + _ context.Context, + body armbillingbenefits.SavingsPlanPurchaseValidateRequest, + _ *armbillingbenefits.RPClientValidatePurchaseOptions, +) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return f.fn(body) +} + +// validTrue returns an RPClientValidatePurchaseResponse with a single +// benefit whose Valid flag is true. +func validTrue() armbillingbenefits.RPClientValidatePurchaseResponse { + v := true + return armbillingbenefits.RPClientValidatePurchaseResponse{ + SavingsPlanValidateResponse: armbillingbenefits.SavingsPlanValidateResponse{ + Benefits: []*armbillingbenefits.SavingsPlanValidResponseProperty{ + {Valid: &v}, + }, + }, + } +} + +// validFalse returns a response indicating the offering is not available. +func validFalse(reason string) armbillingbenefits.RPClientValidatePurchaseResponse { + v := false + return armbillingbenefits.RPClientValidatePurchaseResponse{ + SavingsPlanValidateResponse: armbillingbenefits.SavingsPlanValidateResponse{ + Benefits: []*armbillingbenefits.SavingsPlanValidResponseProperty{ + {Valid: &v, Reason: &reason}, + }, + }, + } +} + +// newFakeProber wires a fakeAzureSPValidate into an AzureSPProber so tests +// never touch the real Azure RP. +func newFakeProber(fn func(armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error)) *AzureSPProber { + fake := &fakeAzureSPValidate{fn: fn} + return &AzureSPProber{ + NewClient: func(_ azcore.TokenCredential) (AzureSPValidateAPI, error) { + return fake, nil + }, + } +} + +// --------------------------------------------------------------------------- +// AzureSPProber.Service +// --------------------------------------------------------------------------- + +func TestAzureSPProber_Service(t *testing.T) { + p := &AzureSPProber{} + assert.Equal(t, "savingsplans", p.Service()) +} + +// --------------------------------------------------------------------------- +// AzureSPProber.ProbeAzure — happy paths +// --------------------------------------------------------------------------- + +func TestAzureSPProber_AllValid(t *testing.T) { + // All 6 candidate combos accepted — all 6 Combos returned. + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return validTrue(), nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + assert.Len(t, got, 6) + + // All must carry provider=azure, service=savingsplans. + for _, c := range got { + assert.Equal(t, "azure", c.Provider) + assert.Equal(t, "savingsplans", c.Service) + } +} + +func TestAzureSPProber_SomeValid(t *testing.T) { + // Only the 1yr and 3yr combos are accepted; P5Y combos are rejected. + // Simulates the live Azure state where P5Y SPs are not yet available. + p := newFakeProber(func(body armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + if len(body.Benefits) == 0 || body.Benefits[0] == nil { + return validFalse("no benefits"), nil + } + props := body.Benefits[0].Properties + if props == nil || props.Term == nil { + return validFalse("no term"), nil + } + if *props.Term == armbillingbenefits.TermP5Y { + return validFalse("P5Y not available"), nil + } + return validTrue(), nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + // 2 terms x 2 payment plans = 4 combos (P5Y x2 dropped) + assert.Len(t, got, 4) + + terms := make(map[int]int) + for _, c := range got { + terms[c.TermYears]++ + } + assert.Equal(t, 2, terms[1], "expected 2 combos for 1yr") + assert.Equal(t, 2, terms[3], "expected 2 combos for 3yr") + assert.Equal(t, 0, terms[5], "expected no combos for 5yr") +} + +func TestAzureSPProber_NoneValid(t *testing.T) { + // All combos rejected — empty slice, no error. + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return validFalse("not available"), nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestAzureSPProber_EmptyBenefitsSlice(t *testing.T) { + // An API response with an empty benefits slice counts as "valid" + // (no invalid flag returned) so the combo is included. + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return armbillingbenefits.RPClientValidatePurchaseResponse{}, nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + assert.Len(t, got, 6, "empty benefits slice treated as valid") +} + +func TestAzureSPProber_NilValidFlag(t *testing.T) { + // A benefit with a nil Valid pointer is not invalid — treated as valid. + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return armbillingbenefits.RPClientValidatePurchaseResponse{ + SavingsPlanValidateResponse: armbillingbenefits.SavingsPlanValidateResponse{ + Benefits: []*armbillingbenefits.SavingsPlanValidResponseProperty{ + {Valid: nil}, + }, + }, + }, nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + assert.Len(t, got, 6, "nil Valid flag treated as valid") +} + +// --------------------------------------------------------------------------- +// AzureSPProber.ProbeAzure — error handling +// --------------------------------------------------------------------------- + +func TestAzureSPProber_APIError_Propagates(t *testing.T) { + // Any non-validation error from ValidatePurchase must bubble up and + // abort the probe — we cannot distinguish "not offered" from + // "auth failed" without the response. + boom := errors.New("network failure") + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return armbillingbenefits.RPClientValidatePurchaseResponse{}, boom + }) + + _, err := p.ProbeAzure(context.Background(), nil) + require.Error(t, err) + assert.ErrorIs(t, err, boom) +} + +func TestAzureSPProber_APIError_FirstComboFails(t *testing.T) { + // If the first combo call fails, the prober must return an error + // immediately and not silently continue with the remaining combos. + calls := 0 + boom := errors.New("auth denied") + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + calls++ + return armbillingbenefits.RPClientValidatePurchaseResponse{}, boom + }) + + _, err := p.ProbeAzure(context.Background(), nil) + require.Error(t, err) + assert.Equal(t, 1, calls, "prober must stop after first error") +} + +func TestAzureSPProber_ClientBuildError(t *testing.T) { + // If NewClient itself fails (e.g. invalid credential format), the + // error must propagate before any ValidatePurchase call is made. + buildErr := errors.New("bad credential") + p := &AzureSPProber{ + NewClient: func(_ azcore.TokenCredential) (AzureSPValidateAPI, error) { + return nil, buildErr + }, + } + + _, err := p.ProbeAzure(context.Background(), nil) + require.Error(t, err) + assert.ErrorIs(t, err, buildErr) +} + +// --------------------------------------------------------------------------- +// Combo content and deduplication +// --------------------------------------------------------------------------- + +func TestAzureSPProber_ComboFields(t *testing.T) { + // Verify that the returned Combos carry the correct provider, service, + // termYears, and payment strings. + p := newFakeProber(func(_ armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + return validTrue(), nil + }) + + got, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + + sort.Slice(got, func(i, j int) bool { + if got[i].TermYears != got[j].TermYears { + return got[i].TermYears < got[j].TermYears + } + return got[i].Payment < got[j].Payment + }) + + want := []Combo{ + {Provider: "azure", Service: "savingsplans", TermYears: 1, Payment: "all-upfront"}, + {Provider: "azure", Service: "savingsplans", TermYears: 1, Payment: "monthly"}, + {Provider: "azure", Service: "savingsplans", TermYears: 3, Payment: "all-upfront"}, + {Provider: "azure", Service: "savingsplans", TermYears: 3, Payment: "monthly"}, + {Provider: "azure", Service: "savingsplans", TermYears: 5, Payment: "all-upfront"}, + {Provider: "azure", Service: "savingsplans", TermYears: 5, Payment: "monthly"}, + } + assert.Equal(t, want, got) +} + +func TestAzureSPProber_RequestContainsTerm(t *testing.T) { + // The probe request body must pass the correct term to ValidatePurchase + // so the API can evaluate it against the actual catalog. + seenTerms := make(map[armbillingbenefits.Term]int) + p := newFakeProber(func(body armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + require.Len(t, body.Benefits, 1) + require.NotNil(t, body.Benefits[0].Properties) + require.NotNil(t, body.Benefits[0].Properties.Term) + seenTerms[*body.Benefits[0].Properties.Term]++ + return validTrue(), nil + }) + + _, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + + assert.Equal(t, 2, seenTerms[armbillingbenefits.TermP1Y], "P1Y must appear twice (upfront + monthly)") + assert.Equal(t, 2, seenTerms[armbillingbenefits.TermP3Y], "P3Y must appear twice") + assert.Equal(t, 2, seenTerms[armbillingbenefits.TermP5Y], "P5Y must appear twice") +} + +func TestAzureSPProber_RequestBillingPlan(t *testing.T) { + // Upfront combos must omit BillingPlan (nil); monthly combos must + // set BillingPlan = P1M. This drives the Azure API to evaluate the + // correct payment schedule. + type planKey struct { + term armbillingbenefits.Term + plan string // "nil" or "P1M" + } + seen := make(map[planKey]int) + + p := newFakeProber(func(body armbillingbenefits.SavingsPlanPurchaseValidateRequest) (armbillingbenefits.RPClientValidatePurchaseResponse, error) { + props := body.Benefits[0].Properties + plan := "nil" + if props.BillingPlan != nil { + plan = string(*props.BillingPlan) + } + seen[planKey{*props.Term, plan}]++ + return validTrue(), nil + }) + + _, err := p.ProbeAzure(context.Background(), nil) + require.NoError(t, err) + + for _, term := range []armbillingbenefits.Term{armbillingbenefits.TermP1Y, armbillingbenefits.TermP3Y, armbillingbenefits.TermP5Y} { + assert.Equal(t, 1, seen[planKey{term, "nil"}], "upfront combo must have nil BillingPlan for %s", term) + assert.Equal(t, 1, seen[planKey{term, "P1M"}], "monthly combo must have P1M BillingPlan for %s", term) + } +} + +// --------------------------------------------------------------------------- +// DefaultAzureProbers +// --------------------------------------------------------------------------- + +func TestDefaultAzureProbers(t *testing.T) { + probers := DefaultAzureProbers() + require.Len(t, probers, 1) + assert.Equal(t, "savingsplans", probers[0].Service()) +}