Skip to content

Plugin SDK Reference

This document provides complete API reference for the FinFocus Plugin SDK (github.com/rshade/finfocus-spec/sdk/go/pluginsdk). The SDK simplifies plugin development by providing interfaces, helper functions, and utilities for building cost source plugins.

  1. Core Interfaces
  2. Server and Serving
  3. Helper Types
  4. Manifest Management
  5. Testing Utilities
  6. Code Examples
  7. Dotted resource tags

The Plugin interface defines the contract for all FinFocus plugins.

type Plugin interface {
// Name returns the plugin name identifier.
Name() string
// GetProjectedCost calculates projected cost for a resource.
GetProjectedCost(
ctx context.Context,
req *pbc.GetProjectedCostRequest,
) (*pbc.GetProjectedCostResponse, error)
// GetActualCost retrieves actual cost for a resource.
GetActualCost(
ctx context.Context,
req *pbc.GetActualCostRequest,
) (*pbc.GetActualCostResponse, error)
}

Methods:

  • Name() string

    • Returns the plugin’s unique identifier
    • Must be lowercase, alphanumeric with hyphens
    • Example: "kubecost", "vantage-api"
  • GetProjectedCost(ctx, req) (resp, error)

    • Calculates projected monthly cost for a resource
    • Must return cost in specified currency
    • Required to implement plugin interface
  • GetActualCost(ctx, req) (resp, error)

    • Retrieves historical cost data
    • Returns time-series cost data
    • Can return NoDataError if not implemented

The Server wraps a Plugin implementation with gRPC handling.

type Server struct {
pbc.UnimplementedCostSourceServiceServer
plugin Plugin
}

Constructor:

func NewServer(plugin Plugin) *Server

Creates a new gRPC server wrapper for the provided plugin.

Methods:

  • Name(ctx, req) (*NameResponse, error)

    • Implements the gRPC Name RPC
    • Delegates to plugin.Name()
  • GetProjectedCost(ctx, req) (*GetProjectedCostResponse, error)

    • Implements the gRPC GetProjectedCost RPC
    • Delegates to plugin.GetProjectedCost()
  • GetActualCost(ctx, req) (*GetActualCostResponse, error)

    • Implements the gRPC GetActualCost RPC
    • Delegates to plugin.GetActualCost()

Configuration for serving a plugin.

type ServeConfig struct {
Plugin Plugin // Plugin implementation to serve
Port int // Port number (0 = auto-select)
}

Fields:

  • Plugin - The plugin implementation to serve
  • Port - TCP port (0 uses PORT env var or ephemeral port)

Starts the gRPC server for a plugin.

func Serve(ctx context.Context, config ServeConfig) error

Parameters:

  • ctx - Context for graceful shutdown
  • config - Server configuration

Behavior:

  1. Resolves port (config.Port → PORT env → ephemeral)
  2. Creates TCP listener on 127.0.0.1
  3. Prints PORT=<number> to stdout
  4. Registers plugin as gRPC service
  5. Serves until context is cancelled
  6. Performs graceful shutdown

Returns:

  • nil on clean shutdown
  • Error if server fails to start or serve

Example:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := pluginsdk.Serve(ctx, pluginsdk.ServeConfig{
Plugin: myPlugin,
Port: 0,
})
if err != nil {
log.Fatal(err)
}

Provides common functionality for plugin implementations.

type BasePlugin struct {
// Private fields
}

Constructor:

func NewBasePlugin(name string) *BasePlugin

Creates a base plugin with initialized matcher and calculator.

Methods:

  • Name() string

    • Returns the plugin name
  • Matcher() *ResourceMatcher

    • Returns the resource matcher for configuration
  • Calculator() *CostCalculator

    • Returns the cost calculator for helpers
  • GetProjectedCost(ctx, req) (resp, error)

    • Default implementation returning NotSupportedError
    • Plugins should override this method
  • GetActualCost(ctx, req) (resp, error)

    • Default implementation returning NoDataError
    • Plugins should override this method

Example:

type MyPlugin struct {
*pluginsdk.BasePlugin
}
func NewMyPlugin() *MyPlugin {
base := pluginsdk.NewBasePlugin("my-plugin")
base.Matcher().AddProvider("aws")
return &MyPlugin{BasePlugin: base}
}

