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.
Table of Contents
Section titled “Table of Contents”- Core Interfaces
- Server and Serving
- Helper Types
- Manifest Management
- Testing Utilities
- Code Examples
- Dotted resource tags
Core Interfaces
Section titled “Core Interfaces”Plugin Interface
Section titled “Plugin Interface”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
NoDataErrorif not implemented
Server and Serving
Section titled “Server and Serving”Server Type
Section titled “Server Type”The Server wraps a Plugin implementation with gRPC handling.
type Server struct { pbc.UnimplementedCostSourceServiceServer plugin Plugin}Constructor:
func NewServer(plugin Plugin) *ServerCreates 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()
ServeConfig
Section titled “ServeConfig”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 servePort- TCP port (0 uses PORT env var or ephemeral port)
Serve Function
Section titled “Serve Function”Starts the gRPC server for a plugin.
func Serve(ctx context.Context, config ServeConfig) errorParameters:
ctx- Context for graceful shutdownconfig- Server configuration
Behavior:
- Resolves port (config.Port → PORT env → ephemeral)
- Creates TCP listener on 127.0.0.1
- Prints
PORT=<number>to stdout - Registers plugin as gRPC service
- Serves until context is cancelled
- Performs graceful shutdown
Returns:
nilon 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)}Helper Types
Section titled “Helper Types”BasePlugin
Section titled “BasePlugin”Provides common functionality for plugin implementations.
type BasePlugin struct { // Private fields}Constructor:
func NewBasePlugin(name string) *BasePluginCreates 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
- Default implementation returning
-
GetActualCost(ctx, req) (resp, error)- Default implementation returning
NoDataError - Plugins should override this method
- Default implementation returning
Example:
type MyPlugin struct { *pluginsdk.BasePlugin}
func NewMyPlugin() *MyPlugin { base := pluginsdk.NewBasePlugin("my-plugin") base.Matcher().AddProvider("aws") return &MyPlugin{BasePlugin: base}}ResourceMatcher
Section titled “ResourceMatcher”Helps determine if a plugin supports a resource.
type ResourceMatcher struct { // Private fields}Constructor:
func NewResourceMatcher() *ResourceMatcherMethods:
-
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}CostCalculator
Section titled “CostCalculator”Provides utilities for cost calculations and responses.
type CostCalculator struct{}Constants:
hoursPerMonth = 730.0- Used for monthly cost calculations
Constructor:
func NewCostCalculator() *CostCalculatorMethods:
-
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
unitPriceis 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 costsmonthlyRate := calc.HourlyToMonthly(0.0104) // 7.592
// Create responseresp := calc.CreateProjectedCostResponse( "USD", 0.0104, "on-demand pricing",)// resp.CostPerMonth == 7.592Manifest Management
Section titled “Manifest Management”Manifest Type
Section titled “Manifest Type”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 descriptionAuthor- Author or organization nameSupportedProviders- List of cloud providersProtocols- Communication protocols (always["grpc"])Binary- Path to plugin executableMetadata- Additional key-value metadata
Methods:
-
Validate() error- Validates all manifest fields
- Returns
ValidationErrorswith 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,.jsonextensions
-
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)}ValidationError
Section titled “ValidationError”Represents a single manifest validation error.
type ValidationError struct { Field string Message string}Methods:
Error() string- Returns formatted error message
ValidationErrors
Section titled “ValidationErrors”Represents multiple validation errors.
type ValidationErrors []ValidationErrorMethods:
Error() string- Returns formatted multi-line error message
- Includes count and details of all errors
Testing Utilities
Section titled “Testing Utilities”The SDK provides testing utilities in testing.go.
Test Helpers
Section titled “Test Helpers”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)}Helper Functions
Section titled “Helper Functions”Error Helpers
Section titled “Error Helpers”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())}Code Examples
Section titled “Code Examples”Minimal Plugin Implementation
Section titled “Minimal Plugin Implementation”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) }}Multi-Provider Plugin
Section titled “Multi-Provider Plugin”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}Plugin with Custom Pricing Logic
Section titled “Plugin with Custom Pricing Logic”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}Resource descriptor on actual cost
Section titled “Resource descriptor on actual cost”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.
Dotted resource tags
Section titled “Dotted resource tags”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, orconnection_string, compared case-insensitively - a Pulumi secret value
- anything inside
tags,tagsAll,labels, orannotations
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.
Related Documentation
Section titled “Related Documentation”- Plugin Development Guide - Building plugins
- Plugin Examples - Common patterns
- Plugin Protocol - gRPC spec
- Plugin Checklist - Implementation checklist