From e3b7d13d43a82954563fecf61e4bf1f5d874f340 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 7 Jun 2026 13:12:34 -0700 Subject: [PATCH 1/8] docs(cli): add structured CLI reference hierarchy under docs/cli/ Adds four tracked markdown pages covering the full CLI surface: - docs/cli/README.md: index with a complete flag reference table for all ~35 root-command flags, grouped by concern - docs/cli/filtering.md: account/region/engine/instance-type/SP-type include-exclude filters plus threshold filters (min-count, min-savings-pct, max-break-even-months, min-pool-size, max-instances), with examples; explicitly calls out the --min-savings-pct percentage vs GUI min_savings dollar naming distinction (P3 trap from gap analysis) - docs/cli/purchase-safety.md: --dry-run/--purchase interaction, --audit-log, --idempotency-window, --rebuy-window-days, DISABLE_PURCHASE_DELAY (internal only), and a pre-production safety checklist - docs/cli/cloud-setup.md: configure-azure and configure-gcp subcommands, flags, guided vs non-interactive modes, and when to use vs SaaS onboarding Updates .gitignore to expose docs/cli/ as a tracked directory (docs/* glob replaces the previous docs/ directory ignore, preserving the ignore for docs/code-review/ and other scratch dirs while allowing docs/cli/ through). Links the new docs/cli/ index from README.md. Closes #1088 --- .gitignore | 6 +- README.md | 9 ++ docs/cli/README.md | 103 +++++++++++++++++ docs/cli/cloud-setup.md | 158 ++++++++++++++++++++++++++ docs/cli/filtering.md | 220 ++++++++++++++++++++++++++++++++++++ docs/cli/purchase-safety.md | 133 ++++++++++++++++++++++ 6 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 docs/cli/README.md create mode 100644 docs/cli/cloud-setup.md create mode 100644 docs/cli/filtering.md create mode 100644 docs/cli/purchase-safety.md diff --git a/.gitignore b/.gitignore index 13b4ae391..f3a667197 100644 --- a/.gitignore +++ b/.gitignore @@ -135,8 +135,10 @@ security-audit-swarm/ # ~/.claude/CLAUDE.md §2a (Tool Selection) for the full rationale. .claude/ -# Generated docs (security runbooks etc.) -docs/ +# Generated docs (security runbooks etc.) and scratch/review files. +# docs/cli/ is explicitly tracked; all other docs/ subdirectories are ignored. +docs/* +!docs/cli # Graphify knowledge graph output — regenerated locally, not committed graphify-out/ diff --git a/README.md b/README.md index 7b6881ce9..773345ed1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,15 @@ CUDly is a comprehensive CLI tool for managing cloud cost commitments across AWS, Azure, and GCP. It helps organizations optimize cloud spending by automating the discovery, analysis, and purchase of multiple Reserved Instances, Savings Plans, and Committed Use Discounts by running a single command. +## CLI Reference + +Full flag documentation, examples, and subcommand reference: [docs/cli/README.md](docs/cli/README.md) + +Topic pages: +- [Filtering](docs/cli/filtering.md) - account, region, engine, instance-type, SP-type, and threshold filters +- [Purchase Safety](docs/cli/purchase-safety.md) - dry-run, audit log, idempotency window, and guardrails +- [Cloud Setup](docs/cli/cloud-setup.md) - `configure-azure` and `configure-gcp` self-hosted credential bootstrap + ## Key Features - **Multi-Cloud Support** - Unified interface for AWS (production), Azure (experimental), and GCP (experimental) diff --git a/docs/cli/README.md b/docs/cli/README.md new file mode 100644 index 000000000..fa7690e06 --- /dev/null +++ b/docs/cli/README.md @@ -0,0 +1,103 @@ +# CUDly CLI Reference + +This section documents the full CLI surface of the `cudly` binary (shipped as `ri-helper` in some builds). Three cobra commands are available: + +- **root** (`cudly`) - the main RI/SP analysis and purchase command +- **configure-azure** - bootstrap Azure Service Principal credentials +- **configure-gcp** - bootstrap GCP Service Account credentials + +`rekey` and `server` are separate binaries with their own entry points and are not covered here. + +## Topic pages + +| Page | Covers | +|------|--------| +| [filtering.md](filtering.md) | Account, region, engine, instance-type, and Savings Plan type include/exclude filters; numeric threshold filters (min-count, min-savings-pct, max-break-even-months, min-pool-size, max-instances) | +| [purchase-safety.md](purchase-safety.md) | Purchase pipeline guardrails: dry-run, --purchase, --yes, --audit-log, --idempotency-window, --rebuy-window-days, DISABLE_PURCHASE_DELAY env | +| [cloud-setup.md](cloud-setup.md) | `configure-azure` and `configure-gcp` subcommands for self-hosted credential bootstrap | + +## Complete flag reference + +All flags belong to the root command unless noted otherwise. + +### Service selection + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--services` | `-s` | `rds` | Comma-separated list of services to process. Valid values: `rds`, `elasticache`, `ec2`, `opensearch`, `redshift`, `memorydb`, `savingsplans` (fans out to all four SP types), `savingsplans-compute`, `savingsplans-ec2instance`, `savingsplans-sagemaker`, `savingsplans-database`. The legacy alias `elasticsearch` maps to `opensearch`. | +| `--all-services` | | `false` | Process all supported services; equivalent to listing every service in `--services`. | + +### Coverage and sizing + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--coverage` | `-c` | `80` | Percentage (0-100) of each recommendation's instance count to purchase (`rec.Count * coverage/100`). Ignored when `--target-coverage` is also set. | +| `--target-coverage` | `-u` | `0` (disabled) | Target percentage (0-100) of historical average hourly usage to cover with commitments. Sizes each recommendation to `floor(avg * target/100)`, leaving the remainder on-demand. Overrides `--coverage` when non-zero. Pairs with `--rebuy-window-days` and `--min-pool-size` (see [filtering.md](filtering.md)). | +| `--override-count` | | `0` (disabled) | Replace every recommendation's count with this fixed number. Useful when testing a specific purchase size. | +| `--max-instances` | | `0` (no limit) | Hard cap on the total number of instances purchased across all recommendations. Applied after coverage scaling. See [filtering.md](filtering.md). | + +### Purchase terms + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--payment` | `-p` | `no-upfront` | Payment option: `all-upfront`, `partial-upfront`, or `no-upfront`. Note: AWS does not offer 3-year no-upfront RDS RIs; cudly warns and skips RDS if that combination is selected. | +| `--term` | `-t` | `3` | Commitment term in years. Must be `1` or `3`. | + +### Purchase execution and safety + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--dry-run` | | `true` | Show what would be purchased without buying. See [purchase-safety.md](purchase-safety.md) for the interaction with `--purchase`. | +| `--purchase` | | `false` | Execute real purchases. Must be combined with `--dry-run=false` (or not set, since `--purchase` alone sets `ActualPurchase=true` but `DryRun` stays `true` unless explicitly overridden). See [purchase-safety.md](purchase-safety.md). | +| `--yes` | | `false` | Skip the interactive confirmation prompt. Use with caution in automation. | +| `--audit-log` | | `./cudly-audit.jsonl` | Path to the JSONL audit log file. Written for every recommendation (dry-run and real). See [purchase-safety.md](purchase-safety.md). | +| `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Any commitment purchased within this window is subtracted from new recommendations. See [purchase-safety.md](purchase-safety.md). | + +### Recommendation quality filters + +| Flag | Default | Description | +|------|---------|-------------| +| `--min-savings-pct` | `0` (no filter) | Drop recommendations whose estimated savings percentage is below this threshold. This is a **percentage** (e.g. `10` = 10%), not a dollar amount. See [filtering.md](filtering.md) for the naming distinction from the GUI `min_savings` dollar filter. | +| `--max-break-even-months` | `0` (no filter) | Drop recommendations whose break-even period exceeds this many months. `0` disables the filter. See [filtering.md](filtering.md). | +| `--min-count` | `0` (no filter) | Drop recommendations for fewer than this many instances. `0` disables the filter. See [filtering.md](filtering.md). | + +### Scoping filters + +| Flag | Default | Description | +|------|---------|-------------| +| `--include-regions` | (all) | Only include recommendations for these AWS regions (comma-separated). Conflicts with any region also listed in `--exclude-regions`. | +| `--exclude-regions` | (none) | Exclude recommendations for these AWS regions (comma-separated). | +| `--include-instance-types` | (all) | Only include these instance types (e.g. `db.t3.micro,cache.t3.small`). Must contain a `.` separator; validated at startup. | +| `--exclude-instance-types` | (none) | Exclude these instance types. Same format as `--include-instance-types`. | +| `--include-engines` | (all) | Only include recommendations for these database engines (e.g. `redis,mysql,postgresql`). | +| `--exclude-engines` | (none) | Exclude recommendations for these database engines. | +| `--include-accounts` | (all) | Only include recommendations for these AWS account names (comma-separated). | +| `--exclude-accounts` | (none) | Exclude recommendations for these account names. | +| `--include-sp-types` | (all) | Only include these Savings Plan types: `Compute`, `EC2Instance`, `SageMaker`, `Database`. | +| `--exclude-sp-types` | (none) | Exclude these Savings Plan types. | +| `--include-extended-support` | `false` | By default, instances running engine versions in AWS Extended Support are excluded because Extended Support surcharges can erase RI savings. Pass this flag to include them. | + +### Target-coverage helpers + +| Flag | Default | Description | +|------|---------|-------------| +| `--rebuy-window-days` | `0` (disabled) | When set, treat existing RIs whose remaining term is at most this many days as already uncovered, so `--target-coverage` sizes replacements before they expire. `0` fully trusts existing coverage. See [purchase-safety.md](purchase-safety.md). | +| `--min-pool-size` | `0` (no filter) | When set, drop RI recommendations for pools whose `AverageInstancesUsedPerHour` is below this threshold. Prevents integer-arithmetic over-coverage on tiny pools (e.g. average=1 cannot approximate 80% coverage without effectively buying 100%). SPs and recommendations without a per-hour signal pass through unfiltered. See [filtering.md](filtering.md). | + +### Input / output + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--output` | `-o` | auto-generated | Output CSV file path. Auto-name format: `cudly-dryrun-YYYYMMDD-HHMMSS.csv` or `cudly-purchase-YYYYMMDD-HHMMSS.csv`. | +| `--input-csv` | `-i` | (none) | Read recommendations from an existing CSV instead of fetching from cloud APIs. The file must have a `.csv` extension and exist at the path given. | + +### Authentication + +| Flag | Default | Description | +|------|---------|-------------| +| `--profile` | (AWS default chain) | AWS profile to use for all API calls. Falls back to the standard SDK chain if not set. | +| `--validation-profile` | (same as `--profile`) | AWS profile to use specifically for querying running instances to validate engine versions. Required for extended-support filtering in multi-account orgs where the main profile may not have cross-account describe permissions. | + +### Subcommand flags + +See [cloud-setup.md](cloud-setup.md) for `configure-azure` and `configure-gcp` flags. diff --git a/docs/cli/cloud-setup.md b/docs/cli/cloud-setup.md new file mode 100644 index 000000000..1925eb5b2 --- /dev/null +++ b/docs/cli/cloud-setup.md @@ -0,0 +1,158 @@ +# Cloud Setup (Self-Hosted) + +CUDly's SaaS offering provides a guided onboarding UI for connecting Azure and GCP accounts. If you are running a self-hosted deployment, two CLI subcommands perform the same bootstrap: `configure-azure` and `configure-gcp`. Both store credentials in AWS Secrets Manager, where the CUDly server reads them at runtime. + +These are one-time setup operations, not part of the regular analysis/purchase workflow. + +## Prerequisites + +- An AWS profile with `secretsmanager:ListSecrets` and `secretsmanager:UpdateSecret` permissions on the secrets created by the CUDly CloudFormation stack. +- The target Secrets Manager secret must already exist (created by the CloudFormation stack). Both commands locate the secret by listing secrets with a name prefix (`-AzureCredentials` or `-GCPCredentials`) and updating the first match. + +## configure-azure + +Store Azure Service Principal credentials in Secrets Manager. + +``` +cudly configure-azure [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--stack-name` | `cudly` | CloudFormation stack name. Used to locate the Secrets Manager secret (`-AzureCredentials`). | +| `--profile` | (AWS default chain) | AWS profile to use when writing to Secrets Manager. | +| `--tenant-id` | | Azure AD Tenant ID (UUID format). | +| `--client-id` | | Azure Service Principal Client ID (UUID format). | +| `--client-secret` | | Azure Service Principal Client Secret. Read securely from stdin if omitted. | +| `--subscription-id` | | Azure Subscription ID (UUID format). | +| `--interactive` / `-i` | `false` | Prompt for all credential fields interactively, even if some are provided as flags. | +| `--skip-setup` | `false` | Skip the guided Azure CLI steps (az login, az account list, az ad sp create-for-rbac). Use when you already have a Service Principal and just want to store the credentials. | + +### What it does + +When `--skip-setup` is not set, the command runs an interactive guided flow: + +1. **`az login`** - opens a browser window for Azure authentication (can be skipped at the prompt). +2. **`az account list --output table`** - lists subscriptions so you can identify the Subscription ID. +3. **`az ad sp create-for-rbac --name CUDly --role "Reservations Administrator" --scopes /subscriptions/`** - creates a Service Principal with the correct role (can be skipped). + +After the guided steps (or immediately with `--skip-setup`), the command prompts for or accepts any missing credential fields and writes them as JSON to the `-AzureCredentials` secret. + +### Non-interactive usage + +```bash +# Provide all credentials as flags (--client-secret is read from a variable to avoid shell history) +AZURE_SECRET="$(cat /run/secrets/azure-client-secret)" +cudly configure-azure \ + --stack-name prod-cudly \ + --profile cudly-admin \ + --tenant-id 12345678-1234-1234-1234-123456789012 \ + --client-id 87654321-4321-4321-4321-210987654321 \ + --client-secret "$AZURE_SECRET" \ + --subscription-id aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb \ + --skip-setup +``` + +### Interactive usage + +```bash +# Let the command guide you through Azure CLI setup and credential collection +cudly configure-azure --stack-name prod-cudly --profile cudly-admin +``` + +### Required Azure permissions + +The Service Principal must have the **Reservations Administrator** role at the subscription scope. This is the minimum role needed to create reservation purchases. + +```bash +# Example: grant the role manually if the guided step was skipped +az role assignment create \ + --assignee "" \ + --role "Reservations Administrator" \ + --scope "/subscriptions/" +``` + +## configure-gcp + +Store GCP Service Account credentials in Secrets Manager. + +``` +cudly configure-gcp [flags] +``` + +### Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--stack-name` | | `cudly` | CloudFormation stack name. Used to locate the secret (`-GCPCredentials`). | +| `--profile` | | (AWS default chain) | AWS profile to use when writing to Secrets Manager. | +| `--credentials-file` | `-f` | | Path to a GCP Service Account JSON key file. Supports `~` expansion. | +| `--project-id` | | | GCP Project ID. Overrides the value embedded in the credentials file if set. | +| `--interactive` / `-i` | | `false` | Prompt for the credentials file path interactively. | +| `--skip-setup` | | `false` | Skip the guided gcloud steps (gcloud auth login, create service account, grant roles, create key). Use when you already have a JSON key file. | + +### What it does + +When `--skip-setup` is not set, the command runs an interactive guided flow: + +1. **`gcloud auth login`** - opens a browser window for GCP authentication. +2. **`gcloud projects list`** - lists projects so you can identify your Project ID. +3. **`gcloud config set project `** - sets the active project. +4. **Creates a `cudly-service-account` Service Account** with the display name "CUDly Service Account". +5. **`gcloud projects add-iam-policy-binding`** - grants `roles/compute.admin` to the new Service Account. +6. **`gcloud iam service-accounts keys create ~/cudly-gcp-key.json`** - downloads a JSON key to your home directory. + +After the guided steps (or with `--skip-setup --credentials-file `), the command reads and validates the JSON file and writes it to the `-GCPCredentials` secret. + +### Non-interactive usage + +```bash +# Store an existing key file +cudly configure-gcp \ + --stack-name prod-cudly \ + --profile cudly-admin \ + --credentials-file ~/cudly-gcp-key.json \ + --skip-setup +``` + +### Interactive usage + +```bash +# Let the command guide you through gcloud setup +cudly configure-gcp --stack-name prod-cudly --profile cudly-admin +``` + +### Required GCP permissions + +The Service Account needs the following roles: + +| Role | Purpose | +|------|---------| +| `roles/compute.admin` | Manage Compute Engine Committed Use Discounts | + +If you manage Cloud SQL or Memorystore commitments, you may need additional roles. Check the GCP documentation for the minimum required permissions per commitment type. + +### Credentials file format + +The command expects a standard GCP Service Account JSON key file (type `service_account`) with at minimum: + +```json +{ + "type": "service_account", + "project_id": "your-project-id", + "client_email": "cudly-service-account@your-project-id.iam.gserviceaccount.com", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\n..." +} +``` + +Any missing required field causes the command to exit with an error before writing to Secrets Manager. + +## When to use vs. SaaS onboarding + +| Scenario | Recommendation | +|----------|---------------| +| SaaS tenant | Use the Settings UI in the CUDly web app. It provides guided onboarding with validation and does not require local AWS credentials. | +| Self-hosted deployment | Use `configure-azure` / `configure-gcp` CLI commands. The CloudFormation stack must be deployed first; these commands only update existing secrets. | +| Rotating credentials | Both commands update (not create) the secret, so they can also be used to rotate credentials without redeploying the stack. | diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md new file mode 100644 index 000000000..f8e3107a2 --- /dev/null +++ b/docs/cli/filtering.md @@ -0,0 +1,220 @@ +# Filtering + +CUDly supports two categories of filters on the root command: + +1. **Scoping filters** - include/exclude subsets of recommendations by account, region, engine, instance type, or Savings Plan type. +2. **Threshold filters** - drop recommendations that do not meet minimum quality or size bars. + +All filters are evaluated before any purchase is attempted. Filtered-out recommendations are listed in the terminal output under an "Excluded" section, so you can verify that the right rows were dropped. + +## Scoping filters + +Scoping filters operate on include/exclude list pairs. An item that appears in both lists is rejected at startup with an error: + +> `region 'us-east-1' cannot be both included and excluded` + +### Account filters + +``` +--include-accounts Only include recommendations for these account names (comma-separated) +--exclude-accounts Exclude recommendations for these account names (comma-separated) +``` + +Account names are matched against the friendly alias resolved from AWS Organizations (`organizations:ListAccounts`). If the alias cannot be resolved, the account ID is used as a fallback. + +```bash +# Only process two accounts +cudly --services rds --include-accounts prod-account,staging-account + +# Skip the sandbox account +cudly --services rds --exclude-accounts sandbox +``` + +### Region filters + +``` +--include-regions Only include recommendations for these AWS regions (comma-separated) +--exclude-regions Exclude recommendations for these AWS regions (comma-separated) +``` + +When `--regions` is omitted, cudly auto-discovers regions from Cost Explorer recommendations. `--include-regions` and `--exclude-regions` are applied as a post-discovery filter on the returned recommendations, not as a discovery constraint. + +```bash +# Only US and EU recommendations +cudly --services ec2 --include-regions us-east-1,us-west-2,eu-west-1 + +# Exclude a region you're migrating away from +cudly --services rds --exclude-regions ap-southeast-1 +``` + +### Engine filters + +``` +--include-engines Only include these database engines (comma-separated) +--exclude-engines Exclude these database engines (comma-separated) +``` + +Engine names are matched against the engine field returned by the Cost Explorer recommendation (e.g. `mysql`, `postgresql`, `redis`, `memcached`, `aurora-postgresql`). Comparisons are case-insensitive. + +```bash +# Only Redis ElastiCache RIs +cudly --services elasticache --include-engines redis + +# Skip MySQL (planning to migrate) +cudly --services rds --exclude-engines mysql +``` + +### Instance-type filters + +``` +--include-instance-types Only include these instance types (comma-separated) +--exclude-instance-types Exclude these instance types (comma-separated) +``` + +Instance type strings must contain a `.` separator (e.g. `db.t3.micro`). cudly validates the format at startup. You can use partial prefixes for simple family exclusions. + +```bash +# Only modern graviton RDS instances +cudly --services rds --include-instance-types db.r8g.large,db.r8g.xlarge + +# Skip deprecated t2 instances +cudly --services rds,elasticache --exclude-instance-types db.t2.micro,db.t2.small,cache.t2.micro +``` + +### Savings Plan type filters + +``` +--include-sp-types Only include these Savings Plan types (comma-separated) +--exclude-sp-types Exclude these Savings Plan types (comma-separated) +``` + +Valid type values: `Compute`, `EC2Instance`, `SageMaker`, `Database`. + +These filters apply only when `--services` includes `savingsplans` (or a specific SP slug). They have no effect on RI-based services. + +```bash +# Compute and EC2Instance SPs only +cudly --services savingsplans --include-sp-types Compute,EC2Instance + +# All SPs except SageMaker +cudly --services savingsplans --exclude-sp-types SageMaker +``` + +### Extended support filter + +``` +--include-extended-support Include instances on Extended Support engine versions (default: excluded) +``` + +AWS Extended Support applies additional per-vCPU-hour charges to database engine versions past their standard end-of-life date (e.g. MySQL 5.7, PostgreSQL 11). By default cudly excludes recommendations for instances running these versions because the surcharges can eliminate or reverse the RI savings. + +When `--include-extended-support` is set, this exclusion is skipped and all instances are considered regardless of their engine version status. + +Extended-support detection requires querying running instances across your accounts. Use `--validation-profile` to supply an AWS profile with cross-account `rds:DescribeDBInstances` permissions if the main profile does not have them. + +```bash +# Include extended-support instances (you plan to upgrade before the RI term ends) +cudly --services rds --include-extended-support + +# Use a separate read-only profile for cross-account validation +cudly --services rds --validation-profile org-reader +``` + +## Threshold filters + +Threshold filters drop individual recommendations that fall below a quality or size bar. They are applied after scoping filters and before purchase. + +### --min-count + +``` +--min-count Minimum instance count (0 = no filter) +``` + +Drop recommendations where the adjusted instance count (after coverage scaling) is below this number. Useful for ignoring one-off or very small recommendations that are not worth the operational overhead. + +```bash +# Only purchase recommendations for 3 or more instances +cudly --services rds --min-count 3 +``` + +### --min-savings-pct + +``` +--min-savings-pct Minimum savings percentage (0 = no filter) +``` + +Drop recommendations whose estimated savings percentage falls below this threshold. + +**Important naming distinction:** `--min-savings-pct` is a **percentage** value (e.g. `10` means "at least 10% savings"). This is different from the GUI and API `min_savings` parameter, which filters by **dollar amount**. A value of `30` in `--min-savings-pct` means "30% savings", but `min_savings=30` in the API means "$30 in savings". Mixing up the two by copying a CLI value into the GUI filter (or vice versa) produces silent, incorrect filtering. + +```bash +# Only recommendations with at least 20% projected savings +cudly --services rds,elasticache --min-savings-pct 20 +``` + +### --max-break-even-months + +``` +--max-break-even-months Maximum break-even period in months (0 = no filter) +``` + +Drop recommendations where the break-even period exceeds this many months. A break-even period is the number of months until the upfront cost of the RI is recovered by the recurring discount. Recommendations without a computable break-even period (e.g. no-upfront) pass through unfiltered. + +```bash +# Only commitments that break even within 18 months +cudly --services ec2 --max-break-even-months 18 +``` + +### --max-instances + +``` +--max-instances Hard cap on total instances purchased (0 = no limit) +``` + +Cap the total number of instances purchased across all recommendations after coverage scaling. Applied as a final cut after all other filters. This is a safety net to prevent unexpectedly large batch purchases, not a primary sizing control. + +```bash +# Never purchase more than 100 instances in a single run +cudly --services rds --max-instances 100 +``` + +### --min-pool-size + +``` +--min-pool-size Minimum pool AverageInstancesUsedPerHour (0 = no filter) +``` + +When set, drop RI recommendations for pools whose `AverageInstancesUsedPerHour` (as reported by Cost Explorer) is below this threshold. This filter is specifically useful when using `--target-coverage` on small pools. + +**Why this matters with `--target-coverage`:** integer arithmetic forces over-coverage on tiny pools. For example, a pool with an average of 1 instance cannot approximate 80% target coverage - the only valid recommendation counts are 0 (0% coverage) or 1 (100% coverage). Skipping these pools avoids systematically over-buying on pools where the target cannot be approximated. + +SPs and any recommendations that do not carry a per-hour usage signal pass through this filter unaffected. + +```bash +# Skip pools smaller than 5 average instances/hour when using target-coverage +cudly --services rds \ + --target-coverage 80 \ + --min-pool-size 5 +``` + +## Combining filters + +All filters compose with AND semantics: a recommendation must pass every active filter to be included. + +```bash +# Conservative multi-filter example: +# - Only US regions +# - Only modern instance families +# - Minimum 5-instance pools +# - At least 15% savings +# - Break-even within 24 months +# - Cap at 200 total instances +cudly --services rds \ + --include-regions us-east-1,us-west-2 \ + --exclude-instance-types db.t2.micro,db.t2.small \ + --min-pool-size 5 \ + --min-savings-pct 15 \ + --max-break-even-months 24 \ + --max-instances 200 \ + --target-coverage 80 +``` diff --git a/docs/cli/purchase-safety.md b/docs/cli/purchase-safety.md new file mode 100644 index 000000000..979a6d0e1 --- /dev/null +++ b/docs/cli/purchase-safety.md @@ -0,0 +1,133 @@ +# Purchase Safety + +CUDly is designed to be safe by default. Real purchases require multiple explicit opt-ins, and several mechanisms prevent duplicate or unintended buys. + +## The purchase decision: --dry-run and --purchase + +``` +--dry-run bool default: true +--purchase bool default: false +``` + +The actual purchase decision is computed as: + +``` +isDryRun = !ActualPurchase || DryRun +``` + +In plain terms: + +| `--purchase` | `--dry-run` | Result | +|---|---|---| +| (not set / false) | (not set / true) | Dry run - nothing purchased | +| `true` | (not set / true) | **Dry run** - `DryRun` default overrides `--purchase` | +| `true` | `false` | Real purchases | +| (not set / false) | `false` | Dry run - `ActualPurchase` is false, so `isDryRun` stays true | + +The footgun: passing `--purchase` alone is not sufficient. You must also pass `--dry-run=false` (or explicitly `--no-dry-run` in shells that support that form). This is intentional - two flags must be flipped to move money. + +```bash +# Correct way to execute real purchases: +cudly --services rds --purchase --dry-run=false + +# Still a dry run (--dry-run defaults to true): +cudly --services rds --purchase +``` + +## Confirmation prompt: --yes + +``` +--yes bool default: false +``` + +When running in purchase mode (`isDryRun=false`), cudly prints a summary of the total instance count and estimated savings and prompts for confirmation before executing any purchase. Pass `--yes` to skip this prompt in automation. + +```bash +# Unattended purchase (use with care): +cudly --services rds --purchase --dry-run=false --yes +``` + +## Audit log: --audit-log + +``` +--audit-log string default: ./cudly-audit.jsonl +``` + +Every recommendation - whether purchased or dry-run - is written as a JSON line to the audit log file before any purchase API call is made. The audit record includes: + +- Run ID (UUID that groups all purchases in a single invocation) +- Recommendation details (service, region, instance type, count, term, payment) +- Purchase result (success/failure, commitment ID, error message) +- Audit status (`skipped` for dry-run, `success` or `error` for real purchases) +- Whether the run was a dry run +- Purchase source (`cli`) + +cudly verifies that the audit log path is writable before making any cloud API calls. If it is not writable (e.g. the directory does not exist), the command exits immediately with an error. + +```bash +# Write audit records to a shared directory +cudly --services rds --purchase --dry-run=false \ + --audit-log /var/log/cudly/audit.jsonl +``` + +The default path (`./cudly-audit.jsonl`) writes to the current working directory. In production deployments, redirect this to a durable, monitored location. + +## Duplicate purchase prevention: --idempotency-window + +``` +--idempotency-window string default: 24h +``` + +Before purchasing a recommendation, cudly checks whether an equivalent commitment was purchased within the idempotency window. If a match is found, the recommendation count is reduced by the already-purchased count. This prevents double-buying when: + +- The tool is run multiple times in quick succession +- A partial batch succeeds and the run is retried + +The window is expressed as a Go duration string (e.g. `24h`, `48h`, `1h30m`). + +```bash +# Extend the window for long-running multi-account deployments +cudly --services rds --idempotency-window 72h +``` + +## Pre-expiry rebuy: --rebuy-window-days + +``` +--rebuy-window-days int default: 0 (disabled) +``` + +When using `--target-coverage`, cudly subtracts existing RI coverage from the sizing calculation so it only recommends incremental purchases. By default this subtraction treats all existing RIs as fully covering demand regardless of when they expire. + +Setting `--rebuy-window-days` changes that behavior: any existing RI whose remaining term is at most this many days is treated as if it has already expired, so `--target-coverage` sizes a replacement recommendation before it actually lapses. This is useful to avoid the coverage gap that would otherwise appear between an RI expiring and a new one taking effect. + +```bash +# Recommend replacements for RIs expiring within the next 60 days +cudly --services rds \ + --target-coverage 80 \ + --rebuy-window-days 60 +``` + +`--rebuy-window-days` has no effect when `--target-coverage` is not set. + +## Between-purchase delay + +cudly inserts a short delay between consecutive purchases to respect AWS API rate limits. This delay is hardcoded (a few seconds) and cannot be configured via a user-facing flag. + +### DISABLE_PURCHASE_DELAY (internal/test only) + +``` +DISABLE_PURCHASE_DELAY=true env var default: unset +``` + +Setting this environment variable skips the between-purchase delay. This is an internal knob used in integration test environments where throughput matters and rate limiting is not a concern. It is not intended for production use. Do not set this in production deployments. + +## Safety checklist for production runs + +Before any real purchase run: + +1. Run without `--purchase` first to review the dry-run CSV output. +2. Check that `--audit-log` points to a writable, durable location. +3. If using `--target-coverage`, verify `--rebuy-window-days` is set appropriately for your RI renewal cadence. +4. Narrow the scope with `--include-regions`, `--include-accounts`, or `--min-savings-pct` before buying across all services. +5. Consider `--max-instances` as a final safety cap for a first run. +6. Use `--idempotency-window` consistent with your run frequency to avoid double-buying on retries. From 6562777da8300747ae96e48d02b881282330f0db Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 7 Jun 2026 14:23:28 -0700 Subject: [PATCH 2/8] docs(cli): specify code-fence languages and redact example private key Add language tags to all 20 bare fenced code blocks in docs/cli/*.md (MD040) and replace the illustrative RSA private key marker in cloud-setup.md with a placeholder to pass detect-private-key. --- README.md | 1 + docs/cli/cloud-setup.md | 6 +++--- docs/cli/filtering.md | 22 +++++++++++----------- docs/cli/purchase-safety.md | 14 +++++++------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 773345ed1..88e9c17a8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ CUDly is a comprehensive CLI tool for managing cloud cost commitments across AWS Full flag documentation, examples, and subcommand reference: [docs/cli/README.md](docs/cli/README.md) Topic pages: + - [Filtering](docs/cli/filtering.md) - account, region, engine, instance-type, SP-type, and threshold filters - [Purchase Safety](docs/cli/purchase-safety.md) - dry-run, audit log, idempotency window, and guardrails - [Cloud Setup](docs/cli/cloud-setup.md) - `configure-azure` and `configure-gcp` self-hosted credential bootstrap diff --git a/docs/cli/cloud-setup.md b/docs/cli/cloud-setup.md index 1925eb5b2..08d7d71c6 100644 --- a/docs/cli/cloud-setup.md +++ b/docs/cli/cloud-setup.md @@ -13,7 +13,7 @@ These are one-time setup operations, not part of the regular analysis/purchase w Store Azure Service Principal credentials in Secrets Manager. -``` +```text cudly configure-azure [flags] ``` @@ -78,7 +78,7 @@ az role assignment create \ Store GCP Service Account credentials in Secrets Manager. -``` +```text cudly configure-gcp [flags] ``` @@ -143,7 +143,7 @@ The command expects a standard GCP Service Account JSON key file (type `service_ "type": "service_account", "project_id": "your-project-id", "client_email": "cudly-service-account@your-project-id.iam.gserviceaccount.com", - "private_key": "-----BEGIN RSA PRIVATE KEY-----\n..." + "private_key": "" } ``` diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index f8e3107a2..eb1af54d7 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -15,7 +15,7 @@ Scoping filters operate on include/exclude list pairs. An item that appears in b ### Account filters -``` +```text --include-accounts Only include recommendations for these account names (comma-separated) --exclude-accounts Exclude recommendations for these account names (comma-separated) ``` @@ -32,7 +32,7 @@ cudly --services rds --exclude-accounts sandbox ### Region filters -``` +```text --include-regions Only include recommendations for these AWS regions (comma-separated) --exclude-regions Exclude recommendations for these AWS regions (comma-separated) ``` @@ -49,7 +49,7 @@ cudly --services rds --exclude-regions ap-southeast-1 ### Engine filters -``` +```text --include-engines Only include these database engines (comma-separated) --exclude-engines Exclude these database engines (comma-separated) ``` @@ -66,7 +66,7 @@ cudly --services rds --exclude-engines mysql ### Instance-type filters -``` +```text --include-instance-types Only include these instance types (comma-separated) --exclude-instance-types Exclude these instance types (comma-separated) ``` @@ -83,7 +83,7 @@ cudly --services rds,elasticache --exclude-instance-types db.t2.micro,db.t2.smal ### Savings Plan type filters -``` +```text --include-sp-types Only include these Savings Plan types (comma-separated) --exclude-sp-types Exclude these Savings Plan types (comma-separated) ``` @@ -102,7 +102,7 @@ cudly --services savingsplans --exclude-sp-types SageMaker ### Extended support filter -``` +```text --include-extended-support Include instances on Extended Support engine versions (default: excluded) ``` @@ -126,7 +126,7 @@ Threshold filters drop individual recommendations that fall below a quality or s ### --min-count -``` +```text --min-count Minimum instance count (0 = no filter) ``` @@ -139,7 +139,7 @@ cudly --services rds --min-count 3 ### --min-savings-pct -``` +```text --min-savings-pct Minimum savings percentage (0 = no filter) ``` @@ -154,7 +154,7 @@ cudly --services rds,elasticache --min-savings-pct 20 ### --max-break-even-months -``` +```text --max-break-even-months Maximum break-even period in months (0 = no filter) ``` @@ -167,7 +167,7 @@ cudly --services ec2 --max-break-even-months 18 ### --max-instances -``` +```text --max-instances Hard cap on total instances purchased (0 = no limit) ``` @@ -180,7 +180,7 @@ cudly --services rds --max-instances 100 ### --min-pool-size -``` +```text --min-pool-size Minimum pool AverageInstancesUsedPerHour (0 = no filter) ``` diff --git a/docs/cli/purchase-safety.md b/docs/cli/purchase-safety.md index 979a6d0e1..ede86060e 100644 --- a/docs/cli/purchase-safety.md +++ b/docs/cli/purchase-safety.md @@ -4,14 +4,14 @@ CUDly is designed to be safe by default. Real purchases require multiple explici ## The purchase decision: --dry-run and --purchase -``` +```text --dry-run bool default: true --purchase bool default: false ``` The actual purchase decision is computed as: -``` +```text isDryRun = !ActualPurchase || DryRun ``` @@ -36,7 +36,7 @@ cudly --services rds --purchase ## Confirmation prompt: --yes -``` +```text --yes bool default: false ``` @@ -49,7 +49,7 @@ cudly --services rds --purchase --dry-run=false --yes ## Audit log: --audit-log -``` +```text --audit-log string default: ./cudly-audit.jsonl ``` @@ -74,7 +74,7 @@ The default path (`./cudly-audit.jsonl`) writes to the current working directory ## Duplicate purchase prevention: --idempotency-window -``` +```text --idempotency-window string default: 24h ``` @@ -92,7 +92,7 @@ cudly --services rds --idempotency-window 72h ## Pre-expiry rebuy: --rebuy-window-days -``` +```text --rebuy-window-days int default: 0 (disabled) ``` @@ -115,7 +115,7 @@ cudly inserts a short delay between consecutive purchases to respect AWS API rat ### DISABLE_PURCHASE_DELAY (internal/test only) -``` +```text DISABLE_PURCHASE_DELAY=true env var default: unset ``` From 361cb0ae24b3d1e1751e46247b8d3ca6a37a7864 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 8 Jun 2026 15:23:41 -0700 Subject: [PATCH 3/8] docs(cli): document --regions and --coverage-lookback-days root flags The "Complete flag reference" claimed all ~35 root flags but omitted two real cmd/main.go flags: --regions (-r), the primary region scoping flag (referenced in filtering.md but never tabulated), and --coverage-lookback-days (default 30), which directly tunes the --target-coverage sizing path the docs cover in depth. Add both to the flag reference so the table matches the binary. --- docs/cli/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cli/README.md b/docs/cli/README.md index fa7690e06..8d1e1ab9c 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -26,6 +26,7 @@ All flags belong to the root command unless noted otherwise. |------|-------|---------|-------------| | `--services` | `-s` | `rds` | Comma-separated list of services to process. Valid values: `rds`, `elasticache`, `ec2`, `opensearch`, `redshift`, `memorydb`, `savingsplans` (fans out to all four SP types), `savingsplans-compute`, `savingsplans-ec2instance`, `savingsplans-sagemaker`, `savingsplans-database`. The legacy alias `elasticsearch` maps to `opensearch`. | | `--all-services` | | `false` | Process all supported services; equivalent to listing every service in `--services`. | +| `--regions` | `-r` | (auto-discover) | AWS regions to process (comma-separated or repeated). When empty, cudly auto-discovers regions from Cost Explorer recommendations. The `--include-regions` / `--exclude-regions` scoping filters are applied on top of the resulting set (see [filtering.md](filtering.md)). | ### Coverage and sizing @@ -33,6 +34,7 @@ All flags belong to the root command unless noted otherwise. |------|-------|---------|-------------| | `--coverage` | `-c` | `80` | Percentage (0-100) of each recommendation's instance count to purchase (`rec.Count * coverage/100`). Ignored when `--target-coverage` is also set. | | `--target-coverage` | `-u` | `0` (disabled) | Target percentage (0-100) of historical average hourly usage to cover with commitments. Sizes each recommendation to `floor(avg * target/100)`, leaving the remainder on-demand. Overrides `--coverage` when non-zero. Pairs with `--rebuy-window-days` and `--min-pool-size` (see [filtering.md](filtering.md)). | +| `--coverage-lookback-days` | | `30` | Calendar days of historical demand fed to `GetReservationCoverage` when computing the existing-RI coverage map for `--target-coverage` sizing. Match this to your AWS console coverage report window to reconcile cudly's `ExistingCoverage` column against the console export. Only affects `--target-coverage`. | | `--override-count` | | `0` (disabled) | Replace every recommendation's count with this fixed number. Useful when testing a specific purchase size. | | `--max-instances` | | `0` (no limit) | Hard cap on the total number of instances purchased across all recommendations. Applied after coverage scaling. See [filtering.md](filtering.md). | From 88343f1520afafd51d055f6cebc85d414ad73586 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 9 Jun 2026 07:00:42 -0700 Subject: [PATCH 4/8] docs(cli/cloud-setup): correct SaaS/CloudFormation misstatements; add deployment overview - Remove false claim of a "SaaS offering" and "Settings UI": CUDly is self-hosted only; there is no managed/hosted tier. - Replace all "CloudFormation stack" references with "Terraform deployment": the project deploys via Terraform (terraform/environments/ aws|gcp|azure) driven by GitHub Actions workflows, not CloudFormation. - Rewrite --stack-name flag description in both configure-azure and configure-gcp tables to describe what it actually does (naming prefix for Secrets Manager secret lookup). - Add "Deployment overview" section documenting the three Terraform environments (AWS Lambda, GCP Cloud Run, Azure Container Apps) and the ci-cd-permissions bootstrap/runtime IAM split. - Replace fabricated "When to use vs. SaaS onboarding" table with a minimal "Usage notes" table covering first-time setup and rotation. --- docs/cli/cloud-setup.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/cli/cloud-setup.md b/docs/cli/cloud-setup.md index 08d7d71c6..945bb0bbf 100644 --- a/docs/cli/cloud-setup.md +++ b/docs/cli/cloud-setup.md @@ -1,13 +1,25 @@ # Cloud Setup (Self-Hosted) -CUDly's SaaS offering provides a guided onboarding UI for connecting Azure and GCP accounts. If you are running a self-hosted deployment, two CLI subcommands perform the same bootstrap: `configure-azure` and `configure-gcp`. Both store credentials in AWS Secrets Manager, where the CUDly server reads them at runtime. +CUDly is a self-hosted tool. Two CLI subcommands handle the one-time credential bootstrap for Azure and GCP: `configure-azure` and `configure-gcp`. Both write credentials to AWS Secrets Manager, where the CUDly server reads them at runtime. These are one-time setup operations, not part of the regular analysis/purchase workflow. +## Deployment overview + +CUDly is deployed via Terraform into your own cloud account. Three target platforms are supported: + +| Cloud | Compute | Terraform environment | +|-------|---------|----------------------| +| AWS | Lambda (Function URL) | `terraform/environments/aws/` | +| GCP | Cloud Run | `terraform/environments/gcp/` | +| Azure | Container Apps | `terraform/environments/azure/` | + +The `ci-cd-permissions/` module within each environment provisions the CI/CD deploy identity and is applied once, manually, by a privileged operator. The main deploy workflow (`.github/workflows/deploy-*.yml`) then runs `terraform init / plan / apply` against the environment directory using OIDC keyless authentication. + ## Prerequisites -- An AWS profile with `secretsmanager:ListSecrets` and `secretsmanager:UpdateSecret` permissions on the secrets created by the CUDly CloudFormation stack. -- The target Secrets Manager secret must already exist (created by the CloudFormation stack). Both commands locate the secret by listing secrets with a name prefix (`-AzureCredentials` or `-GCPCredentials`) and updating the first match. +- An AWS profile with `secretsmanager:ListSecrets` and `secretsmanager:UpdateSecret` permissions on the secrets created by the Terraform deployment. +- The target Secrets Manager secret must already exist (created by the Terraform deployment). Both commands locate the secret by listing secrets with a name prefix (`-AzureCredentials` or `-GCPCredentials`) and updating the first match. ## configure-azure @@ -21,7 +33,7 @@ cudly configure-azure [flags] | Flag | Default | Description | |------|---------|-------------| -| `--stack-name` | `cudly` | CloudFormation stack name. Used to locate the Secrets Manager secret (`-AzureCredentials`). | +| `--stack-name` | `cudly` | Deployment stack name prefix. Used to locate the Secrets Manager secret (`-AzureCredentials`). Must match the name used when the Terraform deployment created the secret. | | `--profile` | (AWS default chain) | AWS profile to use when writing to Secrets Manager. | | `--tenant-id` | | Azure AD Tenant ID (UUID format). | | `--client-id` | | Azure Service Principal Client ID (UUID format). | @@ -86,7 +98,7 @@ cudly configure-gcp [flags] | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--stack-name` | | `cudly` | CloudFormation stack name. Used to locate the secret (`-GCPCredentials`). | +| `--stack-name` | | `cudly` | Deployment stack name prefix. Used to locate the secret (`-GCPCredentials`). Must match the name used when the Terraform deployment created the secret. | | `--profile` | | (AWS default chain) | AWS profile to use when writing to Secrets Manager. | | `--credentials-file` | `-f` | | Path to a GCP Service Account JSON key file. Supports `~` expansion. | | `--project-id` | | | GCP Project ID. Overrides the value embedded in the credentials file if set. | @@ -149,10 +161,9 @@ The command expects a standard GCP Service Account JSON key file (type `service_ Any missing required field causes the command to exit with an error before writing to Secrets Manager. -## When to use vs. SaaS onboarding +## Usage notes -| Scenario | Recommendation | -|----------|---------------| -| SaaS tenant | Use the Settings UI in the CUDly web app. It provides guided onboarding with validation and does not require local AWS credentials. | -| Self-hosted deployment | Use `configure-azure` / `configure-gcp` CLI commands. The CloudFormation stack must be deployed first; these commands only update existing secrets. | -| Rotating credentials | Both commands update (not create) the secret, so they can also be used to rotate credentials without redeploying the stack. | +| Scenario | Notes | +|----------|-------| +| First-time setup | Deploy with Terraform first (`terraform/environments//`). The secrets are created as part of that deployment; these commands only update them. | +| Rotating credentials | Both commands update (not create) the secret, so they can be run again to rotate credentials without redeploying. | From eb6bd50306c09c21d8ee85ee0048436941ff27d9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 9 Jun 2026 10:42:31 -0700 Subject: [PATCH 5/8] docs(cli): deduplicate against main README, link instead of repeat cloud-setup.md "Deployment overview" had a near-verbatim copy of the runtime/Terraform table that now lives in README.md under "Deployment (self-hosted via Terraform)". Replace the duplicated table with a short forward-reference link to the README section; keep the CI/CD bootstrap note that is not in the README. No other material duplication was found: the CLI docs carry genuinely deeper detail (flag-by-flag reference with defaults and edge cases, configure-azure/configure-gcp subcommand flags, filtering semantics, purchase-safety mechanics) that the README does not cover. --- docs/cli/cloud-setup.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/cli/cloud-setup.md b/docs/cli/cloud-setup.md index 945bb0bbf..2d657912a 100644 --- a/docs/cli/cloud-setup.md +++ b/docs/cli/cloud-setup.md @@ -6,13 +6,7 @@ These are one-time setup operations, not part of the regular analysis/purchase w ## Deployment overview -CUDly is deployed via Terraform into your own cloud account. Three target platforms are supported: - -| Cloud | Compute | Terraform environment | -|-------|---------|----------------------| -| AWS | Lambda (Function URL) | `terraform/environments/aws/` | -| GCP | Cloud Run | `terraform/environments/gcp/` | -| Azure | Container Apps | `terraform/environments/azure/` | +For the full Terraform deployment guide (supported runtimes, quick-deploy script, manual steps, `tfvars` reference, accessing the dashboard after deploy), see the [Deployment (self-hosted via Terraform)](../../README.md#deployment-self-hosted-via-terraform) section in the README. The `ci-cd-permissions/` module within each environment provisions the CI/CD deploy identity and is applied once, manually, by a privileged operator. The main deploy workflow (`.github/workflows/deploy-*.yml`) then runs `terraform init / plan / apply` against the environment directory using OIDC keyless authentication. From dbcd8a75819c1576328d1997cbe7e7c390e0a96f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 10 Jun 2026 22:58:47 -0700 Subject: [PATCH 6/8] docs(cli): correct region discovery, CSV dry-run bypass, filter docs Address CodeRabbit review findings on PR #1090: - README.md + filtering.md: when --regions is empty the code enumerates all opted-in regions via EC2 DescribeRegions (Savings Plans always query once via us-east-1); Cost Explorer-based discovery is only a fallback when region listing fails. --include/--exclude-regions filter fetched recommendations and do not constrain queried regions. - README.md + purchase-safety.md: document that --input-csv mode computes isDryRun = !ActualPurchase and ignores --dry-run, so --purchase alone performs real purchases there; split the decision formula per code path and add a CSV-mode warning example. - filtering.md: instance-type include/exclude lists match by exact string equality (slices.Contains); remove the incorrect "partial prefixes" claim. - purchase-safety.md: add the missing skipped_covered audit status (idempotency hit). - README.md: clarify the binary is built as cudly while the cobra root command is named ri-helper; fix the auto-generated CSV filename format (ri-helper--.csv). --- docs/cli/README.md | 12 ++++++------ docs/cli/filtering.md | 4 ++-- docs/cli/purchase-safety.md | 31 +++++++++++++++++++++++-------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/cli/README.md b/docs/cli/README.md index 8d1e1ab9c..70cb14724 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1,8 +1,8 @@ # CUDly CLI Reference -This section documents the full CLI surface of the `cudly` binary (shipped as `ri-helper` in some builds). Three cobra commands are available: +This section documents the full CLI surface of the `cudly` binary. The Makefile builds it as `cudly`, while the cobra root command is named `ri-helper` (a legacy name that still appears in `--help` output and in auto-generated CSV filenames). Three cobra commands are available: -- **root** (`cudly`) - the main RI/SP analysis and purchase command +- **root** (`ri-helper`, invoked via the `cudly` binary) - the main RI/SP analysis and purchase command - **configure-azure** - bootstrap Azure Service Principal credentials - **configure-gcp** - bootstrap GCP Service Account credentials @@ -26,7 +26,7 @@ All flags belong to the root command unless noted otherwise. |------|-------|---------|-------------| | `--services` | `-s` | `rds` | Comma-separated list of services to process. Valid values: `rds`, `elasticache`, `ec2`, `opensearch`, `redshift`, `memorydb`, `savingsplans` (fans out to all four SP types), `savingsplans-compute`, `savingsplans-ec2instance`, `savingsplans-sagemaker`, `savingsplans-database`. The legacy alias `elasticsearch` maps to `opensearch`. | | `--all-services` | | `false` | Process all supported services; equivalent to listing every service in `--services`. | -| `--regions` | `-r` | (auto-discover) | AWS regions to process (comma-separated or repeated). When empty, cudly auto-discovers regions from Cost Explorer recommendations. The `--include-regions` / `--exclude-regions` scoping filters are applied on top of the resulting set (see [filtering.md](filtering.md)). | +| `--regions` | `-r` | (all opted-in regions) | AWS regions to process (comma-separated or repeated). When empty, cudly enumerates all opted-in AWS regions via EC2 `DescribeRegions`; only if that listing fails does it fall back to discovering regions from Cost Explorer recommendations. Savings Plans are account-level, so with `--regions` empty they are always queried once via `us-east-1`. The `--include-regions` / `--exclude-regions` scoping filters are applied to the fetched recommendations afterwards (see [filtering.md](filtering.md)). | ### Coverage and sizing @@ -49,8 +49,8 @@ All flags belong to the root command unless noted otherwise. | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--dry-run` | | `true` | Show what would be purchased without buying. See [purchase-safety.md](purchase-safety.md) for the interaction with `--purchase`. | -| `--purchase` | | `false` | Execute real purchases. Must be combined with `--dry-run=false` (or not set, since `--purchase` alone sets `ActualPurchase=true` but `DryRun` stays `true` unless explicitly overridden). See [purchase-safety.md](purchase-safety.md). | +| `--dry-run` | | `true` | Show what would be purchased without buying. **Ignored in `--input-csv` mode**, where the dry-run decision is `!ActualPurchase` (i.e. only `--purchase` matters). See [purchase-safety.md](purchase-safety.md) for the interaction with `--purchase`. | +| `--purchase` | | `false` | Execute real purchases. In the normal cloud-fetch path it must be combined with `--dry-run=false` (`--purchase` alone sets `ActualPurchase=true` but `DryRun` stays `true`, so the run remains a dry run). **Exception:** in `--input-csv` mode `--dry-run` is ignored and `--purchase` alone executes real purchases. See [purchase-safety.md](purchase-safety.md). | | `--yes` | | `false` | Skip the interactive confirmation prompt. Use with caution in automation. | | `--audit-log` | | `./cudly-audit.jsonl` | Path to the JSONL audit log file. Written for every recommendation (dry-run and real). See [purchase-safety.md](purchase-safety.md). | | `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Any commitment purchased within this window is subtracted from new recommendations. See [purchase-safety.md](purchase-safety.md). | @@ -90,7 +90,7 @@ All flags belong to the root command unless noted otherwise. | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--output` | `-o` | auto-generated | Output CSV file path. Auto-name format: `cudly-dryrun-YYYYMMDD-HHMMSS.csv` or `cudly-purchase-YYYYMMDD-HHMMSS.csv`. | +| `--output` | `-o` | auto-generated | Output CSV file path. Auto-name format: `ri-helper-dryrun-YYYYMMDD-HHMMSS.csv` or `ri-helper-purchase-YYYYMMDD-HHMMSS.csv`. | | `--input-csv` | `-i` | (none) | Read recommendations from an existing CSV instead of fetching from cloud APIs. The file must have a `.csv` extension and exist at the path given. | ### Authentication diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index eb1af54d7..4d4f47828 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -37,7 +37,7 @@ cudly --services rds --exclude-accounts sandbox --exclude-regions Exclude recommendations for these AWS regions (comma-separated) ``` -When `--regions` is omitted, cudly auto-discovers regions from Cost Explorer recommendations. `--include-regions` and `--exclude-regions` are applied as a post-discovery filter on the returned recommendations, not as a discovery constraint. +When `--regions` is omitted, cudly enumerates all opted-in AWS regions via EC2 `DescribeRegions` and queries each of them; discovering regions from Cost Explorer recommendations is only a fallback used when that region listing fails. (Savings Plans are account-level and are always queried once via `us-east-1` when `--regions` is empty.) `--include-regions` and `--exclude-regions` are applied afterwards as a filter on the fetched recommendations (matching each recommendation's region), so they reduce the results but do not change which regions are queried. If both `--regions` and `--include-regions` are set, the queried regions come from `--regions` and the results are then narrowed by the include/exclude lists. ```bash # Only US and EU recommendations @@ -71,7 +71,7 @@ cudly --services rds --exclude-engines mysql --exclude-instance-types Exclude these instance types (comma-separated) ``` -Instance type strings must contain a `.` separator (e.g. `db.t3.micro`). cudly validates the format at startup. You can use partial prefixes for simple family exclusions. +Instance type strings must contain a `.` separator (e.g. `db.t3.micro`). cudly validates the format at startup. Matching is by exact string equality - prefix or family matching is not supported, so every instance type you want included or excluded must be listed in full (e.g. `db.t2.micro,db.t2.small`, not `db.t2`). ```bash # Only modern graviton RDS instances diff --git a/docs/cli/purchase-safety.md b/docs/cli/purchase-safety.md index ede86060e..88adf1cdf 100644 --- a/docs/cli/purchase-safety.md +++ b/docs/cli/purchase-safety.md @@ -9,13 +9,23 @@ CUDly is designed to be safe by default. Real purchases require multiple explici --purchase bool default: false ``` -The actual purchase decision is computed as: +There are two code paths with different rules: -```text -isDryRun = !ActualPurchase || DryRun -``` +- **Cloud-fetch mode** (the default, recommendations fetched from the cloud APIs): + + ```text + isDryRun = !ActualPurchase || DryRun + ``` + +- **CSV input mode** (`--input-csv`): `--dry-run` is **ignored**: -In plain terms: + ```text + isDryRun = !ActualPurchase + ``` + + In CSV mode, `--purchase` alone is enough to execute real purchases. + +In plain terms, for cloud-fetch mode: | `--purchase` | `--dry-run` | Result | |---|---|---| @@ -24,14 +34,19 @@ In plain terms: | `true` | `false` | Real purchases | | (not set / false) | `false` | Dry run - `ActualPurchase` is false, so `isDryRun` stays true | -The footgun: passing `--purchase` alone is not sufficient. You must also pass `--dry-run=false` (or explicitly `--no-dry-run` in shells that support that form). This is intentional - two flags must be flipped to move money. +The footgun: in cloud-fetch mode, passing `--purchase` alone is not sufficient - you must also pass `--dry-run=false`. This is intentional: two flags must be flipped to move money. + +The reverse footgun: in `--input-csv` mode the two-flag protection does not apply. `--purchase` alone performs real purchases, and `--dry-run` has no effect. Treat any CSV-mode invocation that includes `--purchase` as a real purchase run. ```bash -# Correct way to execute real purchases: +# Correct way to execute real purchases (cloud-fetch mode): cudly --services rds --purchase --dry-run=false # Still a dry run (--dry-run defaults to true): cudly --services rds --purchase + +# CSV mode: this executes REAL purchases (--dry-run is ignored): +cudly --input-csv recs.csv --purchase ``` ## Confirmation prompt: --yes @@ -58,7 +73,7 @@ Every recommendation - whether purchased or dry-run - is written as a JSON line - Run ID (UUID that groups all purchases in a single invocation) - Recommendation details (service, region, instance type, count, term, payment) - Purchase result (success/failure, commitment ID, error message) -- Audit status (`skipped` for dry-run, `success` or `error` for real purchases) +- Audit status (`skipped` for dry-run, `skipped_covered` for idempotency hits where an equivalent commitment was already purchased within the window, `success` or `error` for real purchases) - Whether the run was a dry run - Purchase source (`cli`) From 48ec073e4ac3afa8f2f1c8008241bdf8de34bf00 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 17:09:49 +0200 Subject: [PATCH 7/8] docs(cli): correct --idempotency-window CLI behavior description The --idempotency-window flag is accepted and validated by the CLI but its value is stored and never consumed by the purchase pipeline: cfg.IdempotencyWindow is set but no code in cmd/ reads it to deduct previously-purchased commitments from new recommendations. That deduction path runs in the server-side scheduler, not the CLI loop. Update purchase-safety.md and the flag reference table in README.md to describe what the flag actually does (accepted/validated, no-op in CLI) rather than claiming it subtracts prior purchases. Also correct the audit-status list to note that skipped_covered is a schema-level value used by the server path and is not emitted by CLI runs. --- docs/cli/README.md | 2 +- docs/cli/purchase-safety.md | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/cli/README.md b/docs/cli/README.md index 70cb14724..83ea6d874 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -53,7 +53,7 @@ All flags belong to the root command unless noted otherwise. | `--purchase` | | `false` | Execute real purchases. In the normal cloud-fetch path it must be combined with `--dry-run=false` (`--purchase` alone sets `ActualPurchase=true` but `DryRun` stays `true`, so the run remains a dry run). **Exception:** in `--input-csv` mode `--dry-run` is ignored and `--purchase` alone executes real purchases. See [purchase-safety.md](purchase-safety.md). | | `--yes` | | `false` | Skip the interactive confirmation prompt. Use with caution in automation. | | `--audit-log` | | `./cudly-audit.jsonl` | Path to the JSONL audit log file. Written for every recommendation (dry-run and real). See [purchase-safety.md](purchase-safety.md). | -| `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Any commitment purchased within this window is subtracted from new recommendations. See [purchase-safety.md](purchase-safety.md). | +| `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Accepted and validated as a Go duration string; see [purchase-safety.md](purchase-safety.md). | ### Recommendation quality filters diff --git a/docs/cli/purchase-safety.md b/docs/cli/purchase-safety.md index 88adf1cdf..5a756d162 100644 --- a/docs/cli/purchase-safety.md +++ b/docs/cli/purchase-safety.md @@ -73,7 +73,7 @@ Every recommendation - whether purchased or dry-run - is written as a JSON line - Run ID (UUID that groups all purchases in a single invocation) - Recommendation details (service, region, instance type, count, term, payment) - Purchase result (success/failure, commitment ID, error message) -- Audit status (`skipped` for dry-run, `skipped_covered` for idempotency hits where an equivalent commitment was already purchased within the window, `success` or `error` for real purchases) +- Audit status (`skipped` for dry-run, `success` or `error` for real purchases; `skipped_covered` is defined in the schema for server-side idempotency hits but is not emitted by the CLI path) - Whether the run was a dry run - Purchase source (`cli`) @@ -93,15 +93,12 @@ The default path (`./cudly-audit.jsonl`) writes to the current working directory --idempotency-window string default: 24h ``` -Before purchasing a recommendation, cudly checks whether an equivalent commitment was purchased within the idempotency window. If a match is found, the recommendation count is reduced by the already-purchased count. This prevents double-buying when: +This flag is accepted and validated as a Go duration string (e.g. `24h`, `48h`, `1h30m`). The CLI pipeline stores it but does not yet subtract previously-purchased commitments from new recommendations based on this window; that deduction logic runs in the server-side scheduler path, not the CLI purchase loop. Passing this flag in CLI invocations has no effect on which recommendations are purchased. -- The tool is run multiple times in quick succession -- A partial batch succeeds and the run is retried - -The window is expressed as a Go duration string (e.g. `24h`, `48h`, `1h30m`). +The audit status value `skipped_covered` (idempotency hit) is defined in the audit record schema for use by the server path and is not emitted by the CLI. ```bash -# Extend the window for long-running multi-account deployments +# Accepted but currently has no effect on CLI recommendation deduction: cudly --services rds --idempotency-window 72h ``` @@ -145,4 +142,4 @@ Before any real purchase run: 3. If using `--target-coverage`, verify `--rebuy-window-days` is set appropriately for your RI renewal cadence. 4. Narrow the scope with `--include-regions`, `--include-accounts`, or `--min-savings-pct` before buying across all services. 5. Consider `--max-instances` as a final safety cap for a first run. -6. Use `--idempotency-window` consistent with your run frequency to avoid double-buying on retries. +6. Note that `--idempotency-window` does not prevent double-buying in the CLI path; use `--dry-run` review and audit-log inspection to guard against retried runs. From 8f96a0add8e244214de206e8b5f16d105051e691 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 26 Jun 2026 19:52:56 +0200 Subject: [PATCH 8/8] docs(cli): correct API-name and validation/conflict-check claims - filtering.md: account-name resolution uses organizations:DescribeAccount (per-ID + cached), not organizations:ListAccounts. IAM-policy authors granting only ListAccounts on the prior wording would have hit a permission gap. - filtering.md: scope the include/exclude conflict-check note to the three pairs actually validated at startup (regions, instance types, engines) and document that --include-accounts/--exclude-accounts and --include-sp-types/--exclude-sp-types fall through without that check, with exclude winning on overlap. - README.md + purchase-safety.md: drop the "validated as a Go duration string" claim for --idempotency-window; the CLI does not parse the value at startup, only the server-side scheduler does. --- docs/cli/README.md | 2 +- docs/cli/filtering.md | 6 ++++-- docs/cli/purchase-safety.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/cli/README.md b/docs/cli/README.md index 83ea6d874..4ec53063c 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -53,7 +53,7 @@ All flags belong to the root command unless noted otherwise. | `--purchase` | | `false` | Execute real purchases. In the normal cloud-fetch path it must be combined with `--dry-run=false` (`--purchase` alone sets `ActualPurchase=true` but `DryRun` stays `true`, so the run remains a dry run). **Exception:** in `--input-csv` mode `--dry-run` is ignored and `--purchase` alone executes real purchases. See [purchase-safety.md](purchase-safety.md). | | `--yes` | | `false` | Skip the interactive confirmation prompt. Use with caution in automation. | | `--audit-log` | | `./cudly-audit.jsonl` | Path to the JSONL audit log file. Written for every recommendation (dry-run and real). See [purchase-safety.md](purchase-safety.md). | -| `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Accepted and validated as a Go duration string; see [purchase-safety.md](purchase-safety.md). | +| `--idempotency-window` | | `24h` | Lookback window for duplicate purchase detection. Accepted as a Go duration string (not validated by the CLI; currently has no effect on CLI runs). See [purchase-safety.md](purchase-safety.md). | ### Recommendation quality filters diff --git a/docs/cli/filtering.md b/docs/cli/filtering.md index 4d4f47828..53dd1c167 100644 --- a/docs/cli/filtering.md +++ b/docs/cli/filtering.md @@ -9,10 +9,12 @@ All filters are evaluated before any purchase is attempted. Filtered-out recomme ## Scoping filters -Scoping filters operate on include/exclude list pairs. An item that appears in both lists is rejected at startup with an error: +Scoping filters operate on include/exclude list pairs. For region, instance-type, and engine pairs, an item that appears in both lists is rejected at startup with an error: > `region 'us-east-1' cannot be both included and excluded` +The conflict check is NOT applied to `--include-accounts`/`--exclude-accounts` or `--include-sp-types`/`--exclude-sp-types`; for those, a name listed in both lists silently behaves as excluded (exclude wins). Avoid listing the same value in both lists. + ### Account filters ```text @@ -20,7 +22,7 @@ Scoping filters operate on include/exclude list pairs. An item that appears in b --exclude-accounts Exclude recommendations for these account names (comma-separated) ``` -Account names are matched against the friendly alias resolved from AWS Organizations (`organizations:ListAccounts`). If the alias cannot be resolved, the account ID is used as a fallback. +Account names are matched against the friendly alias resolved from AWS Organizations (`organizations:DescribeAccount`, called per account ID and cached). If the alias cannot be resolved, the account ID is used as a fallback. ```bash # Only process two accounts diff --git a/docs/cli/purchase-safety.md b/docs/cli/purchase-safety.md index 5a756d162..505cce1a3 100644 --- a/docs/cli/purchase-safety.md +++ b/docs/cli/purchase-safety.md @@ -93,7 +93,7 @@ The default path (`./cudly-audit.jsonl`) writes to the current working directory --idempotency-window string default: 24h ``` -This flag is accepted and validated as a Go duration string (e.g. `24h`, `48h`, `1h30m`). The CLI pipeline stores it but does not yet subtract previously-purchased commitments from new recommendations based on this window; that deduction logic runs in the server-side scheduler path, not the CLI purchase loop. Passing this flag in CLI invocations has no effect on which recommendations are purchased. +This flag is accepted as a Go duration string (e.g. `24h`, `48h`, `1h30m`). The CLI does not validate the string at startup; it stores the raw value but does not yet subtract previously-purchased commitments from new recommendations based on this window. The deduction logic runs in the server-side scheduler path (where the duration IS parsed), not the CLI purchase loop. Passing this flag in CLI invocations has no effect on which recommendations are purchased. The audit status value `skipped_covered` (idempotency hit) is defined in the audit record schema for use by the server path and is not emitted by the CLI.