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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

CUDly is an open source CLI for discovering and purchasing AWS Reserved Instances and Savings Plans in a single command. It is dry-run by default: nothing is purchased until you pass `--purchase`. `configure-azure` and `configure-gcp` bootstrap credentials for the separate [self-hosted platform](https://github.com/LeanerCloud/cloud-commitments-platform); this CLI's own recommend-and-purchase workflow is AWS-only today. See [cloud setup](docs/cli/cloud-setup.md).

It is also built to be driven by an AI agent for the discovery and analysis side: searching recommendations, sizing a plan, filtering by account or region. The purchase step still needs a human to review the numbers before committing money. **`--yes` currently skips the confirmation prompt outright, including for a non-interactive caller** (a script, a CI job, an agent driving the CLI as a subprocess): see [Safety Features](#safety-features) and [#1943](https://github.com/LeanerCloud/cloud-commitments-cli/issues/1943) before wiring `--purchase --yes` into anything unattended.
It is also built to be driven by an AI agent for the discovery and analysis side: searching recommendations, sizing a plan, filtering by account or region. Real purchases require `--purchase` and confirmation at an interactive terminal. The `--yes` bypass has been removed; scripts and agents should hand their dry-run recommendations to a human for review and purchase.

The CLI depends on the published shared Go modules in [cloud-commitments-go](https://github.com/LeanerCloud/cloud-commitments-go), pinned to fixed versions in `go.mod`. No sibling checkout or parent workspace is needed for local development.

Expand All @@ -17,7 +17,7 @@ The CLI depends on the published shared Go modules in [cloud-commitments-go](htt
## Safety Features

1. **Dry-run by default** - no purchase without the explicit `--purchase` flag.
2. **Confirmation prompt** - `--purchase` prints a summary of instance count and estimated savings, then prompts for confirmation. `--yes` skips this prompt, including for a non-interactive caller - it is not currently an automation boundary. [#1943](https://github.com/LeanerCloud/cloud-commitments-cli/issues/1943) tracks closing that gap.
2. **Confirmation prompt** - At an interactive terminal, `--purchase` prints the total instance count and estimated savings, then asks for confirmation once for the whole run. Nonterminal input is refused, including piped `yes`. Dry runs need no confirmation.
3. **Coverage and instance limits** - `--coverage`, `--target-coverage`, and `--max-instances` shape what a dry run recommends before there is anything to confirm.
4. **RDS extended-support filtering** - by default, recommendations for instances running an engine version in AWS Extended Support are excluded, since the surcharge can erase RI savings; pass `--include-extended-support` to include them.
5. **Audit log written per recommendation** - the audit log path is checked for writability before any cloud API call. Each recommendation then gets its own audit record: for a dry run, written as soon as its (local, no-API-call) result is generated; for a real purchase, written after that purchase call returns.
Expand Down
16 changes: 15 additions & 1 deletion cmd/effective_dry_run_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
package main

import "testing"
import (
"strings"
"testing"
)

func TestYesFlagRemoved(t *testing.T) {
if flag := rootCmd.Flags().Lookup("yes"); flag != nil {
t.Fatal("--yes must not bypass interactive purchase confirmation")
}
for _, arg := range []string{"--yes", "--yes=false"} {
if err := rootCmd.ParseFlags([]string{arg}); err == nil || !strings.Contains(err.Error(), "unknown flag: --yes") {
t.Errorf("%s must be rejected as an unknown flag, got %v", arg, err)
}
}
}

// TestEffectiveDryRun documents the single-flag purchase contract: a run is a
// dry run unless the user opts into real purchases with --purchase. This guards
Expand Down
13 changes: 3 additions & 10 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,17 +200,10 @@ func ApplyInstanceLimit(recs []common.Recommendation, maxInstances int32) []comm
return result
}

// ConfirmPurchase asks the user for confirmation before proceeding.
// totalSavings is the estimated monthly savings from the purchase (not the purchase cost),
// matching the EstimatedSavings column and the "Estimated monthly savings" summary.
// Returns false without prompting if stdin is not a TTY and skipConfirmation is false.
func ConfirmPurchase(totalInstances int, totalSavings float64, skipConfirmation bool) bool {
if skipConfirmation {
return true
}

// ConfirmPurchase requires terminal input; totalSavings is monthly savings, not purchase cost.
func ConfirmPurchase(totalInstances int, totalSavings float64) bool {
if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // G115: uintptr->int for file descriptor; FD values are always small positive integers
log.Printf("stdin is not a terminal and --yes was not set; skipping purchase")
log.Printf("stdin is not a terminal; interactive confirmation is required, skipping purchase")
return false
}

Expand Down
76 changes: 5 additions & 71 deletions cmd/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"math"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -655,76 +654,11 @@ func TestGetEngineFromRecommendation(t *testing.T) {
}
}

// confirmPurchaseWithInput is a testable variant of ConfirmPurchase that reads
// from the provided reader rather than os.Stdin, allowing stdin to be mocked in tests.
func confirmPurchaseWithInput(skipConfirmation bool, input string) bool {
if skipConfirmation {
return true
}
response := strings.TrimSpace(strings.ToLower(strings.SplitN(input, "\n", 2)[0]))
return response == "yes" || response == "y"
}

func TestConfirmPurchase(t *testing.T) {
tests := []struct {
name string
totalInstances int
totalCost float64
skipConfirmation bool
expected bool
}{
{
name: "Skip confirmation returns true",
totalInstances: 10,
totalCost: 100.50,
skipConfirmation: true,
expected: true,
},
{
name: "Skip confirmation with zero cost",
totalInstances: 0,
totalCost: 0.0,
skipConfirmation: true,
expected: true,
},
{
name: "Skip confirmation with high cost",
totalInstances: 1000,
totalCost: 999999.99,
skipConfirmation: true,
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConfirmPurchase(tt.totalInstances, tt.totalCost, tt.skipConfirmation)
assert.Equal(t, tt.expected, result)
})
}
}

func TestConfirmPurchaseInput(t *testing.T) {
// Tests for the interactive stdin branch of ConfirmPurchase logic
tests := []struct {
name string
input string
expected bool
}{
{name: "yes accepts", input: "yes\n", expected: true},
{name: "y accepts", input: "y\n", expected: true},
{name: "YES accepts (case insensitive)", input: "YES\n", expected: true},
{name: "Y accepts (case insensitive)", input: "Y\n", expected: true},
{name: "no rejects", input: "no\n", expected: false},
{name: "n rejects", input: "n\n", expected: false},
{name: "empty string rejects", input: "\n", expected: false},
{name: "arbitrary text rejects", input: "maybe\n", expected: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := confirmPurchaseWithInput(false, tt.input)
assert.Equal(t, tt.expected, result)
func TestConfirmPurchaseRejectsNonterminalInput(t *testing.T) {
for _, input := range []string{"yes\n", "y\n", "YES\n", "no\n", ""} {
t.Run(input, func(t *testing.T) {
setConfirmationStdin(t, input)
assert.False(t, ConfirmPurchase(5, 125))
})
}
}
Expand Down
2 changes: 0 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ type Config struct {
IncludeExtendedSupport bool
AllServices bool
ActualPurchase bool
SkipConfirmation bool
// RecLookbackPeriod controls the LookbackPeriodInDays passed to
// GetReservationPurchaseRecommendation. Valid values: "7d", "30d", "60d"
// (recommendations.DefaultRecLookbackPeriod is the shared default).
Expand Down Expand Up @@ -122,7 +121,6 @@ func init() {
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeEngines, "exclude-engines", []string{}, "Exclude these engines (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.IncludeAccounts, "include-accounts", []string{}, "Only include recommendations for these account names (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeAccounts, "exclude-accounts", []string{}, "Exclude recommendations for these account names (comma-separated)")
rootCmd.Flags().BoolVar(&toolCfg.SkipConfirmation, "yes", false, "Skip confirmation prompt for purchases (use with caution)")
rootCmd.Flags().Int32Var(&toolCfg.MaxInstances, "max-instances", 0, "Maximum total number of instances to purchase (0 = no limit)")
rootCmd.Flags().Int32Var(&toolCfg.OverrideCount, "override-count", 0, "Override recommendation count with fixed number for all selected RIs (0 = use recommendation or coverage)")
rootCmd.Flags().StringVar(&toolCfg.ValidationProfile, "validation-profile", "", "AWS profile to use for validating running instances (if different from main profile)")
Expand Down
8 changes: 4 additions & 4 deletions cmd/multi_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func runToolMultiService(ctx context.Context, cfg Config) {
// runToolMultiService within the cyclomatic-complexity limit.
func runPurchaseAndReport(ctx context.Context, awsCfg aws.Config, scoredResult scorer.ScoredResult, isDryRun bool, cfg Config, drops *common.DropSummary) {
runID := uuid.New().String()
if !confirmPurchaseRun(scoredResult.Passed, isDryRun, cfg) {
if !confirmPurchaseRun(scoredResult.Passed, isDryRun) {
printDropSummary(drops)
AppLogger.Printf("\n❌ Purchase canceled.\n")
return
Expand All @@ -204,12 +204,12 @@ func runPurchaseAndReport(ctx context.Context, awsCfg aws.Config, scoredResult s
// and the --input-csv path (runToolFromCSV) so both entry points show the
// operator the total they are actually authorizing and require exactly one
// confirmation per invocation.
func confirmPurchaseRun(recs []common.Recommendation, isDryRun bool, cfg Config) bool {
func confirmPurchaseRun(recs []common.Recommendation, isDryRun bool) bool {
if isDryRun {
return true
}
totalInstances, totalSavings := sumPassedRecs(recs)
return ConfirmPurchase(totalInstances, totalSavings, cfg.SkipConfirmation)
return ConfirmPurchase(totalInstances, totalSavings)
}

// writeReportAndSummary writes the CSV report and prints the final summary.
Expand Down Expand Up @@ -571,7 +571,7 @@ func prepareCSVPurchaseRun(ctx context.Context, cfg Config, csvModeCoverage floa
AppLogger.Println("⚠️ No recommendations to process after filtering")
return nil, aws.Config{}, "", nil
}
if !confirmPurchaseRun(recs, isDryRun, cfg) {
if !confirmPurchaseRun(recs, isDryRun) {
AppLogger.Printf("\n❌ Purchase canceled.\n")
return nil, aws.Config{}, "", nil
}
Expand Down
5 changes: 0 additions & 5 deletions cmd/multi_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,6 @@ func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) {

toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl")
toolCfg.Coverage = 80.0
toolCfg.SkipConfirmation = true

recs := []common.Recommendation{
{Service: common.ServiceRDS, ResourceType: "db.t3.large", Count: 1, EstimatedSavings: 500},
Expand Down Expand Up @@ -1315,7 +1314,6 @@ func TestProcessServicePurchasesUserCancellation(t *testing.T) {

toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl")
toolCfg.Coverage = 85.0
toolCfg.SkipConfirmation = true // Skip for testing

recs := []common.Recommendation{
{Service: common.ServiceElastiCache, ResourceType: "cache.r6g.large", Count: 2, EstimatedSavings: 200},
Expand Down Expand Up @@ -1459,7 +1457,6 @@ func TestProcessPurchaseLoopActualPurchase(t *testing.T) {

toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl")
toolCfg.Coverage = 80.0
toolCfg.SkipConfirmation = true // Skip confirmation for testing

recs := []common.Recommendation{
{Service: common.ServiceEC2, ResourceType: "t3.small", Count: 1, SourceRecommendation: "EC2 Test 1", EstimatedSavings: 100},
Expand Down Expand Up @@ -1978,7 +1975,6 @@ func TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase(t *testing.T) {
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl")
toolCfg.SkipConfirmation = true
t.Setenv("DISABLE_PURCHASE_DELAY", "true")

recs := []common.Recommendation{
Expand Down Expand Up @@ -2066,7 +2062,6 @@ rds,us-west-2,db.t3.medium,postgres,3,1yr,All Upfront,123456789012
toolCfg.CSVOutput = reportPath
toolCfg.AuditLog = auditPath
toolCfg.ActualPurchase = true
toolCfg.SkipConfirmation = false
toolCfg.Coverage = 100.0
toolCfg.TargetCoverage = 0
toolCfg.MaxInstances = 0
Expand Down
Loading
Loading