Helps determine if a plugin supports a resource.

type ResourceMatcher struct {
// Private fields
}

Constructor:

func NewResourceMatcher() *ResourceMatcher

Methods:

  • AddProvider(provider string)

    • Adds a supported cloud provider
    • Examples: "aws", "azure", "gcp", "kubernetes"
  • AddResourceType(resourceType string)

    • Adds a supported resource type
    • Examples: "aws:ec2:Instance", "azure:compute:VirtualMachine"
  • Supports(resource *ResourceDescriptor) bool

    • Checks if the resource is supported
    • Returns true if provider and type match

Example:

matcher := pluginsdk.NewResourceMatcher()
matcher.AddProvider("aws")
matcher.AddProvider("azure")
matcher.AddResourceType("aws:ec2:Instance")
matcher.AddResourceType("aws:rds:Instance")
if matcher.Supports(resource) {
// Calculate cost
}

Provides utilities for cost calculations and responses.

type CostCalculator struct{}

Constants:

  • hoursPerMonth = 730.0 - Used for monthly cost calculations

Constructor:

func NewCostCalculator() *CostCalculator

Methods:

  • HourlyToMonthly(hourlyCost float64) float64

    • Converts hourly cost to monthly (× 730)
  • MonthlyToHourly(monthlyCost float64) float64

    • Converts monthly cost to hourly (÷ 730)
  • CreateProjectedCostResponse(currency, unitPrice, billingDetail)

    • Creates a standard projected cost response
    • unitPrice is the hourly rate
    • Automatically calculates CostPerMonth
  • CreateActualCostResponse(results []*ActualCostResult)

    • Creates a standard actual cost response
    • Wraps the provided cost results

Example:

calc := pluginsdk.NewCostCalculator()
// Convert costs
monthlyRate := calc.HourlyToMonthly(0.0104) // 7.592
// Create response
resp := calc.CreateProjectedCostResponse(
"USD",
0.0104,
"on-demand pricing",
)
// resp.CostPerMonth == 7.592

Represents plugin metadata.

type Manifest struct {
Name string `yaml:"name" json:"name"`
Version string `yaml:"version" json:"version"`
Description string `yaml:"description" json:"description"`
Author string `yaml:"author" json:"author"`
SupportedProviders []string `yaml:"supported_providers"`
Protocols []string `yaml:"protocols"`
Binary string `yaml:"binary" json:"binary"`
Metadata map[string]string `yaml:"metadata,omitempty"`
}

Fields:

  • Name - Plugin name (lowercase, alphanumeric with hyphens)
  • Version - Semantic version (e.g., “1.0.0”)
  • Description - Human-readable description
  • Author - Author or organization name
  • SupportedProviders - List of cloud providers
  • Protocols - Communication protocols (always ["grpc"])
  • Binary - Path to plugin executable
  • Metadata - Additional key-value metadata

Methods:

  • Validate() error

    • Validates all manifest fields
    • Returns ValidationErrors with all issues
  • SaveManifest(path string) error

    • Saves manifest to YAML or JSON file
    • Format determined by file extension

Functions:

  • LoadManifest(path string) (*Manifest, error)

    • Loads and validates manifest from file
    • Supports .yaml, .yml, .json extensions
  • CreateDefaultManifest(name, author, providers) *Manifest

    • Creates manifest with sensible defaults
    • Version set to “1.0.0”
    • Protocols set to ["grpc"]

Example:

manifest := pluginsdk.CreateDefaultManifest(
"my-plugin",
"John Doe",
[]string{"aws", "azure"},
)
manifest.Description = "My custom cost plugin"
err := manifest.SaveManifest("plugin.manifest.yaml")
if err != nil {
log.Fatal(err)
}

Represents a single manifest validation error.

type ValidationError struct {
Field string
Message string
}

Methods:

  • Error() string
    • Returns formatted error message

Represents multiple validation errors.

type ValidationErrors []ValidationError

Methods:

  • Error() string
    • Returns formatted multi-line error message
    • Includes count and details of all errors

The SDK provides testing utilities in testing.go.

Functions:

  • CreateTestResourceDescriptor(provider, resourceType, sku)

    • Creates a resource descriptor for testing
    • Includes common test tags
  • AssertProjectedCost(t, response, expectedCurrency, expectedUnitPrice)

    • Asserts projected cost response values
    • Automatically fails test on mismatch

