Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/audit_stop_2103_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func failAuditWrites(t *testing.T) {
func auditCfg(t *testing.T) Config {
t.Helper()
dir := t.TempDir()
return Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")}
return Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv"), CallerAccountID: defaultTestAccount}
}

func TestExecuteAndReport_AuditWriteFailureStopsFurtherPurchases(t *testing.T) {
Expand Down
10 changes: 5 additions & 5 deletions cmd/cancellation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func TestExecuteAndReport_CancelDuringPurchaseFinishesInFlightAndReportsWhatRan(
slowDelay(t)
out := captureAppLog(t)
dir := t.TempDir()
cfg := Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Expand Down Expand Up @@ -153,7 +153,7 @@ func TestExecuteAndReport_ErroredPurchaseOnInterruptSaysVerifyInAWS(t *testing.T
slowDelay(t)
out := captureAppLog(t)
dir := t.TempDir()
cfg := Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := &hookTransport{}
Expand All @@ -176,7 +176,7 @@ func TestExecuteAndReport_ErroredPurchaseOnInterruptSaysVerifyInAWS(t *testing.T
func TestProcessPurchaseLoop_AuditRecordWrittenBeforeDelay(t *testing.T) {
slowDelay(t)
captureAppLog(t)
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
recs := []common.Recommendation{validEC2Rec(), validEC2Rec()}
Expand Down Expand Up @@ -207,7 +207,7 @@ func TestProcessPurchaseLoop_AuditRecordWrittenBeforeDelay(t *testing.T) {
func TestExecutePurchasePipeline_AuditRecordWrittenBeforeDelay(t *testing.T) {
slowDelay(t)
captureAppLog(t)
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := &hookTransport{handle: func(r *http.Request) (int, string) {
Expand Down Expand Up @@ -283,7 +283,7 @@ func TestInvocationContext_FurtherSigintsAreAbsorbed(t *testing.T) {
func TestProcessPurchaseLoop_CancelDuringPurchaseDoesNotCancelTheInFlightCall(t *testing.T) {
slowDelay(t)
captureAppLog(t)
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
recs := []common.Recommendation{validEC2Rec(), validEC2Rec()}
Expand Down
2 changes: 1 addition & 1 deletion cmd/ec2_zonal_az_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func purchaseEC2WithRecorder(t *testing.T, rec common.Recommendation) (common.Pu
Credentials: aws.AnonymousCredentials{},
Retryer: func() aws.Retryer { return aws.NopRetryer{} },
}
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
result, _ := purchaseSingleRec(context.Background(), awsCfg, rec, 1, false, cfg)
return result, recorder
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const (

// Config holds all configuration for the RI helper tool.
type Config struct {
// CallerAccountID is the account the credentials belong to (STS
// GetCallerIdentity), set at run start. Purchases are refused for
// recommendations owned by any other account.
CallerAccountID string
AuditLog string
CSVInput string
IdempotencyWindow string
Expand Down
26 changes: 26 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ import (
"github.com/stretchr/testify/assert"
)

// defaultTestAccount is the account the stubbed credentials belong to.
const defaultTestAccount = "111111111111"

// wantPurchaseOutcome is the Success and Error report columns for a dry-run
// row owned by account: the stubbed credentials belong to defaultTestAccount,
// and rows of any other account are refused.
func wantPurchaseOutcome(account string) (success, errMsg string) {
if account == defaultTestAccount {
return "true", ""
}
return "false", fmt.Sprintf("recommendation is for account %s but the credentials are for account %s; cross-account purchases are not supported, so run with credentials for account %s", account, defaultTestAccount, account)
}

// useCallerAccount gives toolCfg the stubbed credentials' account for one test,
// for tests that call the purchase loops directly instead of through an entry
// point (which resolves the account itself).
func useCallerAccount(t *testing.T) {
t.Helper()
prev := toolCfg.CallerAccountID
toolCfg.CallerAccountID = defaultTestAccount
t.Cleanup(func() { toolCfg.CallerAccountID = prev })
}

// TestMain overrides toolCfg.AuditLog's cobra-registered default
// ("./cudly-audit.jsonl", relative to the process working directory) before
// any test in this package runs. Since #1609, processPurchaseLoop (shared by
Expand All @@ -31,6 +54,9 @@ func TestMain(m *testing.M) {
engineVersionFetcher = func(context.Context, Config, bool) (engineVersionData, error) {
return engineVersionData{}, nil
}
callerAccountFetcher = func(context.Context, aws.Config) (string, error) {
return defaultTestAccount, nil
}
code := m.Run()
_ = os.Remove(toolCfg.AuditLog)
os.Exit(code)
Expand Down
39 changes: 37 additions & 2 deletions cmd/multi_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/google/uuid"
)

Expand Down Expand Up @@ -239,6 +240,10 @@ func exitIfInterrupted(ctx context.Context, phase string) {
// runToolMultiService within the cyclomatic-complexity limit.
func runPurchaseAndReport(ctx context.Context, awsCfg aws.Config, scoredResult scorer.ScoredResult, isDryRun bool, cfg Config, drops *common.DropSummary) error {
runID := uuid.New().String()
cfg, err := withCallerAccount(ctx, awsCfg, cfg)
if err != nil {
return err
}
if !confirmPurchaseRun(scoredResult.Passed, isDryRun) {
printDropSummary(drops)
AppLogger.Printf("\n❌ Purchase canceled.\n")
Expand Down Expand Up @@ -303,6 +308,32 @@ func printDropSummary(drops *common.DropSummary) {
}
}

// callerAccountFetcher is the seam tests replace so they never reach STS.
var callerAccountFetcher = fetchCallerAccount

func fetchCallerAccount(ctx context.Context, awsCfg aws.Config) (string, error) {
out, err := sts.NewFromConfig(awsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
return "", err
}
if aws.ToString(out.Account) == "" {
return "", errors.New("GetCallerIdentity returned no account")
}
return aws.ToString(out.Account), nil
}

// withCallerAccount returns cfg with CallerAccountID resolved, so the purchase
// preconditions can refuse recommendations that belong to another account. It
// runs on dry runs too, so a dry run predicts what a real run will refuse.
func withCallerAccount(ctx context.Context, awsCfg aws.Config, cfg Config) (Config, error) {
account, err := callerAccountFetcher(ctx, awsCfg)
if err != nil {
return cfg, fmt.Errorf("cannot determine the AWS account of the credentials (needed to check purchases): %w", err)
}
cfg.CallerAccountID = account
return cfg, nil
}

// loadAWSConfig builds an aws.Config from the tool config.
func loadAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) {
var opts []func(*awsconfig.LoadOptions) error
Expand Down Expand Up @@ -579,7 +610,7 @@ func purchaseAuditStatus(result common.PurchaseResult) string {
func purchaseSingleRec(ctx context.Context, awsCfg aws.Config, rec common.Recommendation, index int, isDryRun bool, cfg Config) (purchaseResult common.PurchaseResult, auditStatus string) {
label := purchaseRegionLabel(rec, rec.Region)
AppLogger.Printf(" [%d] %s %s %s (count=%d)\n", index, rec.Service, label, rec.ResourceType, rec.Count)
if err := validatePurchasePreconditions(rec, rec.Region); err != nil {
if err := validatePurchasePreconditions(rec, rec.Region, cfg.CallerAccountID); err != nil {
AppLogger.Printf(" ❌ cannot purchase: %v\n", err)
return preconditionFailure(rec, err, isDryRun), "error"
}
Expand Down Expand Up @@ -722,6 +753,10 @@ func runToolFromCSV(ctx context.Context, cfg Config) error {
return nil
}

cfg, err = withCallerAccount(ctx, awsCfg, cfg)
if err != nil {
return err
}
return runCSVPurchases(ctx, awsCfg, recs, isDryRun, cfg, runID)
}

Expand Down Expand Up @@ -991,7 +1026,7 @@ func processPurchaseLoop(ctx context.Context, recs []common.Recommendation, regi
// runOnePurchase validates and then dry-runs or executes one CSV-path
// recommendation. attempted reports whether a real purchase call was made.
func runOnePurchase(ctx context.Context, rec common.Recommendation, label, region string, index int, isDryRun bool, serviceClient provider.ServiceClient, cfg Config) (result common.PurchaseResult, status string, attempted bool) {
if err := validatePurchasePreconditions(rec, region); err != nil {
if err := validatePurchasePreconditions(rec, region, cfg.CallerAccountID); err != nil {
return preconditionFailure(rec, err, isDryRun), "error", false
}
if isDryRun {
Expand Down
20 changes: 14 additions & 6 deletions cmd/multi_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func TestProcessService_EdgeCases(t *testing.T) {

// TestProcessServiceWithMocks tests the processService function using mocks.
func TestProcessServiceWithMocks(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
awsCfg := aws.Config{Region: "us-east-1"}

Expand Down Expand Up @@ -1258,6 +1259,7 @@ func TestWriteMultiServiceCSVReport_MultipleResults(t *testing.T) {
// ==================== Additional ProcessPurchaseLoop Tests ====================

func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
Expand Down Expand Up @@ -1308,6 +1310,7 @@ func TestProcessPurchaseLoopEmptyRecommendations(t *testing.T) {
}

func TestProcessServicePurchasesUserCancellation(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
Expand Down Expand Up @@ -1340,6 +1343,7 @@ func TestProcessServicePurchasesUserCancellation(t *testing.T) {
}

func TestProcessServicePurchasesDryRunMultiple(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
Expand Down Expand Up @@ -1412,6 +1416,7 @@ func TestExecutePurchaseWithEmptyPurchaseID(t *testing.T) {
}

func TestProcessPurchaseLoopDryRun(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
// Save original values
origCfg := toolCfg
Expand Down Expand Up @@ -1447,6 +1452,7 @@ func TestProcessPurchaseLoopDryRun(t *testing.T) {
}

func TestProcessPurchaseLoopActualPurchase(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
// Save original values
origCfg := toolCfg
Expand Down Expand Up @@ -1492,6 +1498,7 @@ func TestProcessPurchaseLoopActualPurchase(t *testing.T) {
}

func TestProcessPurchaseLoopWithConfirmation(t *testing.T) {
useCallerAccount(t)
ctx := context.Background()
// Save original values
origCfg := toolCfg
Expand Down Expand Up @@ -1693,8 +1700,8 @@ func TestRunToolFromCSV(t *testing.T) {
// silently ignored, so every row loaded with Count=0 and the whole run
// processed nothing while the NotPanics assertion stayed green.
csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
elasticache,us-west-2,cache.t3.micro,redis,1,1yr,All Upfront,123456789012
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111
elasticache,us-west-2,cache.t3.micro,redis,1,1yr,All Upfront,111111111111
`)

tests := []struct {
Expand Down Expand Up @@ -1761,7 +1768,7 @@ func TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase(t *testing.T) {
isolateAWSEnv(t)

csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111
`)
reportPath := filepath.Join(t.TempDir(), "report.csv")
auditPath := filepath.Join(t.TempDir(), "audit.jsonl")
Expand Down Expand Up @@ -1910,7 +1917,7 @@ func TestRunToolFromCSV_WritesAuditRecordsOnDryRun(t *testing.T) {
isolateAWSEnv(t)

csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111
`)
auditPath := filepath.Join(t.TempDir(), "audit.jsonl")

Expand Down Expand Up @@ -1952,6 +1959,7 @@ rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
// TestProcessPurchaseLoopActualPurchase already uses to test this loop's
// purchase behavior without live AWS credentials.
func TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase(t *testing.T) {
useCallerAccount(t)
tests := []struct {
result common.PurchaseResult
name string
Expand Down Expand Up @@ -2017,7 +2025,7 @@ func TestRunToolFromCSV_ChecksAuditLogWritability(t *testing.T) {
defer func() { toolCfg = origCfg }()

csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111
`)

toolCfg.CSVInput = csvPath
Expand Down Expand Up @@ -2052,7 +2060,7 @@ func TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion(t *testing.T) {
t.Cleanup(func() { log.SetOutput(oldWriter) })

csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012
rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111
rds,us-west-2,db.t3.medium,postgres,3,1yr,All Upfront,123456789012
`)
reportPath := filepath.Join(t.TempDir(), "report.csv")
Expand Down
92 changes: 92 additions & 0 deletions cmd/purchase_account_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"context"
"errors"
"path/filepath"
"testing"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

const (
testCallerAccount = "444444444444"
otherAccount = "555555555555"
)

func TestCheckPurchaseAccount(t *testing.T) {
rec := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", Account: testCallerAccount}

require.NoError(t, checkPurchaseAccount(rec, testCallerAccount))

rec.Account = ""
require.NoError(t, checkPurchaseAccount(rec, testCallerAccount), "a row with no account has nothing to compare")

rec.Account = otherAccount
err := checkPurchaseAccount(rec, testCallerAccount)
require.Error(t, err)
assert.Contains(t, err.Error(), otherAccount)
assert.Contains(t, err.Error(), testCallerAccount)

rec.Account = testCallerAccount
require.Error(t, checkPurchaseAccount(rec, ""), "an unresolved caller account must not disable the check")
}

func TestPurchaseSingleRec_RefusesOtherAccountsRecommendation(t *testing.T) {
captureAppLog(t)
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl"), CallerAccountID: testCallerAccount}
rec := validEC2Rec()
rec.Account = otherAccount

for _, dry := range []bool{true, false} {
// A real run must refuse before it builds a service client: aws.Config{}
// has no credentials, so reaching the purchase call would fail
// differently, not with the account message.
result, status := purchaseSingleRec(context.Background(), aws.Config{}, rec, 1, dry, cfg)
assert.False(t, result.Success, "dryRun=%v", dry)
require.Error(t, result.Error, "dryRun=%v", dry)
assert.Contains(t, result.Error.Error(), "cross-account purchases are not supported", "dryRun=%v", dry)
assert.Equal(t, "error", status)
}
}

func TestProcessPurchaseLoop_RefusesOtherAccountsRecommendationWithoutPurchasing(t *testing.T) {
captureAppLog(t)
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl"), CallerAccountID: testCallerAccount}
other := validEC2Rec()
other.Account = otherAccount
own := validEC2Rec()
own.Account = testCallerAccount
client := &MockServiceClient{}
client.On("PurchaseCommitment", mock.Anything, mock.MatchedBy(func(r common.Recommendation) bool { return r.Account == testCallerAccount }), mock.Anything).
Return(common.PurchaseResult{Success: true, CommitmentID: "ri-own"}, nil).Once()

results, err := processPurchaseLoop(context.Background(), []common.Recommendation{other, own}, "us-east-1", false, client, cfg, "run-1")

require.NoError(t, err)
require.Len(t, results, 2)
assert.False(t, results[0].Success)
require.Error(t, results[0].Error)
assert.Contains(t, results[0].Error.Error(), otherAccount)
assert.True(t, results[1].Success)
client.AssertNumberOfCalls(t, "PurchaseCommitment", 1)
}

func TestWithCallerAccount(t *testing.T) {
orig := callerAccountFetcher
t.Cleanup(func() { callerAccountFetcher = orig })

callerAccountFetcher = func(context.Context, aws.Config) (string, error) { return "444444444444", nil }
cfg, err := withCallerAccount(context.Background(), aws.Config{}, Config{})
require.NoError(t, err)
assert.Equal(t, "444444444444", cfg.CallerAccountID)

callerAccountFetcher = func(context.Context, aws.Config) (string, error) { return "", errors.New("expired token") }
_, err = withCallerAccount(context.Background(), aws.Config{}, Config{})
require.Error(t, err)
assert.Contains(t, err.Error(), "expired token")
}
Loading
Loading