Skip to content
Merged
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@

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)
Expand Down
105 changes: 105 additions & 0 deletions docs/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# CUDly CLI Reference

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** (`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

`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`. |
| `--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

| 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)). |
| `--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). |

### 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. **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. 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

| 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: `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

| 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.
163 changes: 163 additions & 0 deletions docs/cli/cloud-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Cloud Setup (Self-Hosted)

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

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.

## Prerequisites

- 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 (`<stack-name>-AzureCredentials` or `<stack-name>-GCPCredentials`) and updating the first match.

## configure-azure

Store Azure Service Principal credentials in Secrets Manager.

```text
cudly configure-azure [flags]
```

### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--stack-name` | `cudly` | Deployment stack name prefix. Used to locate the Secrets Manager secret (`<stack-name>-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). |
| `--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/<id>`** - 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 `<stack-name>-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 "<client-id>" \
--role "Reservations Administrator" \
--scope "/subscriptions/<subscription-id>"
```

## configure-gcp

Store GCP Service Account credentials in Secrets Manager.

```text
cudly configure-gcp [flags]
```

### Flags

| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--stack-name` | | `cudly` | Deployment stack name prefix. Used to locate the secret (`<stack-name>-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. |
| `--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 <id>`** - 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 <path>`), the command reads and validates the JSON file and writes it to the `<stack-name>-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": "<YOUR_SERVICE_ACCOUNT_PRIVATE_KEY>"
}
```

Any missing required field causes the command to exit with an error before writing to Secrets Manager.

## Usage notes

| Scenario | Notes |
|----------|-------|
| First-time setup | Deploy with Terraform first (`terraform/environments/<cloud>/`). 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. |
Loading
Loading