Example:

func TestMyPlugin(t *testing.T) {
plugin := NewMyPlugin()
resource := pluginsdk.CreateTestResourceDescriptor(
"aws",
"aws:ec2:Instance",
"t3.micro",
)
req := &pbc.GetProjectedCostRequest{Resource: resource}
resp, err := plugin.GetProjectedCost(context.Background(), req)
require.NoError(t, err)
pluginsdk.AssertProjectedCost(t, resp, "USD", 0.0104)
}

Standard error constructors for common scenarios.

Functions:

  • NotSupportedError(resource *ResourceDescriptor) error

    • Returns error indicating resource is not supported
    • Includes resource type and provider in message
  • NoDataError(resourceID string) error

    • Returns error indicating no cost data available
    • Includes resource ID in message

Example:

func (p *MyPlugin) GetProjectedCost(
ctx context.Context,
req *pbc.GetProjectedCostRequest,
) (*pbc.GetProjectedCostResponse, error) {
resource := req.GetResource()
if !p.Matcher().Supports(resource) {
return nil, pluginsdk.NotSupportedError(resource)
}
// Calculate cost...
}
func (p *MyPlugin) GetActualCost(
ctx context.Context,
req *pbc.GetActualCostRequest,
) (*pbc.GetActualCostResponse, error) {
// If historical data not available
return nil, pluginsdk.NoDataError(req.GetResourceId())
}

