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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- An explicit `--output` path that already exists (file, directory or symlink)
is now refused during validation instead of being silently truncated. Choose a
new path or remove the old file; scripts that reuse one path must do the same.
Auto-generated filenames are unaffected (#2050).
- `--regions` now also narrows EC2 Instance Savings Plan recommendations to the listed
regions by the plan's own region (previously only `--include-regions` did); a plan
with no known region is dropped. Compute, SageMaker and Database plans stay
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Export a reviewable report when you need to share results:
./cudly --services rds --profile default --output recommendations.csv
```

The path must not already exist; the run is refused rather than overwriting it.

All purchase operations can spend money. Check the account, region, quantity, and selected commitment before confirming - see [Safety Features](#safety-features) for what is and is not enforced today.

## Credentials and provider status
Expand Down
9 changes: 9 additions & 0 deletions cmd/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,15 @@ func validateFilePaths() error {
return fmt.Errorf("output directory does not exist: %s", dir)
}
}
// Lstat so a symlink (even a dangling one) counts as existing; the
// later os.Create would otherwise follow it and truncate its target.
_, err := os.Lstat(toolCfg.CSVOutput)
if err == nil {
return fmt.Errorf("output path already exists: %s (it would be overwritten); choose a new --output path or remove the existing file", toolCfg.CSVOutput)
}
if !os.IsNotExist(err) {
return fmt.Errorf("cannot check output path %s: %w", toolCfg.CSVOutput, err)
}
}

// Validate CSV input path if provided
Expand Down
49 changes: 49 additions & 0 deletions cmd/validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,55 @@ func TestValidateFilePaths(t *testing.T) {
wantErr: true,
errMsg: "output directory does not exist",
},
{
name: "CSV output file already exists",
setupFunc: func() func() {
p := filepath.Join(tmpDir, "existing.csv")
if err := os.WriteFile(p, []byte("keep"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
toolCfg.CSVOutput = p
return func() { os.Remove(p) }
},
wantErr: true,
errMsg: "output path already exists",
},
{
name: "CSV output path is an existing directory",
setupFunc: func() func() {
toolCfg.CSVOutput = tmpDir
return func() {}
},
wantErr: true,
errMsg: "output path already exists",
},
{
name: "CSV output is a dangling symlink",
setupFunc: func() func() {
p := filepath.Join(tmpDir, "dangling.csv")
if err := os.Symlink(filepath.Join(tmpDir, "missing-target"), p); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
toolCfg.CSVOutput = p
return func() { os.Remove(p) }
},
wantErr: true,
errMsg: "output path already exists",
},
{
name: "CSV input equals output",
setupFunc: func() func() {
p := filepath.Join(tmpDir, "same.csv")
if err := os.WriteFile(p, []byte("keep"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
toolCfg.CSVOutput = p
toolCfg.CSVInput = p
return func() { os.Remove(p) }
},
wantErr: true,
errMsg: "output path already exists",
},
{
name: "CSV input file does not exist",
setupFunc: func() func() {
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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: `ri-helper-dryrun-YYYYMMDD-HHMMSS.csv` or `ri-helper-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`. An explicit path that already exists (file, directory or symlink) is refused instead of overwritten; choose a new path or remove the old file. |
| `--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. The header must contain `Service`, `Region`, `ResourceType`, `Count`, `Term` and `PaymentOption`; a missing column is a load error that names it. `Term` must be `1yr` or `3yr` and `PaymentOption` must be `all-upfront`, `partial-upfront` or `no-upfront` on every row; a blank or unknown value fails the load with the row number. CSVs with only the older four columns are rejected. |

### Authentication
Expand Down
Loading