CLAUDE.md · diff

git:20261006.d474f27 to git:20261006.a8bcab4

375 added, 1108 removed. Audit A to A.

# CLAUDE.md
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
- ## CRITICAL INSTRUCTIONS
-
- **DO NOT RUN `git commit`** - This is explicitly forbidden. Use `git add`, `git status`, `git diff`, and `git log` only. The user will commit manually.
+ Guidance for Claude Code in this repository. It lists commands and the
+ non-obvious rules that have caused real bugs. Feature behavior lives in
+ `specs/NNN-*/` and the code; this file points there instead of restating it.
- **ALWAYS run `make lint` and `make test`** before claiming success.
+ ## Critical Instructions
- **DO NOT modify `.golangci.yml`** without explicit approval.
+ - **Do not run `git commit`** unless the user asks for a commit, directly or by
+ invoking a workflow whose purpose is a commit (for example `/pick-issue`).
+ - **Always run `make lint` and `make test`** before claiming success.
+ - **Do not modify `.golangci.yml`** without explicit approval.
+ - The constitution (`.specify/memory/constitution.md`) wins over any runtime
+ mode instruction (learning, explanatory). Principle VI forbids TODOs and
+ stubs. Record a conflict with `/speckit.revisit`.
## Project Overview
- FinFocus Core is a CLI tool and plugin host system for calculating cloud infrastructure costs from Pulumi infrastructure definitions. It provides both projected cost estimates and actual historical cost analysis through a plugin-based architecture.
+ FinFocus Core is a CLI and plugin host that calculates cloud costs from Pulumi
+ (and Terraform state) definitions: projected estimates, actual history, and
+ Kubernetes allocation, all through gRPC plugins. Sibling repos:
+ `finfocus-spec` (protos, `pluginsdk`) and the `finfocus-plugin-*` plugins
+ (aws-public, azure-public, ...). Cross-repo changes follow the constitution's
+ protocol. Plugin domain knowledge stays in the plugin; core and spec get
+ generic channels only.
## Specs
- Feature specs, plans, and tasks live in `specs/NNN-*/` and are produced by the
- **Spec Kit** pipeline (`.specify/` templates, `.specify/memory/constitution.md`,
- and the `/speckit-*` skills). Spec Kit is the ONLY spec/plan/task pipeline:
- design or plan output from other tools (e.g. superpowers brainstorming or
- writing-plans) MUST be converted into Spec Kit feature folders under `specs/`
- before implementation, not committed elsewhere in the repo.
-
- ## Build Commands
-
- ```bash
- make tools # Install toolchain pinned in mise.toml (first-time setup)
- make build # Build binary to bin/finfocus
- make test # Run unit tests (default, fast)
- make test-race # Run with race detector
- make test-integration # Integration tests (slower)
- make test-e2e # E2E tests (requires AWS credentials)
- make lint # Run golangci-lint + markdownlint
- make validate # go mod tidy, go vet
- make clean # Remove build artifacts
- make run # Build and run with --help
- make dev # Build and run without args
- make docs-lint # Lint markdown docs
- make docs-build # Build Jekyll site
- make docs-serve # Serve docs at http://localhost:4000/finfocus/
- make build-recorder # Build recorder plugin to bin/finfocus-plugin-recorder
- make install-recorder # Build and install recorder to ~/.finfocus/plugins/recorder/0.1.0/
- make test-jev # Test the jev scorer plugin module (plugins/jev, separate go.mod)
- make install-jev # Build and install the jev scorer plugin
- make test-prometheus # Test the prometheus usage-source plugin module (plugins/prometheus, separate go.mod)
- make install-prometheus # Build and install the prometheus usage-source plugin
- ```
-
- ### Single Package/Test Commands
-
- ```bash
- go test -v ./internal/cli/... # Test specific package
- go test -v ./internal/engine/... # Test engine package
- go test -run TestSpecificFunction ./... # Run specific test
-
- # Coverage
- go test -coverprofile=coverage.out ./...
- go tool cover -html=coverage.out # View in browser
-
- # Plugin management
- ./bin/finfocus plugin list
- ./bin/finfocus plugin list --output json # JSON array for machine consumption
- ./bin/finfocus plugin validate
- ```
+ Specs, plans, and tasks live in `specs/NNN-*/`, produced by **Spec Kit**
+ (`.specify/` and the `/speckit-*` skills). It is the only pipeline: output
+ from other planning tools must become a Spec Kit feature folder before
+ implementation.
- ### Troubleshooting
+ ## Commands
```bash
- pkill golangci-lint || true # Fix parallel linting conflicts
- GOOS=linux GOARCH=amd64 make build # Test release build locally
- gh workflow validate .github/workflows/ci.yml # Validate workflow syntax
+ make tools # Install the toolchain pinned in mise.toml
+ make build # bin/finfocus
+ make test # Unit tests: internal/, pkg/, and the plugin modules
+ make test-race
+ make test-integration # Slower; see Integration Tests below
+ make test-e2e # Needs AWS credentials and Pulumi
+ make test-e2e-kind # kind cluster: run-rate, then historical (Prometheus)
+ make lint # golangci-lint, markdownlint, actionlint
+ make validate # go mod tidy -diff, go vet
+ make docs-lint
+ make docs-serve # Astro site at http://localhost:4321/finfocus/
+ make check-plugin-boundaries
+ make test-kubernetes # Also test-jev, test-prometheus (nested plugin modules)
+ make install-kubernetes # Also install-jev, install-prometheus, install-recorder
```
- ### Test Requirements
-
- - **Unit tests**: Must achieve 80% coverage minimum
- - **Critical paths**: Must achieve 95% coverage
- - **All error paths**: Must be tested
- - **Performance regressions**: Must be detected via benchmarks
- - **Integration scenarios**: Must include plugin communication flows
- - **End-to-end workflows**: Must test complete CLI usage
-
- **Never complete a project without running:**
-
```bash
- make test # Run all tests
- make lint # Run linting
+ go test -run TestName ./internal/cli/
+ go test -race -shuffle=on -count=3 ./internal/<pkg>/... # Parallel-safety check
+ ./bin/finfocus plugin list --output json
```
- ## Go Version
-
- **Project Go Version**: 1.27.1 (see `go.mod`)
-
- **CRITICAL**: Before claiming any Go version "doesn't exist" or suggesting version
- changes, verify on <https://go.dev/dl/> first.
-
- ### Constitution Precedence Rule
-
- **CRITICAL**: The constitution (`.specify/memory/constitution.md`) takes **absolute
- precedence** over all runtime mode instructions (learning mode, explanatory mode, etc.).
-
- If any runtime instruction conflicts with a constitution principle:
-
- 1. **Constitution wins** - Follow the constitution rule
- 2. **Use `/speckit.revisit`** - Document the conflict for prevention
- 3. **Never compromise** - Principle VI forbids TODOs/stubs regardless of mode
+ - `make lint` checks golangci-lint's version against `mise.toml` and runs
+ `$(HOME)/go/bin/golangci-lint`. When that binary lags the pin, run
+ `make lint GOLANGCI_LINT="$(mise which golangci-lint)"`.
+ - `pkill golangci-lint || true` clears a parallel-runner conflict. Lint can run
+ longer than five minutes.
+ - After a rebase, run `go vet ./...` before trusting `make test`. A widened
+ interface (for example `engine.ResourcePricer`) breaks test doubles that
+ another merged PR added, with no git conflict.
## Architecture
- Core components and their directories:
+ | Package | Role |
+ | --- | --- |
+ | `internal/cli/` | Cobra commands, routed through `ax.Execute` (ax-go) |
+ | `internal/engine/` | Cost orchestration, cluster allocation, rendering |
+ | `internal/pluginhost/` | gRPC plugin launch, connect, cleanup |
+ | `internal/registry/` | Discovery in `<home>/plugins/<name>/<version>/`, install |
+ | `internal/ingest/` | Pulumi plan and state parsing |
+ | `internal/proto/` | Adapter: descriptors, pre-flight validation |
+ | `internal/router/` | Plugin routing with priority and fallback |
+ | `internal/analyzer/` | Pulumi Analyzer gRPC server |
+ | `internal/tui/` | Bubble Tea v2 / Lipgloss v2 (`charm.land` imports) |
+ | `internal/config/` | Two-tier config |
+ | `plugins/{kubernetes,jev,prometheus}/` | Nested modules; never import core |
- 1. **CLI** (`internal/cli/`) - Cobra commands: cost projected/actual/recommendations, plugin management, analyzer
- 2. **Engine** (`internal/engine/`) - Cost calculation orchestration, output rendering (table/JSON/NDJSON)
- 3. **Plugin Host** (`internal/pluginhost/`) - gRPC plugin lifecycle (launch, connect, cleanup)
- 4. **Registry** (`internal/registry/`) - Plugin discovery in `~/.finfocus/plugins/<name>/<version>/`
- 5. **Ingestion** (`internal/ingest/`) - Pulumi plan JSON parsing
- 6. **Analyzer** (`internal/analyzer/`) - Pulumi Analyzer gRPC protocol for zero-click cost estimation
- 7. **TUI** (`internal/tui/`) - Bubble Tea terminal UI components
- 8. **Router** (`internal/router/`) - Intelligent plugin routing with priority and fallback
- 9. **Config** (`internal/config/`) - Two-tier configuration (project-local overrides global)
+ Entry point `cmd/finfocus/main.go`. Exit codes: 0, 1 (ax `ExitInternal`), and
+ the budget code from `cost --exit-code` (default 1), preserved through
+ `ax.Execute` by `cli.toAxExitError`. User-input errors (flag combinations,
+ malformed input files, bad dates) exit 2 through `toValidationError`
+ (`ax.ExitValidation`). Codes 3 and 4 (`ExitNetwork`, `ExitAuth`) are not used yet.
### Configuration Resolution
- **Project-specific settings** (config, dismissals) precedence:
-
- 1. `--project-dir` flag (explicit override)
- 2. `FINFOCUS_PROJECT_DIR` env var
- 3. Walk up from CWD to find `Pulumi.yaml`, use `$PROJECT/.finfocus/`
- 4. Fall back to `~/.finfocus/` (backward compatible)
-
- **Global resources** (plugins, cache, logs) precedence:
-
- 1. `FINFOCUS_HOME` env var
- 2. `PULUMI_HOME/finfocus`
- 3. `~/.finfocus/`
-
- **Config Merge**: Project `config.hujson` overrides global `config.hujson` at the **top-level key** level
- (shallow merge). Keys absent in project config inherit from global defaults. Legacy `config.yaml` files are auto-migrated to Hujson format on first read.
+ - Project settings (config, dismissals): `--project-dir`, then
+ `FINFOCUS_PROJECT_DIR`, then the nearest `Pulumi.yaml` (`$PROJECT/.finfocus/`),
+ then `~/.finfocus/`.
+ - Global resources (plugins, cache, logs): `FINFOCUS_HOME`, then
+ `PULUMI_HOME/finfocus`, then `~/.finfocus/`. With none of those, it is
+ `./.finfocus` (`config.UsesWorkingDirFallback`).
+ - Project `config.hujson` overrides global at the top-level key (shallow
+ merge). Legacy `config.yaml` is migrated to Hujson on first read.
## Key Patterns
- ### CLI Conventions
-
- - Use `RunE` not `Run` for error handling
- - Use `cmd.Printf()` for output (not `fmt.Printf()`)
- - Defer cleanup functions immediately after obtaining resources
- - Support multiple date formats: "2006-01-02", RFC3339
-
- ### Pre-Flight Request Validation
-
- The adapter layer (`internal/proto/`) validates requests using `pluginsdk` validation
- before gRPC calls. Key points:
-
- - Validation happens in `GetProjectedCostWithErrors()` and `GetActualCostWithErrors()`
- - Uses "VALIDATION:" prefix to distinguish from plugin errors ("ERROR:")
- - Logs at WARN level with resource context for debugging
- - Returns placeholder CostResult with $0 cost and descriptive Notes
- - Invalid resources are skipped; valid resources still call the plugin
- - A projected resource with `ref.*` tags and an empty SKU uses lenient validation
- (`pluginsdk.ValidateProjectedCostRequestLenient`) instead of failing closed on SKU
-
- ### Logging
-
- ```bash
- # Enable debug output
- finfocus cost projected --debug --pulumi-json plan.json
- export FINFOCUS_LOG_LEVEL=debug
- export FINFOCUS_LOG_FORMAT=json # json or console
- export FINFOCUS_TRACE_ID=external-trace-123 # inject external trace ID
- ```
-
- Precedence: CLI flags (`--debug`) > env vars > config file > default (info, console).
-
- ### Global Agentic Flags
-
- The following flags are automatically mounted by `ax.Execute` on all commands:
-
- - **`--format json|human`**: Output format for machine vs human consumption (JSON for agent/tooling, human for terminal). When set to `json`, some commands automatically output machine-readable JSON. Defaults to auto-detection via TTY.
- - **`--dry-run`**: Preview changes without making them (skip real side effects). Affects mutating commands: `plugin install/update/remove`, `analyzer install/uninstall`, `config init/set`, and recommendation operations (dismiss/snooze/undismiss), and `plugin upgrade`.
- - **`--yes`**: Skip confirmation prompts (equivalent to `-y` or `--force` on individual commands). Automatically confirms operations that normally require user approval.
- - **`--idempotency-key string`**: Opaque retry-deduplication key for preventing duplicate-create operations in distributed systems.
- - **`--debug`**: Enable debug logging (also available as persistent flag for CLI-specific control).
-
- Additionally, the following utility commands are available:
-
- - **`__schema [--as=ax|mcp]`**: Emit machine-discoverability schema in AX (default) or MCP format for tool discovery.
- - **`mcp-server [--transport=stdio|http] [--addr] [--allow-non-loopback]`**: Expose the entire CLI as a live Model Context Protocol server.
- - **`--mcp`** (root-local flag): stdio alias for `mcp-server`; same tool list. User docs: `docs/src/content/docs/guides/mcp.md`.
+ - `RunE`, not `Run`. `cmd.Printf`, not `fmt.Printf`. Defer cleanup right after
+ acquiring a resource. Dates accept `2006-01-02` and RFC3339.
+ - Pre-flight validation (`internal/proto/`, `pluginsdk` validators) returns a
+ `$0` placeholder with a `VALIDATION:` note (plugin errors use `ERROR:`) and
+ still calls the plugin for valid resources. A resource with `ref.*` tags and
+ no SKU uses `ValidateProjectedCostRequestLenient`.
+ - Logging: `--debug` > `FINFOCUS_LOG_LEVEL`/`FINFOCUS_LOG_FORMAT` > config >
+ info/console. `FINFOCUS_TRACE_ID` injects a trace id.
+ - `ax.Execute` mounts `--format json|human`, `--dry-run`, `--yes`, and
+ `--idempotency-key` on every command, plus `__schema` and `mcp-server`
+ (`--mcp` is the stdio alias). A command must not redeclare `--format`;
+ Cobra rejects it.
## Testing
- ### TUI Visual Verification
-
- After modifying `internal/tui/` code, ALWAYS:
-
- 1. Render the affected view and read the full output (not just test pass/fail)
- 2. Verify column alignment, section ordering, and data population visually
- 3. Run golden file tests: `go test -run TestGolden ./internal/tui/...`
- 4. Regenerate golden files if layout intentionally changed:
- `UPDATE_GOLDEN=1 go test -run TestGolden ./internal/tui/...`
-
- String-only assertions (`assert.Contains`) are NOT sufficient for TUI testing.
+ - **Testify only** (`require` to stop, `assert` to continue); see `AGENTS.md`.
+ Never write `if got != want { t.Errorf(...) }` or raw `t.Fatalf`.
+ - **TUI changes**: render the view and read it, then run
+ `go test -run TestGolden ./internal/tui/...` (`UPDATE_GOLDEN=1` to
+ regenerate). `assert.Contains` alone is not enough.
+ - **Expected plugin failures**: log expected errors with `t.Logf`; fail only
+ when the required error is absent. `connection refused`, `context deadline
+ exceeded`, or `EOF` in logs of a passing test are normal.
+ - Coverage targets (80%, 95% on critical paths) are in the constitution.
### Parallel Tests
- Tests call `t.Parallel()` (top level and each `t.Run` subtest) unless they touch
- process-wide state. Parallel tests only run after every sequential test in the
- package has finished, so the hazard is parallel tests interfering with each other.
-
- - Not parallel: `t.Setenv`, `t.Chdir`, `os.Setenv`, swapping a package-level
- variable, `config.ResetGlobalConfigForTest`/`SetGlobalConfig`, or anything that
- builds a root command (`NewRootCmd*` sets the process-wide resolved project
- directory through `config.SetResolvedProjectDir`). This includes helpers that call
- those, such as `stubHome` or `WithEnv`.
- - Not parallel: a test that writes a script or binary (`#!/bin/sh`, `os.WriteFile`
- or `os.Chmod` with exec bits) and then executes it, directly or through a helper
- or launcher. A concurrent fork in the same process lets the child inherit the open
- write fd, so the exec fails with `text file busy` (ETXTBSY, golang/go#22315).
- Only the executing test needs to be sequential, because sequential tests never
- overlap parallel ones.
- - Mark such a test with `//nolint:paralleltest // <specific reason>` on the line
- above `func`. Put the directive on the `t.Run` or `for` line instead when only
- some subtests must stay sequential. `nolintlint` requires the reason.
- - Table-driven subtests must not share a mutable fixture. Production code mutates
- proto messages and slices in place (for example `Engine.GetBudgets` on a shared
- `*pbc.Budget`), so build the fixture inside the subtest.
- - Do not use `defer` in a parent test that starts parallel subtests; use
- `t.Cleanup`. The defer runs before the paused subtests resume.
- - Verify with `go test -race -shuffle=on -count=3 ./<pkg>/...`. A flake or race
- means the test is not safe to parallelise.
-
- ### E2E Testing
-
- **Location**: `test/e2e/` (separate Go module)
-
- **Prerequisites**: AWS session or profile configured, Pulumi CLI, `make build`
-
- ```bash
- export PATH="$HOME/.pulumi/bin:$PATH"
- export PULUMI_CONFIG_PASSPHRASE="e2e-test-passphrase"
- make test-e2e
- ```
-
- **CRITICAL**: E2E tests MUST call actual finfocus CLI binary.
- Never simulate cost values or stub CLI execution.
-
- ### Expected Failure Test Patterns
-
- **IMPORTANT**: Tests that intentionally create failing plugin scenarios must follow
- these patterns to avoid false CI failures:
-
- - **Expected errors**: Use `t.Logf()` (informational, test passes), NOT `t.Errorf()`
- - **Required errors**: Use `t.Fatalf("expected error")` only if error is absent
- - **Common expected errors**: `context deadline exceeded`, `connection refused`,
- `broken pipe`/`EOF`, `no such file or directory`
-
- If CI shows these errors in logs but tests are marked PASS, the behavior is correct.
- Only investigate if tests actually FAIL (exit code 1).
-
- ### Error Path Testing
-
- When writing new code, always include tests for error conditions:
-
- 1. Test every error return path
- 2. Validate error messages with `assert.Contains(t, err.Error(), "expected text")`
- 3. Test boundary conditions: empty inputs, nil pointers, invalid ranges
- 4. Test partial failures in batch operations
- 5. Test resource cleanup runs even when errors occur (defer patterns)
-
- ### Testify Assertion Standards
-
- **CRITICAL**: All Go tests MUST use testify's `require` and `assert` packages.
- NEVER use manual `if x != y { t.Errorf(...) }` patterns.
-
- **`require.*`** (stops test on failure): Setup operations, error checks where
- continuing would panic, non-nil checks before use.
-
- **`assert.*`** (continues on failure): Value comparisons, multiple property
- checks, non-critical validations.
-
- | Manual Pattern | Testify Replacement |
- | --- | --- |
- | `if err != nil { t.Fatal(err) }` | `require.NoError(t, err)` |
- | `if err == nil { t.Error("expected error") }` | `require.Error(t, err)` |
- | `if x != y { t.Errorf("got %v, want %v", x, y) }` | `assert.Equal(t, y, x)` |
- | `if len(x) != n { t.Errorf(...) }` | `assert.Len(t, x, n)` |
- | `if !strings.Contains(s, sub) { t.Errorf(...) }` | `assert.Contains(t, s, sub)` |
- | `if x == nil { t.Fatal("nil") }` | `require.NotNil(t, x)` |
-
- ### Local Plugin Development
-
- 1. Clone the plugin repository (e.g., `finfocus-plugin-aws-public`)
- 2. Modify the plugin code (add logging, fix type mapping)
- 3. Build: `make build-region REGION=us-east-1`
- 4. Install: Copy binary to `~/.finfocus/plugins/<plugin>/<version>/`
- 5. Run Core E2E tests to verify
-
- ## Important Files
-
- - `cmd/finfocus/main.go` - CLI entry point, routed through `ax.Execute` (ax-go). Exit codes:
- 0=success, 1=generic/internal error (ax `ExitInternal`), and a user-configurable code (default 1,
- 0-255 via `cost --exit-code`/`--exit-on-threshold`) for budget-exceeded, preserved through
- `ax.Execute` via `internal/cli.toAxExitError`. `ax.ExitValidation`(2)/`ExitNetwork`(3)/`ExitAuth`(4)
- are ax-go's own reserved exit codes for new error classifications, not yet used anywhere in
- finfocus, and not retrofitted onto existing exit-1 paths.
- - `internal/engine/engine.go` - Core orchestration
- - `internal/pluginhost/host.go` - Plugin client management
- - `internal/ingest/pulumi_plan.go` - Pulumi plan parsing
- - `.specify/memory/constitution.md` - Project principles and quality gates
- - `examples/plans/aws-simple-plan.json` - Sample plan for testing
-
- ## Pulumi Integration Notes
-
- ### Plan JSON Parsing
-
- The `pulumi preview --json` output nests resource details under `newState`.
- Ingestion MUST inspect `newState` to extract `inputs` and `type`. Without this,
- property extraction fails and plugins return `InvalidArgument` errors.
-
- `newState` and stack-export resources also carry `parent`, `dependencies`, and
- `propertyDependencies`. `parent` is the Pulumi stack, not a pricing reference.
- `ApplyCrossResourceRefs` follows `propertyDependencies` only: a property that
- points at exactly one in-plan resource becomes `ref.<property>.{urn,type,region,sku}`
- tags. The Pulumi unknown sentinel `04da6b54-80e4-46f7-96ec-b56ff0331ba9` is never
- sent as a tag, SKU, or region. A child's own region is filled from the referenced
- resource only when the child has none and exactly one referenced resource has a
- region. The referenced SKU is never copied into the child's `Sku`. A child with
- `ref.*` tags and an empty SKU uses `ValidateProjectedCostRequestLenient` so the
- plugin still sees the tags. Classic Azure `skuName` is copied only into
- `ref.<property>.sku` (the provider resolver does not treat `skuName` as the
- resource's own SKU). Projected cache keys append `/refs-<hash>` when those tags
- exist. Spec: `specs/618-cross-resource-refs/`.
-
- `ConvertToProto` also emits dotted keys for nested maps and arrays
- (`sku.capacity`, `rootBlockDevice.0.volumeType`) beside each collapsed key.
- It skips `__` segments, credential-like segments (`password`, `passwd`,
- `passphrase`, `secret`, `token`, `credential`, `ciphertext`, `privatekey`,
- `apikey`, `accesskey`, `connectionstring`, with the snake_case forms), a
- top-level input named `ref`
- (so it cannot flatten into the `ref.*` reference namespace), and the containers
- `tags`, `tagsAll`, `labels`, and `annotations`. Depth is capped at 6
- segments, new keys at 128 characters, new values at 256 characters, and the
- whole tag map at 50 entries. Existing collapsed keys are kept first.
- No credential or Pulumi secret reaches a plugin through tags: `ConvertToProto`
- drops credential-like top-level keys (`isCredentialKey`, the one rule shared
- with dotted keys and attributes) and secret values, the dotted walk skips
- secret maps (a decrypted secret's `value` leaf), `ConvertValueToString`
- returns `""` for a secret and leaves credential keys and secrets out of
- collapsed map and slice text, actual-cost tags drop secret values, and
- `EstimateCost` attributes go through `redactedProperties`.
- Projected cache keys always append `/tags-<digest>` of that map, then
- `/refs-<hash>` when reference tags exist. `EstimateCost` attributes stay
- nested. Spec: `specs/619-dotted-tag-keys/`.
-
- Core also sends the raw properties as `ResourceDescriptor.attributes`
- (finfocus-spec v0.7.3), built by `engine.BuildAttributes`. They go on projected
- `GetProjectedCost` and `BatchCost`, `Supports`, `GetPricingSpec`, actual
- `BatchCost`, and `GetActualCostRequest.resource` (finfocus-spec v0.7.4). At any depth they drop keys `skipDottedSegment` rejects (`__`
- and credential-like names) and Pulumi secrets (`history.IsPulumiSecret`, the one
- shared rule). At the top level they drop `ref` and `ref.*`. Pulumi unknowns and
- the `tags`/`labels` containers are kept. Over `pluginsdk.MaxAttributesBytes`
- (65536), they are omitted with a warning. Projected cache keys add
- `/attrs-<digest>` after `/refs-`, and the in-memory `Supports` key adds
- `:attrs-<digest>`, so `Supports` runs about once per distinct resource. A
- projected `BatchCost` chunk is also split so its descriptors stay under
- `maxBatchRequestBytes` (3 MiB; grpc-go's default limit is 4 MiB). Tags are
- unchanged. Spec: `specs/621-k8s-workload-projected-cost/`.
-
- `GetActualCostRequest.resource` is built by `proto.actualCostDescriptor` from
- `GetActualCostRequest.Resource` with `PrepareProjectedDescriptor`, as the spec
- requires. It is optional enrichment: it is dropped (warn log, request still
- sent) when the request names more than one resource, provider or type is
- empty, or `pluginsdk.ValidateResourceDescriptor` rejects it, never a
- `VALIDATION` placeholder. Request `tags` keep the injected `sku`/`region`/
- `provider`/`resource_type` for plugins older than v0.7.4. Actual cache keys add
- a `descriptor` filter (per-resource digest of flattened tags, `ref.*` included,
- and attributes; `actualDescriptorCacheSuffix`) only when a resource would send
- descriptor content, so resources without one keep their existing keys.
-
- ### Property Extraction
-
- The adapter (`internal/proto/adapter.go`) relies on the `Inputs` map to extract:
-
- - **SKU**: from `instanceType`, `type`, etc.
- - **Region**: from `availabilityZone`, `region`
-
- If ingestion fails to populate `Inputs`, these fields are empty.
-
- ### Resource Type Compatibility
-
- Pulumi provides types like `aws:ec2/instance:Instance` (Type Token). Plugins may
- expect `aws:ec2:Instance` or just `ec2`. Plugins should handle the standard
- Pulumi format or normalize internally.
-
- ### Pulumi SDK Import Path
-
- For Analyzer development, use the correct import:
-
- ```go
- pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
- // NOT: github.com/pulumi/pulumi/sdk/v3/proto/go/pulumirpc
- ```
-
- ## Multi-Repository Ecosystem
-
- FinFocus operates across three repositories:
-
- - **finfocus** (this repo) - CLI tool, plugin host, orchestration
- - **finfocus-spec** - Protocol buffer definitions, SDK generation
- - **finfocus-plugin** - Plugin implementations (Kubecost, Vantage, etc.)
-
- Cross-repo changes follow the protocol in `.specify/memory/constitution.md`.
+ Call `t.Parallel()` at the top level and in each `t.Run`, except:
- ### Agent Skills Placement
+ - Tests that touch process-wide state: `t.Setenv`, `t.Chdir`, `os.Setenv`,
+ package-level variables, `config.ResetGlobalConfigForTest`/`SetGlobalConfig`,
+ or anything that builds a root command (`NewRootCmd*` sets the resolved
+ project dir), including helpers such as `stubHome` and `WithEnv`.
+ - A test that writes a binary or script and then executes it. A concurrent
+ fork inherits the write fd and the exec fails with `text file busy`
+ (golang/go#22315).
+ - Mark them `//nolint:paralleltest // <reason>` above `func` (or on the
+ `t.Run`/`for` line for some subtests).
+ - Do not share a mutable fixture across subtests; production code mutates
+ protos in place. In a parent with parallel subtests use `t.Cleanup`, never
+ `defer`.
- - **Tool-specific skills** (`finfocus-install`, `finfocus-budget`, `finfocus-diagnose`, etc.) → live in
- `rshade/finfocus` under `agent-skills/` — these are product skills tightly coupled to
- finfocus CLI commands, file paths, and architecture
- - **Generic cost workflow skills** (`cost-check`, `cost-drift`, `cost-optimize`,
- `budget-setup`) → live in `rshade/agent-skills` as multi-tool skills not tied to any
- specific cost tool
+ ### E2E
- ## Common Error Types
+ - `test/e2e/` is its own module and must run the real `finfocus` binary;
+ never stub cost values. AWS E2E needs `PULUMI_CONFIG_PASSPHRASE` and
+ `~/.pulumi/bin` on `PATH`.
+ - The run-rate half of `make test-e2e-kind` installs aws-public into the real
+ `~/.finfocus`. `TestCostCluster_KindHistorical` uses a temp home: run
+ `test/e2e/kind/setup.sh`, apply `test/e2e/kind/prometheus.yaml`, then
+ `go test -tags e2e_kind -run '^TestCostCluster_KindHistorical$' ./...` from
+ `test/e2e` (its own module; a bare run from the repo root skips it) with
+ `FINFOCUS_BINARY` set.
+ - Remote-write fixtures need `storage.tsdb.out_of_order_time_window` in the
+ Prometheus config and a window relative to now. A fixed date is rejected as
+ out of bounds once a newer sample exists, and later falls out of the 15-day
+ retention.
+ - `make test-e2e-kind` pins `E2E_AWS_PUBLIC_VERSION` (Renovate bumps it).
- - `ErrNoCostData`: No cost data available for a resource
- - `ErrMixedCurrencies`: Multiple currencies detected in cross-provider aggregation
- - `ErrInvalidGroupBy`: Invalid grouping type used for time-based aggregation
- - `ErrEmptyResults`: Attempted aggregation on empty results
- - `ErrInvalidDateRange`: Invalid date range (end date before start date)
- - `ErrResourceValidation`: Internal resource validation failed
- - `ErrConfigCorrupted`: Configuration file is malformed
+ ### Integration Tests (`test/integration/`)
- Structured error codes for JSON/NDJSON output: `PLUGIN_ERROR`, `VALIDATION_ERROR`,
- `TIMEOUT_ERROR`, `NO_COST_DATA` (see `internal/engine/types.go`).
+ - `helpers.CLIHelper.Execute` must go through `ax.Execute`; a bare
+ `cli.NewRootCmd().Execute()` lacks `--format`, `--dry-run`, `--yes`, and
+ `--idempotency-key`.
+ - Tests built with `helpers.NewCLIHelper(t)` and no `WithEnv` read the real
+ `~/.finfocus/config.hujson`. Compare the failure *set* with a pre-change
+ baseline, not the count.
+ - Setting `HOME` for a `go`/`make` child moves Go's caches when `GOPATH` is not
+ exported (CI); cleanup then fails on read-only module files. Pass the real
+ `GOMODCACHE`/`GOCACHE` (see `scaffold_build_test.go`). Reproduce with
+ `env -u GOPATH go test ...`.
+ - `config.yaml` fixtures are intentional legacy input for auto-migration.
- ## Recorder Plugin
+ ## Releases
- Reference plugin for inspecting Core-to-plugin data shapes and contract testing.
+ - release-please manages `CHANGELOG.md` and versions; never edit them by hand.
+ A `feat` below 1.0 bumps the patch.
+ - A new nested plugin goes into `release-please-config.json` with
+ `initial-version` and its path in the root package's `exclude-paths`. Do
+ not add it to `.release-please-manifest.json`: a hand-written `0.1.0` makes
+ the first release `0.1.1`. The release PR adds the entry.
+ - `release-monorepo-plugin.yml` builds any `<name>-vX.Y.Z` tag from
+ `plugins/<name>/cmd`; a new plugin needs no workflow change. Plugin tag
+ prefixes must not start with `v`.
+ - Nested modules must pin the same `finfocus-spec` as the root `go.mod`; CI
+ compares them.
- | Variable | Default | Description |
- | --- | --- | --- |
- | `FINFOCUS_RECORDER_OUTPUT_DIR` | `./recorded_data` | Directory for recorded JSON files |
- | `FINFOCUS_RECORDER_MOCK_RESPONSE` | `false` | Enable randomized mock responses |
+ ## Pulumi Integration
- ## Jev Scorer Plugin
+ - `pulumi preview --json` nests resources under `newState`; ingestion must read
+ `inputs` and `type` there or plugins get `InvalidArgument`.
+ - `parent` is the stack, not a pricing reference. Cross-resource refs follow
+ `propertyDependencies` only and become `ref.<property>.{urn,type,region,sku}`
+ tags (`specs/618-cross-resource-refs/`). The unknown sentinel
+ `04da6b54-80e4-46f7-96ec-b56ff0331ba9` is never sent as a tag, SKU, or
+ region. A referenced SKU is never copied into the child's `Sku`.
+ - **No credential or Pulumi secret reaches a plugin.** One rule each:
+ `isCredentialKey`/`skipDottedSegment` for names and `history.IsPulumiSecret`
+ for values. They apply to collapsed tags, dotted keys
+ (`specs/619-dotted-tag-keys/`), `ResourceDescriptor.attributes`
+ (`specs/621-k8s-workload-projected-cost/`), actual-cost tags, and
+ `EstimateCost` attributes. Do not add a second rule.
+ - Anything new sent to a plugin must be in the projected cache key; that is
+ why keys end in `/tags-`, `/refs-`, `/attrs-` digests and feature suffixes
+ (`/pricing-spec`, `/growth-v1`).
+ - `__`-prefixed inputs (`__defaults`, `__provider`) are Pulumi internals; filter
+ them from anything shown to users.
+ - Analyzer import: `pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"`
+ (not `.../proto/go/pulumirpc`).
- `plugins/jev/` is a separate module (like `plugins/kubernetes`) implementing
- `RecommendationScorerService` with TypeSafe AI's Jev API. It must not import
- finfocus core packages (`make check-plugin-boundaries`). Design notes:
+ ## Plugins
- - Off unless `TYPESAFE_API_KEY` is set; without it every scoring call returns
- `UNAUTHENTICATED`. The key is read only from the environment and must never
- appear in logs, errors, tests or committed files.
- - Thin `net/http` client in `plugins/jev/internal/jevapi`; no third-party Jev SDK.
- - Question names are `<signal>:#<position>` over an ordered state list; the
- recommendation id never leaves the plugin (answers map back by position via
- `batch.indexes`). Question text never contains recommendation content. Free
- text is stripped and capped in `internal/scoring/record.go`. Tests find a
- record's key with `questionKey()` in `helpers_test.go`, because every
- one-record `priority` request uses `priority:#0`.
- - `jevapi.Client` does not start a retry whose wait outlasts the caller's
- deadline (core's `scoring.timeout_seconds`); it returns the `*APIError`.
- - Live tests (`TestLive*`, `TestEvaluation` with `JEV_EVAL=1`) are skipped without
- a key. Batching lowers `priority` rank quality (Spearman about 0.55 at batch
- 25 against 0.77 at batch 1), so priority is always one record per request.
- - Scores are ranking-only. Never gate an irreversible action on one.
+ - **Recorder** (reference/contract plugin): `FINFOCUS_RECORDER_OUTPUT_DIR`
+ (default `./recorded_data`), `FINFOCUS_RECORDER_MOCK_RESPONSE`.
+ - **Jev scorer** (`plugins/jev/`): off unless `TYPESAFE_API_KEY` is set; the key
+ never appears in logs, errors, tests, or files. The recommendation id never
+ leaves the plugin (answers map back by position). `priority` is always one
+ record per request (batching drops rank quality). Scores rank only; never
+ gate an irreversible action on one.
+ - **Agent skills**: finfocus-specific skills live in `agent-skills/` here;
+ generic cost-workflow skills live in `rshade/agent-skills`.
## Package-Specific Gotchas
- Non-obvious behaviors that can cause subtle bugs if you don't know about them.
-
### Plugin Host (`internal/pluginhost/`)
- - **Port allocation**: Uses allocate→hold→release→bind pattern. Race window between
- release and plugin bind is mitigated by `StartWithRetry`
- - **Zombie prevention**: Always call `cmd.Wait()` after `cmd.Process.Kill()`
- - **`PORT` env var removed**: Core does NOT set `PORT` (avoids Cloud Run conflicts).
- Plugins must use `--port` flag or `FINFOCUS_PLUGIN_PORT`
+ - Ports: allocate, hold, release, bind, retried by `StartWithRetry`. A port
+ stays in `pendingPorts` until its plugin binds. A skipped pending port's
+ listener is closed at once: the readiness probe is a bare TCP dial, so a
+ held listener would answer for another plugin.
+ - Always `cmd.Wait()` after `cmd.Process.Kill()`.
+ - Core never sets `PORT`; plugins use `--port` or `FINFOCUS_PLUGIN_PORT`.
### CLI (`internal/cli/`)
- - **`analyzer serve` stdout**: Prints ONLY the port number to stdout (Pulumi handshake
- protocol). ALL logging must go to stderr exclusively
- - **DismissalStore**: Uses `GetResolvedProjectDir()` → project `dismissed.json`;
- falls back to `~/.finfocus/dismissed.json`
- - **`config init`**: Without `--global`, inside a Pulumi project creates
- `$PROJECT/.finfocus/config.hujson` + `.gitignore`. Outside Pulumi project → global init
- - **`config routes`**: `config routes list` shows effective routing source/path;
- `config routes test <type> [region]` simulates per-feature plugin selection without loading plugin binaries
- - **`config validate` and cost pre-run**: both call `ValidateConfigSource`.
- `--file` selects a document. `--output json` prints the report. An unknown
- `--output` is rejected before the file is read. A missing default file stays
- valid. `cost projected`, `cost actual`, `cost recommendations`,
- `cost estimate`, `cost forecast`, and `cost cluster` fail in pre-run when a present file is
- invalid, and they ignore a missing file. Flat `cost.budgets.amount` is warned
- and not applied; the on-disk field is `cost.budgets.global.amount`. Period
- stays monthly. Threshold stays 0–1000. Amount 0 disables a scope. Unknown
- keys warn, with a suggestion when the name is close. Alert `notifications`
- destinations are checked too: type `slack` or `webhook`, a literal `url`
- must be `https://` with a host, `channel` is Slack only, `method`
- (POST/PUT) and `headers` are webhook only, and a `${NAME}` reference must
- name a `FINFOCUS_NOTIFY_*` variable. When the file is the resolved
- project's `config.hujson` or legacy `config.yaml` (`config.ProjectConfigPath`,
- `isProjectConfigFile`), `ValidateProjectConfigSource` also rejects any
- `${...}` in a destination; YAML is converted to JSON first and loses line
- numbers. A bare `config validate` also checks the resolved project file with
- those rules and tags its findings with `file`
- - **Unit tests leak into the real `~/.finfocus`**: any test that executes a
- mutating command (`dismiss`, `snooze`, `config set`) writes to the developer's
- and the CI runner's actual home unless it sets
- `t.Setenv("FINFOCUS_HOME", t.TempDir())`. `NewDismissalStore("")` falls back to
- `ResolveConfigDir()`, which honors `FINFOCUS_HOME` first. This has produced
- tests that pass *only* because a sibling test seeded the shared store — green
- as a package, red when run alone. Detect with
- `HOME=$(mktemp -d) go test -run TestName ./internal/cli/`; a test that fails
- in isolation but passes in the package is order-dependent, not flaky
- - **Validate `--output` before loading state**: commands that early-return on an
- empty result (e.g. history's `len(events) == 0`) must reject an unknown format
- *first*, or an invalid `--output` silently exits 0. See `config_routes.go`,
- `analyzer_check.go`, `plugin_list.go` for the up-front pattern
- - **Overview docs path**: the published page is
- `docs/src/content/docs/commands/overview.md`. Root `README.md` links there.
- `docs/commands/overview.md` is not a file. Plain headers are
- `ACTUAL(MTD)` and `PROJECTED` (`PROJECTED*` in state-only mode). Delta comes
- from `CalculateRowDelta`, not projected minus month-to-date. The Warn column
- repeats a shown drift as `drift` and also lists `error` and `new`. `error`
- covers a plugin error reported on the result (`PLUGIN_ERROR`, `TIMEOUT_ERROR`,
- `VALIDATION_ERROR`, an `ERROR:`/`VALIDATION:` note) as well as a returned error; `NO_COST_DATA`
- is not an error. `ApplyChangesToRows` re-derives warnings when a status changes. The TUI
- cell uses `name+N` when that list does not fit in 7 columns. `estimate` and
- `stale` exist on `OverviewWarning` and are not derived
- - **Cost table flags**: `--show-breakdown` on `cost projected` and `cost actual`
- adds alphabetical component sub-rows in the plain table (`├─`, last row `└─`).
- Empty maps, nil maps, and empty keys add no sub-rows. `--show-confidence`
- adds the actual-cost Confidence column. Neither flag changes JSON or NDJSON.
- `--estimate-confidence` still sets the plugin request and decides whether
- JSON/NDJSON keep `confidence`. Either new actual flag skips the interactive
- TUI so the table is what the user sees. Time-based `--group-by` stays on the
- cross-provider aggregation table, which has no per-resource sub-rows
- - **Accessibility flags are local to output commands**: `overview`, `cost projected`, `cost actual`, and `cost recommendations` accept `--no-color`, `--plain`, `--color`, and `--high-contrast`. `overview` also keeps `--force-color` as an alias of `--color`. Do not put `--plain` on the `cost` parent: `cost history view` already defines a local `--plain`, and Cobra rejects the redefined flag. `FINFOCUS_PLAIN`, a non-empty `NO_COLOR`, `FORCE_COLOR`, and `FINFOCUS_HIGH_CONTRAST` fill unset flags (`strconv.ParseBool`; invalid values are ignored). Explicit `--plain` or `--no-color` wins over color flags. Those env vars win over `FORCE_COLOR` and `FINFOCUS_HIGH_CONTRAST` when the color mode was not set by a flag. `--plain` and `--no-color` stay `OutputModePlain`. High contrast does not change `ColorOK` and the other package color variables; `tui.Palette(true)` is ANSI 46, 226, 196, and 231, used by the styled budget box. Plain budget status text is `[OK] Within budget` or `[WARNING] Exceeds N% threshold`. Call `tui.DetectResolvedOutputMode` with the resolved struct, never `DetectOutputModeFor`,
- which re-reads `NO_COLOR` and would undo an explicit `--color`. `RenderScopedBudgetStatus` takes the
- resolved `tui.Accessibility` like the global box. `cost projected` prints its diff table as plain text in
- every mode, so its four flags only change the budget box (the help text says so). `FINFOCUS_PLAIN` fills
- in `cost history view`'s default format only: an explicit `--output`, `--format json`, or `AGENT_MODE`
- keeps JSON, and an explicit `--plain` still wins. `FORCE_COLOR` forces color for any non-empty value except `0` and `false` (force-color.org; supports-color treats those two as off)
- - **Budget CLI flags stay off the global config**: `--exit-on-threshold` and
- `--exit-code` are `BudgetFlagOverrides` on the command context, passed into
- `evaluateBudgetStatus` and `legacyBudgetConfig`. They are not written onto
- `config.GetGlobalConfig()`. Precedence is the CLI pointer, then the scoped
- budget field, then parent `BudgetsConfig`, then the zero value. Only a copy
- of the global scope receives the overlay. Provider, tag, and type scopes do not.
- - **Budget notifications** (`internal/notification`, `internal/cli/budget_notify.go`,
- spec `specs/625-budget-alert-notifications/`): `notifyBudgetAlerts` runs in
- `evaluateBudgetStatusWithRender` after evaluation and before
- `checkBudgetExitFromResult`; it writes only to stderr and never changes the
- returned error or stdout. A run opts in with `--notify` (persistent on `cost`,
- local on `overview`) or `FINFOCUS_NOTIFY` (`strconv.ParseBool`; invalid warns
- and is false); otherwise one hint line is printed when a crossed threshold has
- destinations. No state between runs. `ThresholdStatus.Notifications` is
- `json:"-"`, and statuses store their budget with destinations stripped, so
- `--output json` never shows a URL. Masking is display-only
- (`Config.MaskedForDisplay` in `config get`/`config list`), because
- `Config.Save` marshals the struct. Only `${FINFOCUS_NOTIFY_*}` expands: the
- dispatcher checks every reference before calling the env lookup, so no other
- variable is ever read. `ShallowMergeYAML` marks destinations in a project
- overlay's `cost` section as project-sourced (unexported field,
- `FromProject()`), and the dispatcher skips any project destination that
- contains `${` without a lookup. When no FINFOCUS_HOME, PULUMI_HOME, or home
- directory exists, `ResolveConfigDir` falls back to `./.finfocus`
- (`config.UsesWorkingDirFallback`); that global config is a project file too,
- so `New` marks its destinations project-sourced. Sends are concurrent, 10s
- each, HTTPS only, no redirects; reasons are redacted (every variable value,
- URL, and secret-looking header: 8+ characters, or an Authorization,
- Proxy-Authorization, Cookie, token, key, or secret name) and never include a
- response body. A banner render error still notifies (`renderBudgetWithScope`
- returns the result with the error). `overview` runs `validateCostConfig`
- when the run opted in. Statuses are built only through `newScopedBudgetStatus`
- and `Evaluate`, which strip destinations. CLI tests swap the HTTP client with
- `cli.SetNotificationClientForTest`
+ - `analyzer serve` prints only the port on stdout (Pulumi handshake); all logs
+ go to stderr.
+ - **Tests leak into the real `~/.finfocus`** unless they set
+ `t.Setenv("FINFOCUS_HOME", t.TempDir())`; this has produced tests that pass
+ only after a sibling seeded the store. A test that fails alone
+ (`HOME=$(mktemp -d) go test -run Name`) is order-dependent, not flaky. The
+ same leak makes `TestPluginListCmd_TableOutputUnchanged` fail when several
+ plugin versions are installed locally.
+ - Reject an unknown `--output` before loading state; early returns on empty
+ results otherwise exit 0 silently (see `config_routes.go`, `plugin_list.go`).
+ - `config validate` and every `cost` pre-run share `ValidateConfigSource`.
+ The on-disk budget field is `cost.budgets.global.amount`; flat
+ `cost.budgets.amount` is warned and ignored.
+ - Do not put `--plain` (or other accessibility flags) on the `cost` parent:
+ `cost history view` has its own `--plain`. Resolve modes with
+ `tui.DetectResolvedOutputMode`, never `DetectOutputModeFor`, which re-reads
+ `NO_COLOR` and undoes an explicit `--color`.
+ - Budget CLI flags (`--exit-on-threshold`, `--exit-code`) are
+ `BudgetFlagOverrides` on the command context, never written onto the global
+ config; only a copy of the global scope receives them.
+ - Budget notifications (`specs/625-budget-alert-notifications/`): stderr only,
+ never change stdout or the exit code. Only `${FINFOCUS_NOTIFY_*}` expands,
+ and a project-sourced destination with `${` is skipped without a lookup.
+ Sends are HTTPS only, follow no redirects, and time out after 10s. Failure
+ reasons redact every variable value, URL, and secret-looking header, and
+ never include a response body.
+ `ThresholdStatus.Notifications` is `json:"-"`; masking is display-only
+ because `Config.Save` marshals the struct. Tests swap the client with
+ `cli.SetNotificationClientForTest`.
+ - The overview docs page is `docs/src/content/docs/commands/overview.md`.
### Registry (`internal/registry/`)
- - **Region-specific binaries**: When `plugin.metadata.json` has a `region` key, registry
- looks for `finfocus-plugin-<name>-<region>` first, then falls back to standard names
- - **Concurrent `Open`**: plugins launch in parallel (at most `maxConcurrentPluginOpens`, 8)
- and the returned clients keep `ListLatestPlugins` order, which is sorted by name (router
- priority ties depend on it). `ProcessLauncher` keeps a port in `pendingPorts` from
- allocation until the plugin binds, so two concurrent launches never get the same port
- - **Checksum verification**: Only a confirmed hash mismatch is fatal. Missing
- `checksums.txt`, download failures, or unlisted assets produce warnings and continue
- - **`--skip-checksum`** flag available on `plugin install` and `plugin update`
- - **Monorepo plugins (`tag_prefix`)**: an entry with `tag_prefix: "kubernetes-"` installs
- from `rshade/finfocus` tags like `kubernetes-v0.1.0`. "Latest" is the highest semver among
- prefixed stable releases (scans 100), never `/releases/latest`; install dir, asset names, and
- recorded version use the *canonical* version (`v0.1.0`, via `CanonicalVersion`). The entry
- also needs `asset_hints.asset_prefix`, and prefixes must not start with `v` (CLI workflows
- treat `v*` tags as CLI releases)
- - **Asset-less releases**: Release Please publishes a release before GoReleaser uploads
- its archives (aws-public v0.2.0 had none for about 40 minutes). With no version
- requested, `Installer.fetchRelease` replaces a latest release with zero assets by the
- highest-semver earlier stable release that has assets (same rule on the `tag_prefix`
- path) and warns through `progress`. An explicit version is never swapped:
- `FindPlatformAssetWithHints` returns `*NoAssetsError` (`errors.Is` with
- `ErrReleaseHasNoAssets`). Its message still starts with `no asset found for
- <platform>` and `isNoAssetError` also matches it with `errors.Is`, so the CLI's
- version fallback (prompt or `--fallback-to-latest`) still applies. Assets that exist
- but do not match keep `no asset found for ... Available:`.
- `make test-e2e-kind` pins `E2E_AWS_PUBLIC_VERSION` (Renovate bumps it via the
- `# renovate:` comment regex manager)
- - **Default plugin dir**: `DefaultPluginDir()` is `config.ResolveConfigDir()/plugins`
- (FINFOCUS_HOME, PULUMI_HOME/finfocus, ~/.finfocus), the directory discovery reads.
- `NewInstaller("")` and `NewInstallerWithClient(c, "")` use it
+ - `Open` launches up to 8 plugins concurrently; clients keep
+ `ListLatestPlugins` order, sorted by name (router priority ties rely on it).
+ - With `region` in `plugin.metadata.json`, discovery looks for
+ `finfocus-plugin-<name>-<region>` first.
+ - Only a confirmed checksum mismatch is fatal; `--skip-checksum` exists.
+ - Monorepo plugins (`tag_prefix: "kubernetes-"`): "latest" is the highest
+ prefixed stable semver, never `/releases/latest`; installs use the canonical
+ version; the entry needs `asset_hints.asset_prefix`.
+ - A latest release with no assets yet (release-please publishes before the
+ archives upload) is replaced by the newest earlier release with assets. An
+ explicit version is never swapped; it returns `*NoAssetsError`
+ (`ErrReleaseHasNoAssets`), which the CLI's version fallback still handles.
+ - `DefaultPluginDir()` is `config.ResolveConfigDir()/plugins`.
### Router (`internal/router/`)
- - **Fallback chain**: `$0.00` cost is a VALID result (does NOT trigger fallback).
- Only nil/empty results trigger fallback to the next plugin
- - **Priority**: Higher number = higher priority (sorted descending by `sortByPriority`)
- - **Provider is the billing cloud, not the package**: `resourcetype.NormalizeProvider`
- holds the only alias table (`aws-native`→`aws`; `azure-native`, `azurerm`→`azure`;
- `google-native`, `google`→`gcp`; anything else passes through lowercased).
- `resourcetype.ExtractProvider` returns the cloud and `ExtractPackage` the raw
- prefix. Never split a type token for a provider anywhere else. `resource_type`
- keeps the package, so routing `patterns` such as `aws-native:*` still match.
- `ProviderMatches` normalizes both sides, so a plugin that still lists
- `azure-native` keeps matching. Provider budget keys, `ByProvider`,
- `GroupByProvider`, the `provider=` filter and history provider lookups compare
- normalized names, which also lets history entries stored under a package name
- match a cloud filter
- - **No config = no routing**: `createRouterForEngine()` returns nil if no routing config;
- engine falls back to querying all plugins
+ - A `$0.00` result is valid and does not fall back; only nil/empty does.
+ - Higher priority number wins. No routing config means no router (all
+ plugins are queried).
+ - Provider means the billing cloud. `resourcetype.NormalizeProvider` holds the
+ only alias table (`aws-native`→`aws`, `azure-native`/`azurerm`→`azure`,
+ `google-native`/`google`→`gcp`); never split a type token for a provider
+ anywhere else. `resource_type` keeps the package, so `aws-native:*` patterns
+ still match.
### Engine (`internal/engine/`)
- - **`hoursPerMonth = 730`** for monthly cost calculations
- - **Plugin error text is kept**: a failed `GetProjectedCost` or `GetActualCost`
- call keeps the plugin's gRPC status as `<Code>: <message>` (`pluginRPCError`,
- which still answers `status.Code`), e.g. `plugin call failed: InvalidArgument:
- region is required for GetProjectedCost` in `.finfocus.errors`. Only an empty
- projected result list is `no cost data available`. The projected
- `NO_COST_DATA` note appends the first other error after any decline reasons,
- and the actual `--fallback-estimate` note is `ERROR: ` plus that error, both
- capped at 160 bytes. Core does not read `aws:region` from Pulumi provider or
- stack config; region comes from `region`, `availabilityZone`, an ARN, then
- `AWS_REGION`/`AWS_DEFAULT_REGION`
- - **Projected cost diff**: `cost projected` calls `GetProjectedCostDiff`.
- An empty operation is a create (`$0` before, current price after); Terraform
- state maps to `same` (existing infrastructure, no delta). Internal `pulumi:`
- types that no plugin priced are left out of the entries, and a projected cache
- hit is re-keyed to the requesting resource's ID (identical resources share one
- cache entry). `update`,
- `replace`, and `create-replacement` price `OldProperties` then `Properties`.
- `delete` and `delete-replaced` price old properties and a `$0` after.
- `same` is priced once. Both sides use the same basis: `OldProperties` is outputs
- merged under the old inputs (like `Properties`), and `ref.*` tags are resolved
- once over the whole plan (`resolveDiffRefTags`) and given to both descriptors,
- because the before and after slices hold different resources. A replacement's
- `create-replacement`, `replace`, and `delete-replaced` steps share a URN and
- are collapsed into one `update` entry first (`collapseReplacements`), or the
- old resource would be counted twice. `summary.totalMonthly` and budget evaluation use the
- after total. Table output is the diff table (TTY included). JSON adds
- `finfocus.diff`. NDJSON is one diff entry per line. `--show-breakdown`
- adds component sub-rows on that table only. Recommendations are merged
- onto the after cost and skipped for deletes
- - **Cost history** (`cost history collect|view|list`): snapshots live in
- `~/.finfocus/history/<project>@<stack>.history.db` (`@` cannot appear in a
- Pulumi name, so names never collide). The project is the `Pulumi.yaml` in the
- working directory or the middle part of `--stack org/project/stack`
- (`resolveHistoryTarget`). A database records its project in `meta` and
- `OpenCostDBFor` errors on a different one. A legacy `<stack>.history.db`
- (slashes become dashes) is kept by the project its snapshot URNs name
- (`CostDB.InferProject`), or adopted when they name none; any other project
- gets its own file. With no project, a bare `--stack` uses the legacy file,
- else a single `@` match, else errors. That file is not the
- resource-observation `history.db`. `collect`
- prices each successful `update` through `GetProjectedCostWithErrors` and
- stores the result; a successful `destroy` is `$0` with annotation
- `Stack destroyed` and does not call the pricer. `view` and `list` are
- read-only. A real plugin `$0` is stored. Adapter `none`, the unpriced note,
- or a result error fails that checkpoint (fail fast, per the #549 design: no
- inaccurate totals), and that includes a type an installed plugin declines.
- `pulumi:` internal types (provider resources are `custom: true`) are skipped
- before pricing, because the engine never prices them. `internal/history` must not import
- `engine` or `ingest` (`engine` already imports `history`). The parent
- `cost --stack` flag is reused; history subcommands do not redeclare it
- - **Cost history collect details**: `pulumiExporter.History` reads
- `pulumi stack history` page by page (page size 100), stops on a
- page with no new versions (a short page is not an end marker), and asks once without paging flags
- if the CLI rejects them, because the default page is 10 updates. `import` and
- `refresh` updates are not collected (a checkpoint is a deployment; their cost
- shows at the next update). `history list` skips an unreadable or locked database
- with a stderr warning (`ListCostDBsLenient`), and `Stats` reads only the key
- count and the two end snapshots. `view --provider` errors on an unknown provider.
- - **Cost history currencies**: `cost history view` warns and keeps the
- dominant currency (most snapshots; a tie keeps the newer timestamp, then
- the earlier code). `--currency` filters and does not warn. `--strict`
- returns `history.ErrMixedCurrencies`. An empty currency is `(unset)`.
- One currency produces no warning. `cost history export` uses the same
- default and prints the warning on stderr
- - **Cost history prune**: `cost history prune` deletes per-stack snapshots
- (`--keep`, `--older-than`, `--force`). `--dry-run` is the global ax flag.
- Compaction uses `bolt.Compact` after the database is closed. This is not
- `BoltStore.cleanupExpiredEntries`. `cost.history.retention.auto_prune`
- runs that policy after `collect` when the command loaded config
- - **Cost history export**: `cost history export` writes JSON, CSV, or NDJSON
- from the per-stack database. `--format` is the root ax flag. The command
- does not declare a second `--format`, because Cobra rejects the redefined
- flag. `csv` and `ndjson` are not agent modes (`ParseMode` allows `json` and
- `human` only). The export command's `Args` saves those two values and sets
- the flag to `json` before `ax.Execute` resolves the mode. Other commands
- still reject them. `--from`
- and `--to` are inclusive dates. `--provider` keeps every snapshot and sets
- `total_monthly` to that provider, or 0 when the snapshot has none.
- Sparklines are `history.Sparkline` (U+2581 through U+2588, width 7). A flat
- series is the low block. Empty input is an empty string. `cost projected`
- and `cost actual` add a Trend column only when that stack's history file
- exists. A missing file does not fail the cost command. There is no ntcharts
- dependency. The interactive `d` key from #550 is not part of this command
- - **Cost history diff**: `cost history diff` compares two stored snapshots
- by URN (`internal/history/diff.go`). It reads bbolt only. A cost move of
- $0.01 or less is unchanged. `--threshold` hides smaller impacts.
- `--from` and `--to` take `vN` or `YYYY-MM-DD` (nearest snapshot). Empty
- `--to` is the newest snapshot. The interactive `d` key from #550 is not
- part of this command
- - **Cost history in CI**: `cost history collect --versions` defaults to 0
- (every successful checkpoint not already stored). `--versions 1` keeps
- the newest. Recipes are
- `docs/src/content/docs/guides/ci-cd-cost-tracking.md`. There is no
- `docs/guides/ci-cd-cost-tracking.md` page. Release assets are versioned
- archives (`finfocus-v0.4.0-linux-amd64.tar.gz`); the install script is
- `scripts/install.sh`
- - **`cost projected --explain`** calls `GetPricingSpec` after
- `GetProjectedCostDiff` and stores the view on `DiffEntry.PricingSpec`
- (`pricing_spec` in JSON and NDJSON). It does not change `Monthly`. The
- default is off, and a disabled run does not call the RPC. A delete is
- explained from the old properties; every other operation uses the new
- ones. An RPC error or an empty spec omits the field and keeps the cost.
- The next selected plugin is tried when the first has no spec. Unpriced
- `pulumi:` rows are not explained because they are not diff entries
- - **`cost forecast`** prices the same inputs as `cost projected` and projects
- the after-change monthly cost with `pricing.ApplyGrowth` (finfocus-spec).
- The plugin response `growth_type` is copied onto `CostResult.GrowthType`.
- Projected cache keys end in `/growth-v1`, so an entry stored before that
- field existed is not reused as a flat forecast. `--terraform-state` resolves
- types before pricing, the same way `cost projected` does. A blank currency
- is USD, so it does not join an EUR total. A global budget in another
- currency is omitted with a warning.
- (`none`, `linear`, `exponential`, or empty). The response has no rate.
- `--growth-rate` is that rate. `--growth-type` overrides every resource.
- Linear is `base * (1 + rate * month)`. Exponential compounds. Month 0 is
- the current UTC month. `--months` is 1–36 and means months ahead, so the
- series has months+1 points. A missing rate on a plugin linear or
- exponential resource stays flat and is warned. `--growth-type linear`
- without a rate is an error. Two currencies fail. `ERROR:` and
- `VALIDATION:` results are omitted. A negative or non-finite point is
- clamped to zero. Plain output is an asciigraph chart (budget line from
- the global budget, `--split-providers` for provider series). JSON and
- NDJSON emit timestamped `forecast.Series` values (`forecast`, provider
- names, and `history` when `--stack` has a same-currency cost-history
- database). A missing history file does not fail the command. The series
- is the input for the interactive history chart (#550). This command does
- not import ntcharts and does not change `cost projected` monthly totals.
- Spec: `specs/626-cost-forecast/`
- - **Plugin pricing spec fallback** is off by default. `newEngineWithCache`
- copies `cost.pricing_spec_fallback`. `cost projected --pricing-spec-fallback`
- overrides that for the command when the flag is set. When on, a resource
- whose plugins returned no projected price is priced from `GetPricingSpec`
- before local YAML. Disabled runs do not call the RPC. Notes start with
- `Calculated from plugin pricing spec` and the adapter is `plugin-spec`.
- A `$0` rate is a priced result. Unknown billing modes and RPC errors fall
- through to YAML. Each `GetPricingSpec` call (fallback and `DiscoverPricingSpec`)
- has a 5s deadline (`perResourceTimeout`). A chain that stopped at a plugin
- whose router `Fallback` is false asks no plugin for a spec. With the flag on,
- projected cache keys end in `/pricing-spec`, so a run with it off never reads a
- `plugin-spec` result (the exported `ProjectedResourceCacheKey` stays the
- default key). `per_hour` and `per_cpu_hour` use 730 hours (the AWS Pricing Calculator
- basis, 365 × 24 / 12), `per_day` uses `daysPerMonth` (30, the month AWS's billing
- examples use: the ELB page's `$/hour * 24 hours * 30 days` and CloudWatch's
- `30 days * 24 hours = 720 hours`). Do not "fix" the 720 against 730 by arithmetic:
- both are AWS's own conventions for different purposes. `per_gb_month`
- multiplies by storage size or 1 GB. A billing mode the engine does not know that
- still has a usable unit, such as `per_hour_plus_data`, is priced from the unit
- alone and the note says `<mode>: hourly rate only, other charges not included`.
- A tiered spec is graduated, as finfocus-spec defines it
- (`docs/ADVANCED_PATTERNS.md`: usage in each tier is `min(usage, max) - min`; the S3
- example is "first 50 TB at $0.023, next 400 TB at $0.022"): each tier bills the part of
- the quantity inside it (`graduatedTierCost`). Tiers whose ranges do not cover the
- whole quantity (a gap, a first tier above zero, a bounded last tier below the
- quantity) are unusable and fall through, since the uncovered part has no rate. A `$0` rate stays a price, so a plugin must answer `zero_cost` or an error
- for "no price", not a zero rate (#1639 reports aws-public returning a zero
- `per_hour` rate for a type it does not find, which core prices as `$0`; unverified
- here, since it is a plugin-side issue)
- - **Estimate TUI pricing discovery** calls `GetPricingSpec` when
- `cost estimate --interactive` starts. `DiscoverPricingSpec` caches by
- resource type, SKU, and region on the engine for that session, so an edited SKU
- asks the plugin again. The view lists each plugin
- billing mode, its tiers, assumptions, and usage hints. Left and right move
- between modes. `not_implemented`, an empty mode, and RPC errors hide that
- section and leave property editing in place. The lookup does not replace
- `EstimateCost`
- - **Budget health thresholds**: OK (<80%), WARNING (80-89%), CRITICAL (90-100%),
- EXCEEDED (>100%). Aggregation uses worst-case status
- - **Cache hits**: Append `(cached)`, including the leading space, to the Adapter field for visual feedback
- - **Cache corruption**: Auto-detected and auto-recovered (delete + recreate)
- - **Plugin TTL hints**: Plugins can set `expires_at` on responses to control per-entry
- cache TTL. The engine extracts `ExpiresAt` from `CostResult`, calls
- `cache.CalculatePluginTTL()`, and uses `SetWithTTL()` when a plugin hint is present.
- Past timestamps skip caching entirely. TTLs exceeding `MaxTTLSeconds` (604800 = 7 days)
- are capped. Debug logs record TTL overrides; warn logs record caps and skips
- - **Optional LRU tier**: `cost.cache.lru_enabled` (default false) wraps the
- BoltDB cache with `cache.TieredStore` inside `initCacheFromConfig`, so every
- `newEngineWithCache` caller gets it. Reads hit memory first and promote a
- disk hit. Writes update disk, then refresh memory. Expired memory entries
- fall through. `lru_max_items` of 0 uses 256. `FINFOCUS_CACHE_LRU_ENABLED`
- and `FINFOCUS_CACHE_LRU_MAX_ITEMS` override the file. A wrap failure keeps
- the Bolt store. The engine field stays `cache.Cache`
- - `checkPluginSupports` sends the descriptor `proto.PrepareProjectedDescriptor` builds
- (the same one `GetProjectedCost` sends, including a region inherited from a
- referenced resource, and its attributes), and caches per
- client+provider+type+region+sku+feature, plus an attributes digest when the resource
- has attributes (SKU is part of the key: a first SKU-less
- resource in a region must not poison the cached answer for every other SKU there);
- plugins on finfocus-spec ≥ v0.6.2 answer `Supports` for real, so a region-bound plugin
- (aws-public) declines other regions instead of being called and failing. A plugin that
- never implements `SupportsProvider` gets the SDK's generic fallback response
- (`Supported:false`, `Reason: pluginsdk.DefaultSupportsNotImplementedReason`); the engine
- treats that reason as fail-open (cached `true`), same as an RPC error, so such a plugin
- isn't silently dropped from routing
- - **Recommendations**: `convertProtoRecommendation` keeps the full plugin record
- (`ID`, category, priority, confidence, source, metadata, `ImpactDetail`, `ResourceInfo`,
- `ActionDetail`, primary/secondary reasons);
- `Recommendation.ID` is the dismissal ID. The cache key is
- `recommendations/multi/{types}/{hash}` where the hash covers resource id/provider/type/
- properties plus dismissed IDs (`cache.HashRecommendationInputs`). `--include-dismissed`
- sets `include_dismissed` on the plugin request and still sends the host's excluded IDs,
- so an older plugin keeps hiding host dismissals. That fetch suffixes the cache key with
- `/include-dismissed`. The default key is unchanged. Spec: `specs/622-include-dismissed/`
- and finfocus-spec `specs/599-include-dismissed/`. Resources are routed per
- plugin via `routeRecommendationTargets` (router feature `Recommendations`)
- - **Recommendation scoring** (`internal/scoring`, opt-in via `scoring.*` config, off by default):
- `scoring.Service.Score` runs after the fetch in `cost recommendations`, sends recommendations
- to a scorer plugin (`PLUGIN_CAPABILITY_RECOMMENDATION_SCORING`, normalized
- `recommendation_scoring`) through `pbc.RecommendationScorerServiceClient`. Core applies
- `identifier_mode` (default pseudonymized: per-request HMAC key, opaque `rec-N` ids, raw ids
- scrubbed from text) and `field_allowlist` before sending; identifier handling also covers
- identifiers inside `action_detail` (Kubernetes cluster/namespace/controller/container names) and
- free text there; the first call probes batch size
- (20, halving on INVALID_ARGUMENT), later batches run up to 8 concurrent. Scorer failure only
- warns. Scores are cached in the `scores` bucket (extracted values only). Scores never dismiss
- or hide anything. Scorer-only plugins are skipped by `routeRecommendationTargets`.
- Cost features (`ProjectedCosts`, `ActualCosts`, `BatchCost`) drop scorer-only clients inside
- `selectPluginMatchesForResource` before `Supports`, including router matches, so they never
- appear in unpriced decline notes. State-based actual-cost estimation uses the same skip
- before `GetProjectedCost`. Plugins with no capabilities, and plugins that also report
- `recommendations`, stay offered. Recommendations selection is unchanged
+ - `hoursPerMonth = 730`. Pricing-spec `per_day` uses 30 days (720 h). Do not
+ "fix" one against the other: both are AWS's own conventions.
+ - JSON/NDJSON error codes are `PLUGIN_ERROR`, `VALIDATION_ERROR`,
+ `TIMEOUT_ERROR`, and `NO_COST_DATA` (`types.go`); `NO_COST_DATA` is not an
+ error.
+ - A failed plugin call keeps the gRPC status as `<Code>: <message>`
+ (`pluginRPCError`). Only an empty projected result is `no cost data
+ available`. Core does not read `aws:region` from provider or stack config.
+ - Projected diff (`cost projected`): replacement steps sharing a URN are
+ collapsed into one `update` (`collapseReplacements`), or the old resource
+ counts twice. Both sides use the same basis and the same plan-wide `ref.*`
+ resolution. Unpriced `pulumi:` types are left out.
+ - Cost history (`internal/history`, `cost history *`): files are
+ `<project>@<stack>.history.db`, separate from the resource-observation
+ `history.db`. `collect` fails a checkpoint rather than store an inaccurate
+ total (#549). `internal/history` must not import `engine` or `ingest`.
+ History subcommands reuse the parent `--stack`. `cost history export` takes
+ `csv`/`ndjson` through the root `--format` by rewriting it in `Args` before
+ `ax.Execute` resolves the mode. `pulumi stack history` is read in pages of
+ 100 until a page adds no new version; a short page is not the end, and the
+ CLI's default page is only 10 updates.
+ - `--explain`, `--pricing-spec-fallback`, and estimate-TUI discovery call
+ `GetPricingSpec`; disabled runs never call it, and each call has a 5s
+ deadline. A `$0` rate is a price; a plugin must answer `zero_cost` or an
+ error for "no price". Tiers are graduated, and tiers that do not cover the
+ whole quantity fall through to YAML.
+ - `cost forecast` (`specs/626-cost-forecast/`) does not change `cost projected`
+ totals. A blank currency is USD.
+ - `checkPluginSupports` caches per client, provider, type, region, SKU,
+ feature, and attributes digest. SKU must stay in the key, or one SKU-less
+ answer poisons every SKU in that region. The SDK's "not implemented" reason
+ is fail-open, like an RPC error.
+ - Plugin `expires_at` sets a per-entry TTL (capped at 7 days; a past time skips
+ caching). A plugin whose price depends on its own environment must send
+ `expires_at` = now, because the cache key cannot see it.
+ - Cache hits append "(cached)", with a leading space, to the adapter. A
+ corrupt cache is deleted and recreated. `cost.cache.lru_enabled` wraps
+ Bolt in `cache.TieredStore`.
+ - Budget health: OK < 80%, WARNING 80-89%, CRITICAL 90-100%, EXCEEDED > 100%;
+ aggregation takes the worst.
+ - Recommendations: `Recommendation.ID` is the dismissal id. The cache key hashes
+ inputs plus dismissed ids; `--include-dismissed` still sends excluded ids and
+ adds `/include-dismissed`.
+ - Scoring (`internal/scoring`, off by default) pseudonymizes identifiers by
+ default, only warns on failure, and never dismisses or hides anything.
+ Scorer-only plugins are dropped from cost features before `Supports`.
### Overview Field Semantics (`internal/engine/overview_*.go`)
- Understanding what each field *means* prevents the most common overview bugs.
- Every field on `OverviewRow` has a specific temporal basis, population rule,
- and set of valid comparisons. Violating these invariants produces subtle bugs
- (misleading deltas, nonsensical drift, UI garbage).
-
- #### Cost Fields — Temporal Basis
-
- | Field | Struct | Temporal Basis | Unit | Source |
- | --- | --- | --- | --- | --- |
- | `MTDCost` | `ActualCostData` | Partial month (day 1 → today) | Dollars spent so far | Actual cost plugin |
- | `MonthlyCost` | `ProjectedCostData` | Full canonical month (730h) | Dollars if run all month | Projected cost plugin |
- | `ExtrapolatedMonthly` | `CostDriftData` | Full calendar month (28-31d) | Projected from MTD trend | Calculated by `CalculateCostDrift` |
- | `Delta` | `CostDriftData` | Full calendar month | ExtrapolatedMonthly - Projected | Calculated by `CalculateCostDrift` |
-
- **Key rule**: `MTDCost` and `MonthlyCost` are **different units**. You cannot
- subtract one from the other. To compare them, you must first extrapolate
- `MTDCost` to a full month using `getExtrapolatedActual()` (30-day standard)
- or `CalculateCostDrift()` (calendar-accurate).
-
- #### When Fields Are Nil vs Populated
-
- Fields start nil after merge and get populated during enrichment. What gets
- populated depends on the resource's `Status`:
-
- | Status | `ActualCost` | `ProjectedCost` | `CostDrift` | `PropertyDiffs` |
- | --- | --- | --- | --- | --- |
- | Active | Yes (has billing history) | Yes (current config) | Maybe (nil if < 10% or day < 3) | No (no changes) |
- | Updating | Yes (still running) | Yes (new config pricing) | Maybe | Yes (what changed) |
- | Replacing | Yes (old resource billing) | Yes (new resource pricing) | Maybe | Yes (what changed) |
- | Creating | No (doesn't exist yet) | Yes (new resource pricing) | No (no history) | No |
- | Deleting | Yes (still running) | No (will be removed) | No (no projection) | No |
-
- This table is the **source of truth** for which cost computations are valid
- per status. If a formula assumes a field is non-nil, check this table first.
-
- #### Delta Column — What It Means Per Status
-
- The "Delta" TUI column answers: "how will this change affect my monthly bill?"
- Use `CalculateRowDelta()` — it encodes status-aware logic:
-
- | Status | Delta Formula | Meaning |
- | --- | --- | --- |
- | Updating/Replacing | `projected - extrapolatedActual` | Cost impact of the config change |
- | Creating | `+projected` | New cost being added |
- | Deleting | `-extrapolatedActual` | Cost being removed |
- | Active (with drift) | `CostDrift.Delta` | How much actual spend deviates from projection |
- | Active (no drift) | `-` (no delta shown) | Spend is tracking projection (< 10% off) |
-
- #### Extrapolation — Two Methods, Intentionally Different
-
- | Function | Month Basis | Used For |
+ | Field | Basis | Source |
| --- | --- | --- |
- | `getExtrapolatedActual()` | 30-day standard | Delta calculations (consistent cross-month) |
- | `CalculateCostDrift()` | Calendar days (28-31) | Drift % (calendar-accurate precision) |
-
- Do not unify these. Delta uses 30-day for stable comparisons across months.
- Drift uses calendar days because a February drift % must account for 28 days.
-
- #### Drift Nil Cases
-
- `CostDrift` is nil (not populated) in these cases — all intentional:
-
- - **Day 1-2 of month** (`driftMinDay = 3`): insufficient data
- - **Drift < 10%** (`driftWarningThreshold`): not significant enough to show
- - **New resource** (has projected, no actual): nothing to extrapolate from
- - **Deleted resource** (has actual, no projected): nothing to compare against
- - **Recently created** (`CreatedAt` within billing window, < 3 days old)
-
- Code must always handle `row.CostDrift == nil` as a normal case, not an error.
-
- #### Pulumi Plan Data — What to Filter
+ | `MTDCost` | Day 1 to today (spent so far) | Actual plugin |
+ | `MonthlyCost` | Full 730 h month | Projected plugin |
+ | `ExtrapolatedMonthly` | Calendar month from the MTD trend | `CalculateCostDrift` |
+ | `Delta` (drift) | `ExtrapolatedMonthly - MonthlyCost` | `CalculateCostDrift` |
- `PulumiStep.OldState.Inputs` and `NewState.Inputs` contain both user-specified
- properties and Pulumi internal metadata. When displaying to users:
+ - `MTDCost` and `MonthlyCost` are different units; never subtract them without
+ extrapolating first.
+ - Population by status:
- - **Filter keys prefixed with `__`** (e.g., `__defaults`, `__provider`) —
- these are Pulumi SDK internals, not user properties
- - **Truncate values** in TUI to prevent wrapping (max 40 chars via
- `truncateDiffValue()`) — Pulumi inputs can contain large arrays/objects
- - **PropertyDiff data flow**: Plan JSON → `diffInputs()` (CLI) →
- `PlanStep.PropertyDiffs` → merge → `OverviewRow.PropertyDiffs` → TUI view
+ | Status | Actual | Projected | Drift | PropertyDiffs |
+ | --- | --- | --- | --- | --- |
+ | Active | yes | yes | maybe | no |
+ | Updating / Replacing | yes | yes | maybe | yes |
+ | Creating | no | yes | no | no |
+ | Deleting | yes | no | no | no |
- #### State-Only Mode
+ - The Delta column always comes from `CalculateRowDelta()`, which is
+ status-aware; never compute it inline. Update/replace is projected(after)
+ minus the baseline projected(current) (`GetBaselineProjectedMonthlyCost`),
+ the same basis. It falls back to extrapolated actual only without a
+ baseline, and shows no delta without property diffs. Creating is
+ `+projected`, deleting is `-extrapolatedActual`, active is the drift delta.
+ - `GetExtrapolatedActual()` and `ForceExtrapolateActual()` use 30 days
+ (stable deltas); `CalculateCostDrift()` uses calendar days. Do not unify
+ them.
+ - `CostDrift == nil` is normal: day 1-2, drift under 10%, new or deleted, or
+ created under 3 days ago.
+ - TUI diff values are truncated to 40 characters (`truncateDiffValue`). State-only
+ mode marks projected costs with `*`; `p` runs a preview and updates rows in
+ place.
- When no preview is provided, overview shows state resources with `*` footnote
- on projected costs. The `p` key triggers on-demand preview; when it completes,
- `ApplyChangesToRows()` and `ApplyPropertyDiffsToRows()` update rows in-place.
+ ### Cluster Allocation (`internal/engine/cluster*.go`, `plugins/`)
- ### Cluster allocation (`internal/engine/cluster*.go`, `plugins/kubernetes/`)
+ Specs 613-623 hold the details. Invariants:
- - **Core never interprets Kubernetes**: nodes arrive from `GetStats` as ordinary
- `ResourceDescriptor`s (`sku`/`region` passed as properties) and are priced by
- the engine; grouping is string-map aggregation. Run-rate uses
- `GetProjectedCostWithErrors` and `Monthly`. A window (`From` and `To` both
- set, end after start) uses `GetWindowCost` and copies `TotalCost` only.
- `Monthly` on that path is ignored. Exactly one bound errors before
- `GetStats`. A window whose stats mode is not historical is
- `ErrStatsModeMismatch` and the text names both modes. No window plus
- historical stays `ErrHistoricalUnsupported`
- - **Historical unpriced**: a missing window result, a result error, or
- `TotalCost <= 0` is unpriced and sets `Incomplete`. Projected pricing is
- not called. Every node unpriced is still fatal. Mixed currencies and
- `ErrConservation` are unchanged
- - **Historical pricing**: a window calls `Engine.GetWindowCost`, which sets
- `SkipStateEstimate` and allocates `TotalCost`. No window stays
- `GetProjectedCostWithErrors` and `Monthly`. A stats warning whose text
- starts with `incomplete:` sets `ClusterResult.Incomplete`. The
- `plugins/prometheus` module must not import finfocus core packages or
- `plugins/kubernetes`
- - **Spot on-demand note**: `allocateNode` writes `spot node priced on-demand`
- only when `AllocateRequest.mode` is not `STATS_MODE_HISTORICAL`. The cost
- stays the value core put on `PricedResource`
- - **Prometheus node identity** (`plugins/prometheus/identity`,
- `specs/623-prometheus-usage-source/`): descriptor rules are copied from
- `plugins/kubernetes/usage/nodes.go`. That module is not imported.
- kube-state-metrics names (`label_` plus non-alphanumeric to `_`) round-trip
- only for the keys `NodeDescriptor` reads. `provider_id` comes from
- `kube_node_info`. Recorded identity wins over the live API. A node neither
- source can identify is omitted with
- `incomplete: node <name>: cannot determine provider, instance type, or region`.
- A kubeconfig failure warns `incomplete:`. Retention shorter than the window
- is detected store-wide from the earliest node allocatable sample
- (`promql.StoreStart`), not per pod: a pod's late first sample is not a gap. A fargate compute-type node is
- not an allocatable priceable, and no Fargate price is added. An EKS
- control-plane priceable is emitted only when the live client is connected
- and the API host matches the regex in `nodes.go`. Otherwise one warning
- without the `incomplete:` prefix. `SelectCluster` returns `InvalidArgument`
- when several `cluster` label values are present and the scope names none of
- them. The error lists those values
- - **Node identity** (#1588): the allocator keys a node by cluster and node
- name joined with NUL, same as the workload key. The priceable `id` stays
- the Kubernetes node name so stats validation and the idle row still match
- it. `NodeDescriptor` copies the kubeconfig context onto tags `cluster` and
- `node`. A priced node with no `cluster` tag still joins on the node name.
- Two priced entries that share `kind=node` and `id` are rejected by the SDK
- before allocation; a multi-cluster request gives each one its own id and
- keeps the Kubernetes name on the `node` tag
- - **`$0` price = unpriced**: aws-public returns `$0` (not an error) for unknown
- instance types, so `priceResources` treats `Monthly <= 0` as unpriced
- - **Conservation is enforced in core** (`VerifyConservation`, rel 1e-6, delegating to
- `pluginsdk.CheckConservation`) even though the conformance suite also checks it —
- third-party allocators exist, and one shared SDK rule keeps core and plugins in agreement
- - **`plugins/kubernetes` is a nested module** and must not import core packages
- (`make check-plugin-boundaries`); it declines `Supports` so `cost projected`
- skips it (finfocus-spec ≥ v0.6.2 delivers plugin `Supports` answers to hosts;
- earlier SDKs errored and the engine failed open)
- - **Allocator policy v2** (`specs/614-allocator-policy-v2/`): `idle` and
- `system_workloads` accept `share` as well as the default `separate`. Idle
- CPU and memory are shared in proportion to each workload's already-computed
- CPU cost and memory cost. System rows (`namespace=kube-system` or
- `controller_kind=DaemonSet`) are then folded into the other workloads on
- the same node. The idle row stays even at `$0`, because
- `ValidateAllocateResponse` requires exactly one idle row per priced node.
- When both fields are `share`, idle is distributed first (including onto
- system rows) and system rows are folded after that
- - **EKS Fargate pods** (`specs/615-eks-fargate-pods/`): a node labeled
- `eks.amazonaws.com/compute-type=fargate` still has no capacity rows and no
- node descriptor. Each running pod on it is a priceable
- `aws:eks/fargate:Pod` (sku `fargate`, tags `kind=fargate`, `cpu`,
- `memory_gib`). The allocator assigns that priced cost to the pod and never
- folds it into `__idle__`. `Monthly <= 0` stays unpriced (`Fargate pod has
- no price`, or the pricer's note). A `fargate-` node name with no fargate
- priced entry still notes `Fargate pricing not supported yet`. Rates are
- aws-public's job
- ([finfocus-plugin-aws-public#409](https://github.com/rshade/finfocus-plugin-aws-public/issues/409));
- kind cannot simulate Fargate, so `make test-e2e-kind` does not cover it
- - **Projected workload pricing** (`specs/621-k8s-workload-projected-cost/`):
- `plugins/kubernetes/workload` prices Deployment, StatefulSet, DaemonSet,
- Job, and CronJob from `attributes` at rates from
- `FINFOCUS_KUBERNETES_CPU_HOURLY_RATE` and
- `FINFOCUS_KUBERNETES_MEMORY_GIB_HOURLY_RATE`, with
- `FINFOCUS_KUBERNETES_DAEMONSET_NODE_COUNT` and
- `FINFOCUS_KUBERNETES_JOB_HOURS_PER_MONTH` as hints. `Supports` and
- `GetProjectedCost` share `workload.Estimate`. A `Supports` decline reason
- (at most 160 characters) is kept in a `NO_COST_DATA` note, like a
- `GetProjectedCost` error's text (see Engine). The
- per-pod request reuses `usage.EffectiveRequests`, with limits standing in
- for absent requests first. Prices carry `expires_at` = now because the
- core cache key cannot see the plugin's environment; without it a rate change
- kept the old cached price
- - **Pulumi URN linking** (`specs/616-k8s-pulumi-urn-linking/`): the provider
- does not write a URN onto objects. Users set annotation
- `finfocus.dev/pulumi-urn` (not a label: URN values contain `:` and exceed
- 63 characters). The collector copies it to subject
- `label.finfocus.dev/pulumi-urn`, which stats validation allows. `cost
- cluster --group-by pulumi-stack` groups by `<stack>/<project>`; a missing
- or malformed value is `<none>`. Group JSON includes `pulumi_urns`
+ - Core never interprets Kubernetes. Nodes arrive from `GetStats` as ordinary
+ descriptors and are priced by the engine; grouping is string-map aggregation.
+ - Run-rate prices with `GetProjectedCostWithErrors` and `Monthly`. A window
+ (`--from`/`--to`) prices with `GetWindowCost` and `TotalCost` only, labels the
+ period with `FormatWindow` (not `FormatPeriod`, which `cost actual` keeps),
+ and requires a source in historical mode (`ErrStatsModeMismatch`).
+ - A `$0` or missing price is unpriced (aws-public returns `$0` for unknown
+ types). All nodes unpriced is fatal. A stats warning starting `incomplete:`
+ sets `Incomplete`.
+ - Conservation is enforced in core (`VerifyConservation`, shared SDK rule)
+ even though the conformance suite checks it too.
+ - Nodes are keyed by cluster and node name; the priceable `id` stays the
+ Kubernetes node name.
+ - The idle row stays at `$0`: `ValidateAllocateResponse` needs one per priced
+ node. Fargate pods are their own priceables and never fold into idle.
+ - `plugins/kubernetes` and `plugins/prometheus` must not import core or each
+ other. Prometheus node identity copies the descriptor rules from
+ `plugins/kubernetes/usage/nodes.go`.
+ - Prometheus collection (`plugins/prometheus/`):
+ - Take `max by (node)` of kube-state-metrics capacity before integrating.
+ Replicas export the same node twice, and a sum doubles capacity.
+ - cAdvisor scraped from the kubelet often has no `node` label. The node
+ then comes from `kube_pod_info`, and matching must accept the empty label.
+ - Clusters are discovered from `kube_node_labels` or
+ `kube_node_status_allocatable` before any sum drops the label.
+ - Retention shorter than the window is detected store-wide
+ (`promql.StoreStart`), not per pod. A hole shorter than the 5m lookback is
+ not detected.
+ - The bearer token goes only to the configured scheme and host (a redirect
+ elsewhere would receive it otherwise), and URLs in errors and logs drop
+ userinfo and query.
+ - Pod label keys from kube-state-metrics are sanitized
+ (`app.kubernetes.io/name` becomes `app_kubernetes_io_name`).
- ### MCP Server (`internal/cli/mcp.go`, `internal/cli/output_mode.go`)
+ ### MCP Server (`internal/cli/mcp.go`, `output_mode.go`)
- - **Per-call lifecycle**: ax-go's dispatcher re-executes the *shared* root once
- per `tools/call` (nested `ExecuteContext`), so root `PersistentPreRunE`/
- `PersistentPostRunE` run per call. `commandLifecycle.serving` marks dispatched
- calls: they reuse the server's `loggingSession` via `attach` and never close
- it. Opening/closing logging per call previously leaked handles and ended in
- `close .../finfocus.log: file already closed`. `loggingSession.Close` is
- idempotent. Config resolution still runs per call
- - **Serialized dispatch**: one mutex covers every call, so a blocking command
- stalls the whole server. That is why `analyzer serve` is excluded
- - **Output resolver**: `resolveOutputFormat` gives an explicitly set `--output`
- first priority, then `--format` (the dispatcher injects `json` on every call),
- then `AGENT_MODE`, resolved via `ax.ResolveMode(..., stdoutIsTTY=true)`, else the
- flag default. It deliberately ignores `ax.ModeFromContext` and TTY detection:
- those report JSON whenever stdout is piped, which would flip redirected human
- output. New commands with an output flag must call it,
- and commands without one should emit JSON when `machineOutputRequested(cmd)`
- - **Flag stickiness**: the dispatcher resets flag values *and* `Changed` per
- call, but only on a real server. Nested `ExecuteContext` in unit tests does
- not reset, so assert cross-call `--output` behavior in the integration test
- - **Exclusion**: `applyMCPExclusions` marks `mcpExcludedCommands` (root,
- `analyzer serve`, `cost history collect` (minutes-long, would stall the serialized
- dispatcher), `setup`, `plugin init`) with ax-go's node-only `mcp.Exclude`
- at tree build time; they stay in `--help`. ax-go (v0.7.0+) skips `help` and
- non-runnable groups itself. Never use `Hidden` to exclude a root/group: ax-go
- prunes the whole subtree. `withPositionalCommandsHidden` still hides
- positional-arg leaves while an MCP tool list is built so the static
- `__schema --as=mcp` matches the live server, which cannot pass positional args
- - **Tool allow-list golden**: `internal/cli/testdata/mcp/tools.golden` pins the
- tool list for both the static schema (unit) and live `tools/list` of both
- entry points (`test/integration/mcp_server_test.go`). A new command must make
- an expose-or-exclude decision; regenerate with `UPDATE_GOLDEN=1`
- - **Version**: the MCP handshake rejects `dev`/`unknown` versions, so binaries
- built for MCP tests need `-ldflags -X .../pkg/version.version=...`
+ - ax-go re-executes the shared root once per `tools/call`, so persistent
+ pre/post run per call. Dispatched calls reuse the server's
+ `loggingSession` and never close it.
+ - One mutex serializes every call; a blocking command stalls the server. That
+ is why `analyzer serve` and `cost history collect` are excluded
+ (`mcpExcludedCommands`, `mcp.Exclude`). Never use `Hidden` to exclude a
+ group: ax-go prunes the whole subtree.
+ - `resolveOutputFormat`: explicit `--output`, then `--format`, then
+ `AGENT_MODE`. It ignores TTY detection on purpose. New commands with an
+ output flag must use it.
+ - Flag values reset per call only on a real server; assert cross-call
+ behavior in the integration test.
+ - `internal/cli/testdata/mcp/tools.golden` pins the tool list. A new command
+ must be exposed or excluded on purpose (`UPDATE_GOLDEN=1`).
+ - The MCP handshake rejects `dev`/`unknown`; build test binaries with
+ `-ldflags -X .../pkg/version.version=...`.
### Plugin Upgrade (`internal/pluginupgrade/`, `agent-skills/finfocus-plugin-upgrade/`)
- - **Bumping finfocus-spec in core needs a hop**: `TestHopsReachCoreSpecVersion`
- fails until `hops.go` has an entry whose `To` is at least
- `pluginsdk.SpecVersion`, and `TestHopsMatchGuides` requires a matching
- `references/to-vX.Y.Z.md`. Add both in the bump PR, even for an additive
- release (a guide that says "no changes required" is fine). The v0.7.5 hop
- is that kind of release: handler gRPC statuses pass through (a wrapped
- status sends only its own message; plain errors stay Internal),
- `include_dismissed` is field 8, and `WithSampleResource` is optional. Do
- not return an upstream status as the handler result
- - **Nested modules must match the root finfocus-spec pin**: CI validation
- compares `go.mod` with `plugins/kubernetes/go.mod` and `plugins/jev/go.mod`
- and fails when `github.com/rshade/finfocus-spec` differs. A root pin bump
- updates both modules even when they do not use the new field.
- `pluginsdk.SpecVersion` stays put until a release moves it, so a
- pseudo-version pin does not add a hop
- - **Hops are releases that need author action, not minor lines**: v0.5.7 (a
- patch) changed `HandleDryRun`; v0.6.1 raised Go to 1.27.1
- - **`SpecVersion` must be `v`-prefixed**: `pluginsdk.ValidateSpecVersion`
- rejects `"0.6.1"`, so `GetPluginInfo` returns `Internal` and `plugin list`
- shows `N/A`. `plugin init` now writes `SpecVersion = pluginsdk.SpecVersion`;
- scaffolds from v0.4.0 and earlier have a bare literal, which the upgrade
- rewrites
- - **Minimum supported version is v0.5.0**; there are no pre-rename plugins
- - **The scaffold's finfocus-spec pin is `pluginsdk.SpecVersion`** (#248), so
- bumping finfocus-spec in core also moves new plugins.
- `TestPluginInitTracksCoreSpecVersion` keeps it at v0.7.1 or newer and checks
- that a fresh scaffold needs no `plugin upgrade`; the scaffold integration
- test proves the templates still compile against it
- - **`plugin.manifest.json` is not the pluginsdk manifest**: `plugin validate`
- parses it as the flat `registry.Manifest` (`name`, `version`, ...) and
- requires both to match the install directory. The scaffold's `manifest.json`
- (`pluginsdk.SaveManifest`, nested `metadata`/`specification`) fails to parse
- there, so the generated `make install` writes the flat file with `printf`.
- `test/integration/plugin/scaffold_build_test.go` builds, installs, and
- validates a generated project
- - Rebuild `agent-skills/finfocus-plugin-upgrade/finfocus-plugin-upgrade.skill`
- (a zip of `SKILL.md` + `references/`) after editing either
- - **Skill install (`internal/pluginskill/`)**: `plugin init` and `plugin upgrade`
- run `npx -y skills@1.7.0 add <finfocus tree URL> --skill finfocus-plugin-dev
- --skill finfocus-plugin-upgrade --agent codex --agent claude-code --copy -y`
- in an empty temp directory, then copies the two skill directories into the
- plugin and merges their `skills-lock.json` entries. Never run npx in the
- plugin directory: npx honors its `.npmrc` (registry) and `node_modules`
- (local `skills` package), so an untrusted checkout could run its own code.
- The copy and the lockfile merge write through an `os.Root` on the plugin
- directory, so a symlinked `.agents`, `.claude`, or `skills-lock.json` cannot
- redirect a write outside the checkout.
- Without `--agent` the skills CLI writes a copy for
- every agent it detects (`.bob`, `.grok`, ...). The source is the release tag
- only for a clean semver newer than v0.4.0 (the last tag without the skill),
- else `main`. Upgrade re-runs `add`, not `skills update`, because `update`
- moves to `main`. Every failure is a warning, never an error. Tests must never
- run npx: `internal/cli` swaps the installer in `TestMain` (`main_test.go`,
- `export_test.go`), and `test/integration/plugin` passes `--no-skill`. Spec:
- `specs/620-plugin-dev-skill/`
-
- ### Integration Tests (`test/integration/`)
-
- - **The CLI helper must go through `ax.Execute`**: `helpers.CLIHelper.Execute`
- runs commands via `ax.Execute`, the same entry point as `cmd/finfocus`. Calling
- `cli.NewRootCmd().Execute()` directly does NOT mount the persistent agentic flags
- (`--format`, `--dry-run`, `--yes`, `--idempotency-key`), so those flags fail with
- "unknown flag" in integration tests while working in production and in unit tests
- (which use `axtest.Run`). `ax.Execute` returns an exit code rather than an error,
- so the helper converts non-zero codes back into an error from stderr
- - **Many integration tests do not isolate `HOME`**: tests that call
- `helpers.NewCLIHelper(t)` without `WithEnv` read the developer's real
- `~/.finfocus/config.hujson`, so local config can change their results. (A local
- `cost.budgets` entry used to inject a "BUDGET STATUS" banner into JSON/NDJSON
- output and fail ~57 tests locally; the banner now renders only for table output.)
- Always diff the integration failure *set* against a pre-change baseline rather
- than comparing failure counts
- - **Overriding `HOME` for a `go`/`make` child moves Go's caches**: when `GOPATH`
- is not exported (CI), `HOME=<t.TempDir()>` puts the module cache under the
- temp dir. Its files are read-only, so `t.TempDir` cleanup fails with
- `permission denied` (green locally where `GOPATH` is set). Pass the real
- `GOMODCACHE`/`GOCACHE` from `go env`, as `scaffold_build_test.go` does.
- Reproduce with `env -u GOPATH go test ...`
- - **`config.yaml` fixtures are intentional**: most `config.yaml` references in
- `test/integration/` write legacy YAML as *input* to exercise auto-migration. Only
- assertions that `config init` *creates* a file should expect `config.hujson`
+ - Bumping finfocus-spec needs a hop: `hops.go` must reach
+ `pluginsdk.SpecVersion` and have a `references/to-vX.Y.Z.md` guide (tests
+ enforce both), even for an additive release. Add the guide's row to
+ `SKILL.md` and rebuild the `.skill` zip (`SKILL.md` + `references/`).
+ - `SpecVersion` must be `v`-prefixed, or `GetPluginInfo` returns `Internal`.
+ - `plugin.manifest.json` is the flat `registry.Manifest`, not the pluginsdk
+ manifest; `plugin validate` needs name and version to match the install dir.
+ - Skill install runs `npx skills@1.7.0 add` in an empty temp dir, never the
+ plugin dir (its `.npmrc` and `node_modules` could run code), and writes
+ through an `os.Root`. Every failure is a warning. Tests never run npx.
## Recent Changes
- - 626-cost-forecast: `cost forecast` projects plugin GrowthType with finfocus-spec `pricing.ApplyGrowth` and emits timestamped series for a later interactive chart. No new module.
- - 625-budget-alert-notifications: Added Go 1.27.1 (see `go.mod`) + stdlib `net/http`, `encoding/json` (Slack and generic HTTPS webhook budget alert destinations, no new modules)
- - 623-prometheus-usage-source: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.7.5 (historical stats mode, `UnitCoreHours` / `UnitGiBHours` already released), Cobra, gRPC pluginsdk, `github.com/prometheus/client_golang` query API (plugin module only), client-go (live node-identity fallback only), kind + kubectl (existing E2E), testify
- - 622-include-dismissed: `cost recommendations --include-dismissed` sets `GetRecommendationsRequest.include_dismissed` (finfocus-spec field 8) and still sends excluded IDs; the cache key gains `/include-dismissed`
- - 621-k8s-workload-projected-cost: finfocus-spec v0.7.3 (`ResourceDescriptor.attributes`); core sends redacted attributes; the kubernetes plugin prices declared workloads
- - 608-batch-cost-consumer: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions with `BatchCost` RPC), Cobra (CLI), gRPC, zerolog (logging)
- - 608-resource-history-store: Added Go 1.27.1 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`).
- - 608-estimate-cost-rpc: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions), gRPC, Cobra, zerolog
- - 607-state-only-flag: Added Go 1.27.1 (see `go.mod`) + Cobra (CLI framework), Bubble Tea (TUI), zerolog (logging)
- - 606-cache-expires-at: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (provides `expires_at` proto fields), BoltDB (cache storage), zerolog (logging)
+ - 626-cost-forecast: `cost forecast` projects plugin GrowthType with finfocus-spec `pricing.ApplyGrowth`
+ - 625-budget-alert-notifications: Slack and generic HTTPS webhook budget alert destinations (stdlib `net/http`)
+ - 623-prometheus-usage-source: Prometheus usage source plugin and historical `cost cluster` windows
## Active Technologies
- - Go 1.27.1 (see `go.mod`) + stdlib `net/http`, `encoding/json`, Cobra, ax-go, testify; no persistent state (625-budget-alert-notifications)
- - Go 1.27.1 (see `go.mod`) + finfocus-spec v0.7.5 (historical stats mode, `UnitCoreHours` / `UnitGiBHours` already released), Cobra, gRPC pluginsdk, `github.com/prometheus/client_golang` query API (plugin module only), client-go (live node-identity fallback only), kind + kubectl (existing E2E), testify (623-prometheus-usage-source)
- - Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions with `BatchCost` RPC), Cobra (CLI), gRPC, zerolog (logging) (608-batch-cost-consumer)
- - BoltDB cost cache (`~/.finfocus/cache/cache.db`) — `Engine.GetProjectedCost` caches batch projected results per-resource via `storeProjectedCostCache`; `Engine.GetActualCostWithOptions` caches actual-cost results by full request key via `storeActualCostCacheIfClean` (608-batch-cost-consumer)
- - Go 1.27.1 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`) (608-resource-history-store)
- - BoltDB at `~/.finfocus/history/history.db` (separate from cache) (608-resource-history-store)
- - Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions), gRPC, Cobra, zerolog (608-estimate-cost-rpc)
- - N/A (no new persistent state) (608-estimate-cost-rpc)
+ - Go 1.27.1 (`go.mod`), finfocus-spec v0.7.5 (`pluginsdk`), Cobra, ax-go, gRPC, zerolog, BoltDB (`go.etcd.io/bbolt`), Bubble Tea v2, testify; `prometheus/client_golang` and client-go in plugin modules only