finfocus-plugin-dev · git:20261003.811835d · 2026-10-03 · sha256 180d5d22d86b01ee

finfocus-plugin-dev git:20261003.811835dA

Immutable. This exact content never changes and is served at /api/v1/blob/180d5d22d86b01ee.

---
name: finfocus-plugin-dev
description: >
  Implement and maintain a FinFocus cost plugin in a repository scaffolded by
  `finfocus plugin init`. Use when adding or changing pricing logic in a
  plugin, adding a resource type, writing the Supports handler, returning
  GetProjectedCost or GetActualCost results, testing a plugin locally against
  a Pulumi plan, or installing a plugin build into `~/.finfocus/plugins`.
  Triggers on: "implement GetProjectedCost", "add a resource type to my
  plugin", "plugin Supports", "finfocus plugin", "pluginsdk", "finfocus-spec
  SDK", "test my plugin locally", "plugin pricing logic", "plugin
  expires_at", or any task inside a FinFocus plugin repo. For SDK version
  bumps use the finfocus-plugin-upgrade skill instead.
---
<!-- Copyright 2025-2026 Richard Shade. Licensed under Apache-2.0. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# FinFocus Plugin Development

A FinFocus plugin is a standalone binary that serves the `CostSourceService`
gRPC API from `github.com/rshade/finfocus-spec/sdk/go/pluginsdk`. The
`finfocus` CLI launches it, asks `Supports`, then calls `GetProjectedCost` (and
optionally `GetActualCost`, `GetRecommendations`, and others) per resource.
This repository was scaffolded by `finfocus plugin init`.

## Rules

- **Never hand-edit SDK-version state** (the `finfocus-spec` line in
  `go.mod`, `SpecVersion`, workflow pins). Run `finfocus plugin upgrade` and
  follow the `finfocus-plugin-upgrade` skill.
- **Never fabricate prices.** If the plugin cannot price a resource, return
  "not supported" from `Supports` or an error from `GetProjectedCost`. Do not
  guess, and do not return a default. The scaffold's example rates in
  `calculateEC2InstanceCost` are placeholders: replace them. Scaffolds from
  finfocus v0.4.0 and earlier also default a missing instance type to
  `t3.micro` and price unknown types at the `t3.micro` rate; remove both.
- Price from real data: a local pricing table with a documented source and
  date, or the provider's pricing API. Record the source in `billing_detail`.
- Do not run `git commit` or `git add` unless the user asks.
- `SpecVersion` must be `v`-prefixed (`v0.7.2`). Use `pluginsdk.SpecVersion`,
  as the scaffold does. Older scaffolds wrote a bare literal such as `"0.6.1"`;
  `finfocus plugin upgrade` rewrites it.
- Never write to stdout from the plugin: the SDK prints a single
  `PORT=<n>` handshake line there. Log to stderr or the SDK log writer.

## Repo layout (from `finfocus plugin init`)

| Path | Purpose | Edit? |
| --- | --- | --- |
| `cmd/plugin/main.go` | Builds `pricing.NewCalculator()` and calls `pluginsdk.Serve`; reads `--port` and `FINFOCUS_PLUGIN_PORT`; optional health server | Rarely |
| `internal/pricing/calculator.go` | `Calculator` embeds `*pluginsdk.BasePlugin`; `GetPluginInfo`, `Supports`, `GetProjectedCost`, `GetActualCost` | Yes, main work |
| `internal/pricing/data.go` | `PricingData` load/save helpers and example data | Yes, if you use local tables |
| `internal/client/client.go` | Provider API client stub (`GetResourceCost`, `ValidateCredentials`, `GetSupportedRegions`) | If you call live APIs |
| `internal/pricing/calculator_test.go` | Example tests using `pluginsdk.NewTestPlugin` | Yes, extend |
| `manifest.yaml`, `manifest.json` | Plugin metadata (name, version, providers, capabilities) | Keep in sync |
| `Makefile` | `build`, `test`, `lint`, `install`, `cover`, `security` | Rarely |
| `.golangci-lint.yml` | Lint config used by `make lint` | No |
| `.github/workflows/` | CI, release, release-please (docker, claude review when generated) | Via upgrade only |
| `docs/`, `docker/`, `issues.md`, `examples/` | Generated docs, image, TODO list | As needed |

Binary name is `finfocus-plugin-<name>`; `make build` writes it to `bin/`.
Details on the SDK are in `references/sdk-api.md`.

## Add a resource type end to end

1. **Find the real input.** Run a plan (see Testing loop) and read what the
   plugin receives. Core sends the Pulumi type token
   (`aws:ec2/instance:Instance`), `provider`, `sku`, `region`, and the
   resource inputs as `tags`. Scaffolds from v0.4.0 and earlier match
   `aws:ec2:Instance`, which core never sends; fix that case. Normalize the
   token in one function and test it.