package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/rshade/finfocus-spec/sdk/go/pluginsdk"
pbc "github.com/rshade/finfocus-spec/sdk/go/proto/finfocus/v1"
)
type MinimalPlugin struct {
*pluginsdk.BasePlugin
}
func NewMinimalPlugin() *MinimalPlugin {
base := pluginsdk.NewBasePlugin("minimal")
base.Matcher().AddProvider("aws")
base.Matcher().AddResourceType("aws:ec2:Instance")
return &MinimalPlugin{BasePlugin: base}
}
func (p *MinimalPlugin) GetProjectedCost(
ctx context.Context,
req *pbc.GetProjectedCostRequest,
) (*pbc.GetProjectedCostResponse, error) {
resource := req.GetResource()
if !p.Matcher().Supports(resource) {
return nil, pluginsdk.NotSupportedError(resource)
}
return p.Calculator().CreateProjectedCostResponse(
"USD",
0.0104,
"fixed-rate",
), nil
}
func main() {
plugin := NewMinimalPlugin()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
config := pluginsdk.ServeConfig{
Plugin: plugin,
Port: 0,
}
log.Printf("Starting %s plugin...", plugin.Name())
if err := pluginsdk.Serve(ctx, config); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
type MultiProviderPlugin struct {
*pluginsdk.BasePlugin
awsPrices map[string]float64
azurePrices map[string]float64
}
func NewMultiProviderPlugin() *MultiProviderPlugin {
base := pluginsdk.NewBasePlugin("multi-provider")
// Support multiple providers
base.Matcher().AddProvider("aws")
base.Matcher().AddProvider("azure")
// Support multiple resource types
base.Matcher().AddResourceType("aws:ec2:Instance")
base.Matcher().AddResourceType("azure:compute:VirtualMachine")
return &MultiProviderPlugin{
BasePlugin: base,
awsPrices: map[string]float64{
"t3.micro": 0.0104,
"t3.small": 0.0208,
},
azurePrices: map[string]float64{
"Standard_B1s": 0.0104,
"Standard_B2s": 0.0416,
},
}
}
func (p *MultiProviderPlugin) GetProjectedCost(
ctx context.Context,
req *pbc.GetProjectedCostRequest,
) (*pbc.GetProjectedCostResponse, error) {
resource := req.GetResource()
if !p.Matcher().Supports(resource) {
return nil, pluginsdk.NotSupportedError(resource)
}
var price float64
var detail string
switch resource.GetProvider() {
case "aws":
instanceType := resource.GetTags()["instanceType"]
price = p.awsPrices[instanceType]
detail = "AWS on-demand"
case "azure":
vmSize := resource.GetTags()["vmSize"]
price = p.azurePrices[vmSize]
detail = "Azure Pay-As-You-Go"
}
return p.Calculator().CreateProjectedCostResponse(
"USD",
price,
detail,
), nil
}
type CustomPricingPlugin struct {
*pluginsdk.BasePlugin
basePrices map[string]float64
discountTiers map[string]float64
}
func NewCustomPricingPlugin() *CustomPricingPlugin {
base := pluginsdk.NewBasePlugin("custom-pricing")
base.Matcher().AddProvider("aws")
return &CustomPricingPlugin{
BasePlugin: base,
basePrices: map[string]float64{
"t3.micro": 0.0104,
"t3.small": 0.0208,
"t3.medium": 0.0416,
},
discountTiers: map[string]float64{
"dev": 1.0, // No discount
"staging": 0.9, // 10% discount
"production": 0.8, // 20% discount
},
}
}
func (p *CustomPricingPlugin) GetProjectedCost(
ctx context.Context,
req *pbc.GetProjectedCostRequest,
) (*pbc.GetProjectedCostResponse, error) {
resource := req.GetResource()
if !p.Matcher().Supports(resource) {
return nil, pluginsdk.NotSupportedError(resource)
}
// Get base price
instanceType := resource.GetTags()["instanceType"]
basePrice := p.basePrices[instanceType]
// Apply environment-based discount
env := resource.GetTags()["environment"]
discount := p.discountTiers[env]
if discount == 0 {
discount = 1.0
}
finalPrice := basePrice * discount
return p.Calculator().CreateProjectedCostResponse(
"USD",
finalPrice,
"custom pricing with discount",
), nil
}

From finfocus-spec v0.7.4, core sets GetActualCostRequest.resource to the same descriptor it sends for projected cost: provider, resource_type, sku, region, flattened tags, and redacted attributes. Read pricing dimensions from it, and fall back to tags, resource_id, and arn when it is unset:

if resource := req.GetResource(); resource != nil {
sku, region := resource.GetSku(), resource.GetRegion()
// ...
}

Request tags stay the resource’s cloud tags. Core still adds sku, region, provider, and resource_type there for plugins built before v0.7.4, but a cloud tag with the same name wins, so treat those keys as labels when resource is set. Core leaves resource unset when the resource has no provider or type, or when the descriptor would exceed the SDK limits.


Projected cost, Supports, batch, and recommendation calls build ResourceDescriptor.tags with ConvertToProto. Every top-level property is still one string. Each non-empty map or array also adds one key per scalar leaf. Path segments join with ., and array elements use a zero-based index (sku.capacity, rootBlockDevice.0.volumeType). Leaf text matches ConvertValueToString: whole numbers have no decimal point, and booleans are true or false.

Existing keys and values stay the same, including keys that start with __, except that a top-level input with a credential-like name (the list below) or a Pulumi secret value is never sent, and collapsed map text leaves those out. If a dotted key collides with an existing key, the existing value wins. EstimateCost attributes stay nested Struct values and do not gain dotted keys.

The flattener does not emit:

  • nil leaves, empty maps, empty arrays, or a leaf equal to the Pulumi unknown sentinel 04da6b54-80e4-46f7-96ec-b56ff0331ba9
  • a path with any segment that starts with __
  • a path whose segment contains password, passwd, passphrase, secret, token, credential, ciphertext, privatekey, private_key, apikey, api_key, accesskey, access_key, connectionstring, or connection_string, compared case-insensitively
  • a Pulumi secret value
  • anything inside tags, tagsAll, labels, or annotations

A scalar at six segments is kept. A seventh segment is not. A new key longer than 128 characters, or a new value longer than 256 characters, is skipped. Collapsed values that are already longer than 256 characters are left as they are. The tag map stops at 50 entries. Collapsed keys are kept even when they already pass 50. Remaining room is filled by dotted keys, shallower paths first and then by name. One debug log line records dotted_kept and dotted_dropped for a truncated resource. The line has counts only.

The projected cache key is projected/{provider}/{type}/{region}/{sku}/tags-{digest}. The digest is the first eight bytes of SHA-256 over the flattened tag map, so nested fields such as sku.capacity change the key. /refs-{digest} is still appended when the resource has ref.* properties. Projected and recommendation entries miss once after this change. The cache file is safe to delete.