2. **Register support.** Add the provider or resource type to the matcher in
   `NewCalculator` (`base.Matcher().AddProvider`, `AddResourceType`) and extend
   `Supports` (see below).
3. **Extract inputs.** Read SKU and region from `req.GetResource()`; fall
   back to `pluginsdk/mapping` helpers (`ExtractAWSSKU`, `ExtractAzureSKU`,
   `ExtractGCPSKU`, and the `*Region` forms) over `Tags`.
4. **Price it.** Compute an hourly rate and call
   `c.Calculator().CreateProjectedCostResponse(currency, hourly, detail)`,
   which sets `CostPerMonth = hourly * 730`. For a monthly price, build the
   response with `pluginsdk.NewGetProjectedCostResponse(
   pluginsdk.WithProjectedCostDetails(unit, currency, monthly, detail))`.
5. **Test.** Add a table-driven case for the new type, plus one unsupported
   and one missing-SKU case.
6. **Run the real loop** (Testing loop) and compare the number with the
   provider's public price page.
7. **Update** `manifest.yaml`/`manifest.json` capabilities if they changed and
   the README resource list.

## Supports contract

`Supports(ctx, *pbc.SupportsRequest) (*pbc.SupportsResponse, error)` is the
optional `SupportsProvider` interface.

- Answer `Supported: false` with a `Reason` rather than returning an error.
  Core treats an RPC error as fail-open (calls the plugin anyway).
- A plugin that does not implement `Supports` gets the SDK default
  (`Supported: false`, `Reason: pluginsdk.DefaultSupportsNotImplementedReason`).
  Core treats that reason as fail-open too, so implement `Supports` for real.
- Core sends provider, resource type, SKU, and region, and caches answers per
  client, provider, type, region, SKU, and feature. A region-bound plugin must
  decline other regions here so the router can pick another plugin.
- Keep it cheap and offline: no API calls.

## Cost response contracts

- `GetProjectedCost` returns `Currency`, `UnitPrice` (hourly), `CostPerMonth`,
  and `BillingDetail`. Use `BillingDetail` to state assumptions (OS, tenancy,
  defaults applied, price source).
- Validate with `pluginsdk.ValidateProjectedCostRequest` (needs provider, type,
  SKU, region). If tags contain `ref.*` keys and SKU is empty, core validates
  with `ValidateProjectedCostRequestLenient`; do the same, then price from the
  `ref.<property>.sku` tags or report what is missing in `BillingDetail`.
- `$0` is a valid priced result and does **not** trigger fallback to the next
  plugin; only an error or empty result does. Return `$0` only when the
  resource is truly free. Otherwise return an error, so another plugin can try.
- Unsupported resource: return `pluginsdk.NotSupportedError(resource)`.
  No billing data: `pluginsdk.NoDataError(id)`.
- Cache hints: set `ExpiresAt` with `pluginsdk.WithProjectedCostExpiresAt`.
  Past timestamps skip caching; core caps TTLs at 7 days.
- Optional extras (implement only if you can do them correctly):
  `GetRecommendations`, `GetPricingSpec`, `EstimateCost`, `BatchCost`,
  `DryRun`. See `references/sdk-api.md`.

## Testing loop

1. `make test` (unit tests, in-process gRPC via `pluginsdk.NewTestPlugin`).
2. Conformance: `pluginsdk.RunBasicConformance(plugin)` (also Standard and
   Advanced) in a test; see `references/testing.md`.
3. `make install` copies the binary to
   `~/.finfocus/plugins/<name>/<version>/`.
4. `finfocus plugin list` then `finfocus plugin validate`.
5. Price a real plan:
   `finfocus cost projected --pulumi-json plan.json` (create the plan with
   `pulumi preview --json > plan.json`). Add `--debug` to see plugin calls.
6. `make lint` before finishing.

More in `references/testing.md` and `references/core-contract.md`.

## Common pitfalls

- Core does not set `PORT`. Use `--port` or `FINFOCUS_PLUGIN_PORT`; the SDK's
  `Serve` handles both and announces the chosen port on stdout.
- Pulumi tokens vary (`aws:ec2/instance:Instance`); never match on a single
  hard-coded spelling.
- Nested inputs arrive as dotted tag keys (`sku.capacity`,
  `rootBlockDevice.0.volumeType`) beside the collapsed key.
- A mismatch between `PluginVersion`, `manifest.*` version, and the install
  directory makes `plugin list` confusing. Keep them equal.
- A `region` key in `plugin.metadata.json` makes core look for
  `finfocus-plugin-<name>-<region>` first. Region-per-binary plugins should
  follow that name.
- `make install` must write `plugin.manifest.json` as flat JSON whose
  `name` and `version` match the install directory, or `finfocus plugin
  validate` fails. Scaffolds from v0.4.0 and earlier copy `manifest.yaml`
  there instead; replace that line with the current scaffold's `printf`.