common

package
v0.0.0-...-6ded401 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 28, 2026 License: OSL-3.0 Imports: 14 Imported by: 20

Documentation

Overview

Package common — JSON codec helpers for the polymorphic ServiceDetails interface so per-rec details survive the round-trip through purchase_executions.recommendations (JSONB) without dropping the service-specific fields that drive offering lookup.

Background: before issue #453 the only Details data preserved across the dashboard → DB → executePurchase boundary was a single "engine" string on RecommendationRecord. EC2 platform / tenancy / scope, RDS AZ-config, SP plan-type / hourly-commitment all vanished, so the cloud service client's findOfferingID either fell back to "Linux/UNIX"-style defaults (silently mis-purchasing Windows recs as Linux) or returned "invalid service details for <Service>" outright. We now persist the full ServiceDetails payload as a raw JSON blob on the record, and reconstruct the matching typed pointer here at execute time using the rec's Service string as the discriminator.

All helpers live in pkg/common so the typed dispatch sits next to the type definitions (ComputeDetails / DatabaseDetails / …). internal/config (where RecommendationRecord lives) is deliberately kept free of pkg/common imports so the dependency graph stays a strict DAG.

Pointer invariant: this codec always reconstructs *DatabaseDetails / *CacheDetails, and every other producer (AWS, Azure, the CSV loader) constructs them as pointers too, so Recommendation.Details never holds a bare DatabaseDetails / CacheDetails value anywhere in the codebase. Consumers that type-switch on Details only need the pointer case for these two types. ComputeDetails is the one remaining exception: the Azure compute client (providers/azure/services/compute) and the GCP compute-engine client (providers/gcp/services/computeengine) both still construct it as a value, so consumers that handle ComputeDetails must keep accepting both forms. Dropping the value case would silently blank every Azure VM and GCP compute row instead of failing to compile.

Package common provides cloud-agnostic types and interfaces for multi-cloud cost optimization

Index

Constants

View Source
const (
	DropMinPoolSize           = "--min-pool-size"
	DropExtendedSupport       = "--include-extended-support"
	DropTargetAlreadyMet      = "target-already-met"
	DropTargetSizedToZero     = "target-sized-to-zero"
	DropFamilyAlreadyAtTarget = "family-nu-already-at-target"
	DropFamilyNoNUSignal      = "family-nu-no-nu-signal"
	DropFamilySizedToZero     = "family-nu-sized-to-zero"
	DropDuplicateDedup        = "duplicate-dedup"
)

Drop reason keys used by all filter and sizing stages. Using typed constants avoids typos at call sites and lets tests assert on the exact key.

View Source
const (
	PurchaseSourceCLI = "cudly-cli"
	PurchaseSourceWeb = "cudly-web"
	PurchaseSourceMCP = "cudly-mcp"
)

Source values for PurchaseOptions.Source. Kept lowercase so they can be used directly as GCP label values (GCP labels must be lowercase) and match AWS tag / Azure reservation tag conventions.

View Source
const ArcheraEnrollmentWindowDays = 7

ArcheraEnrollmentWindowDays is how long after a purchase a buyer has to enroll that commitment in Archera's coverage.

View Source
const ArcheraNonGatingDisclosure = "This is entirely optional. CUDly's purchase and management features " +
	"work fully without Archera."

ArcheraNonGatingDisclosure states that the offer is optional and that CUDly is fully functional without it. Disclosure 1 of 2.

View Source
const ArcheraPitch = "Want to push your coverage to 100% without the risk that a future capacity decrease " +
	"leaves you paying for commitments you no longer use? You can buy underutilization insurance for " +
	"Reserved Instances and Savings Plans from Archera."

ArcheraPitch is the offer itself: what the coverage does and why a buyer who just committed spend might want it.

View Source
const ArcheraSignupURL = "https://www.archera.ai/cudly"

ArcheraSignupURL is the Archera signup link carrying CUDly attribution. Shared with the CLI and kept identical to the frontend's ARCHERA_SIGNUP_URL (frontend/src/archera.ts).

NOTE: internal/email/templates.go currently sends a DIFFERENT link (https://archera.ai/signup?mode=cudly). That divergence predates this constant and is deliberately left alone here rather than silently normalized, because the two may be distinct attribution paths on Archera's side; reconciling them is a partnership question, not a refactor.

View Source
const ArcheraSponsorshipDisclosure = "For full disclosure, Archera sponsors CUDly's Open Source development " +
	"from a fraction of their insurance premiums."

ArcheraSponsorshipDisclosure states the financial relationship behind the recommendation. Disclosure 2 of 2.

View Source
const IdempotencyTagKey = "cudly-idempotency-token"

IdempotencyTagKey is the tag key under which the deterministic per-rec idempotency token (see DeriveIdempotencyToken) is stamped on commitments that the cloud API cannot dedupe natively. EC2 Reserved Instances have no ClientToken on PurchaseReservedInstancesOffering, so the EC2 client checks for an already-tagged RI before purchasing and tags the freshly-bought RI with this key afterwards, making a re-driven purchase idempotent (issue #636).

View Source
const PurchaseTagKey = "purchase-automation"

PurchaseTagKey is the tag/label key every CUDly-purchased commitment carries so customers and CUDly itself can attribute commitments back to the tool.

Variables

View Source
var ErrCommitmentPurchaseNotSupported = errors.New("commitment purchase not supported for this service")

ErrCommitmentPurchaseNotSupported is returned by ServiceClient.PurchaseCommitment implementations for services that have no programmatic committed-use / commitment purchase API. Their recommendations are advisory only: callers must surface them for human action rather than auto-purchasing. Returning this sentinel (instead of silently provisioning a billable resource) guarantees a "purchase" never creates infrastructure. Callers can detect it with errors.Is(err, ErrCommitmentPurchaseNotSupported).

View Source
var ErrMuteSecretMissing = errors.New("common: NOTIFICATION_MUTE_SECRET is required")

ErrMuteSecretMissing is returned by ResolveMuteSecret when NOTIFICATION_MUTE_SECRET is unset. Falling back to a well-known key in any environment would make unsubscribe tokens forgeable for any (email, scope) tuple, so callers must fail closed.

Functions

func BuildReservationName

func BuildReservationName(f ReservationNameFields, fallbackPrefix string) string

BuildReservationName composes a rich, parseable identifier for an AWS reservation purchase. The format mirrors the Azure DisplayName format from #686 so cross-cloud parsers can share logic:

{svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand}

e.g. "opensearch-us-east-1-r6gd-large-search-3x-1yr-allup-20260521T002019-a1b2c3d4" (which then gets truncated to fit the 60-char cap — see below).

The result is always sanitized via SanitizeReservationID for AWS reservation-name allowlists ([a-zA-Z0-9-], so underscores in SKU names become '-') and never longer than awsReservationNameMaxLen (60). If the composed string would exceed 60, optional tail fields are progressively dropped: random suffix first, then timestamp, then payment-option. The service code, region, SKU, count, and term are NEVER dropped — those are the high-signal segments operators rely on to identify the reservation in the AWS console.

fallbackPrefix is the prefix passed to SanitizeReservationID for the unreachable empty-output fallback (e.g. "rds-reserved-"); it preserves the prior call-site behaviour at every service when the builder ever emits an unsanitisable input.

func DeriveIdempotencyToken

func DeriveIdempotencyToken(executionID string, recIndex int) string

DeriveIdempotencyToken returns a deterministic token for a single purchase recommendation, derived from the owning execution's ID and the recommendation's index within that execution. The same (executionID, recIndex) pair always yields the same token, so a re-driven purchase of a stranded execution (issue #636) reuses the identical token: AWS Savings Plans dedupe on it natively via CreateSavingsPlanInput.ClientToken, and the EC2 RI client uses it as a dedupe tag (IdempotencyTagKey). The output is a 64-char hex SHA-256 digest, which fits the AWS ClientToken 64-character limit exactly.

Unlike GenerateApprovalToken this is intentionally NOT random: idempotency requires the token be reproducible from durable inputs (execution_id + index), which both survive a strand-and-re-drive. It is not a credential, so the lack of unpredictability is by design.

func DeriveMuteToken

func DeriveMuteToken(key []byte, email, scope string) string

DeriveMuteToken returns a 32-byte HMAC-SHA256 token (hex-encoded) that signs the (email, scope) tuple. The token is embedded in the List-Unsubscribe URL; the handler re-derives it from the query params and compares in constant time, so a forged URL cannot mute a different address.

key must be resolved by the caller via ResolveMuteSecret, which applies a fail-closed policy in every environment. An empty key is a configuration error and yields the empty string so the caller emits no usable token (and a later VerifyMuteToken comparison against it fails), rather than silently signing with a well-known fallback.

func EngineFromDetails

func EngineFromDetails(details ServiceDetails) string

EngineFromDetails extracts and normalizes the engine name from recommendation details. Returns an empty string for non-database/cache service types. DatabaseDetails/CacheDetails are always pointers (every producer constructs them that way; see service_details_codec.go's package doc for the invariant). The nil-pointer guards are not dead code: the `details == nil` check above only catches an untyped nil, so a typed nil reaches the switch and would panic on the field read.

func GenerateApprovalToken

func GenerateApprovalToken() (string, error)

GenerateApprovalToken returns a 32-byte cryptographically secure random token, hex-encoded (64 chars). Used for purchase + RI exchange + plan approval flows where the token is the only credential in a one-click email link.

Why not uuid.New().String()? UUID v4 is 122 bits of entropy in a known format (8-4-4-4-12 hex with version + variant nibbles fixed). 32 random bytes provide a full 256 bits of unpredictability and a uniform output space, making token guessing computationally hopeless.

func IdempotencyGUID

func IdempotencyGUID(token string) string

IdempotencyGUID formats an idempotency token as a deterministic canonical GUID (8-4-4-4-12 lowercase hex) for use as an Azure reservationOrderID (issue #641). The Azure Reservations API path is reservationOrders/{guid} and a PUT is idempotent on a stable order ID, so deriving the GUID from the token makes a re-drive re-PUT the same order rather than create a second reservation.

It uses the first 32 hex characters (128 bits) of the token, which is itself a SHA-256 hex digest, so the GUID is deterministic and collision-free at any realistic purchase volume. Returns "" when token is shorter than 32 hex chars (e.g. empty) so callers keep their prior non-idempotent ID behavior.

func IdempotentReservationID

func IdempotentReservationID(prefix, token string) string

IdempotentReservationID derives a deterministic, AWS-safe reservation ID from an idempotency token (issue #641). The same token always yields the same ID, so a re-driven purchase reuses the identical customer-supplied reservation ID and AWS rejects the duplicate server-side (RDS/ElastiCache/MemoryDB each return a *AlreadyExists* fault). Returns "" when token is empty so the caller keeps its prior non-idempotent (timestamp-based) ID behaviour for call sites that supply no token (e.g. the CLI path).

prefix should be a short, lowercase, hyphen-terminated service tag (e.g. "rds-id-") so the reservation is identifiable in the console; the token is hex so the result needs no further sanitisation beyond SanitizeReservationID's invariants.

func IsSavingsPlan

func IsSavingsPlan(s ServiceType) bool

IsSavingsPlan reports whether s is any Savings Plans service slug -- the umbrella sentinel (ServiceSavingsPlansAll), any of the four per-plan-type constants, or the dash-free frontend spelling "savingsplans" that the API handler stores verbatim without normalisation. Use it when code needs to recognise the Savings Plans family irrespective of plan type (e.g., stats aggregation, region-ignoring filters, display-name branching).

func MarshalServiceDetails

func MarshalServiceDetails(details ServiceDetails) (json.RawMessage, error)

MarshalServiceDetails encodes a ServiceDetails value into a raw JSON payload suitable for the RecommendationRecord.Details JSONB column. Returns (nil, nil) when details is nil so the column stays NULL / absent rather than persisting a literal JSON "null".

func MaskToken

func MaskToken(token string) string

MaskToken returns a log-safe representation of an idempotency/approval token: the first 8 characters followed by an ellipsis, never the full value. This keeps just enough of the prefix to correlate log lines for a single purchase while avoiding emitting the whole caller-supplied token into persistent logs (a stable per-execution identifier that should not leak verbatim). An empty token yields "(none)". A token of 8 chars or fewer is fully redacted to "(redacted)" rather than echoed: an 8-char prefix of an 8-char input is the whole value, so for short inputs (e.g. a short secret a future caller might pass) nothing of the token is emitted.

func Matches

func Matches(rec Recommendation, c Commitment) bool

Matches returns true if a Commitment covers the same resource type as a Recommendation. Match criteria: Provider, Region, Service, ResourceType, and normalized engine. OfferingClass and Term are not compared because Commitment has no such fields. State filtering (e.g. excluding "retired") is the caller's responsibility.

func NormalizeEngineName

func NormalizeEngineName(engine string) string

NormalizeEngineName normalizes database engine names to a consistent format. Returns lowercase of the input as a fallback when the engine is not recognized.

func NormalizeSource

func NormalizeSource(s string) (string, error)

NormalizeSource lowercases s and returns it when it matches an allowed source. Returns an error on anything else so cloud tags cannot be polluted with arbitrary caller-supplied strings (which would be impossible to retroactively remove from committed resources).

func ReservationOrderID

func ReservationOrderID(token, fallback string) string

ReservationOrderID returns the Azure reservationOrderID to PUT for a purchase: the deterministic GUID derived from token when one is supplied (issue #641, so a re-drive re-PUTs the same idempotent order), otherwise fallback (the caller's prior non-idempotent ID, e.g. a random GUID or a timestamp). Centralizing the choice keeps each executor's PurchaseCommitment a single statement and avoids repeating the same empty-token guard across every Azure service.

func ResolveMuteSecret

func ResolveMuteSecret() ([]byte, error)

ResolveMuteSecret returns the HMAC key for notification mute tokens, applying a fail-closed policy in every environment. Local development and tests must provide an explicit test-only value rather than sharing a predictable key.

func SanitizeReservationID

func SanitizeReservationID(id, fallbackPrefix string) string

SanitizeReservationID returns an identifier safe for AWS reservation/reserved-instance ID or name fields: only ASCII letters, digits, and hyphens; no leading/trailing hyphen; no consecutive hyphens. Dots are replaced with hyphens. If the result would be empty, returns fallbackPrefix plus a Unix timestamp.

func VerifyMuteToken

func VerifyMuteToken(key []byte, email, scope, token string) bool

VerifyMuteToken returns true when token equals the HMAC for (email, scope) under key. Comparison is constant-time to prevent timing attacks. A non-empty token never matches when key is empty, so a missing secret fails closed.

func WriteAuditRecord

func WriteAuditRecord(record AuditRecord, path string) error

WriteAuditRecord marshals record to a single JSON line and appends it to path. Returns an error if RunID is empty or if any I/O step fails.

Types

type Account

type Account struct {
	Provider    ProviderType `json:"provider"`
	ID          string       `json:"id"`
	Name        string       `json:"name"`
	DisplayName string       `json:"display_name"`
	IsDefault   bool         `json:"is_default"`
}

Account represents a cloud account/subscription/project

type AuditRecord

type AuditRecord struct {
	RunID             string          `json:"run_id"`
	Provider          ProviderType    `json:"provider"`
	AccountID         string          `json:"account_id"`
	AccountName       string          `json:"account_name"`
	Region            string          `json:"region"`
	Service           string          `json:"service"`
	ResourceType      string          `json:"resource_type"`
	CommitmentType    CommitmentType  `json:"commitment_type"`
	Term              int             `json:"term_months"`
	Count             int             `json:"count"`
	EstimatedCost     float64         `json:"estimated_cost"`
	EstimatedSavings  float64         `json:"estimated_savings"`
	CommitmentID      string          `json:"commitment_id"`
	Status            string          `json:"status"`
	ErrorMessage      string          `json:"error_message"`
	Timestamp         time.Time       `json:"timestamp"`
	DryRun            bool            `json:"dry_run"`
	RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty"`
	// Source identifies the CUDly surface (cudly-cli / cudly-web) that
	// triggered the purchase. Mirrors the value stamped on the commitment
	// itself via purchase-automation tag/label.
	Source string `json:"source,omitempty"`
}

AuditRecord is one line in the JSON-lines audit log. Status values: "success", "error", "skipped" (dry-run), "skipped_covered" (idempotency).

func NewAuditRecord

func NewAuditRecord(runID string, rec Recommendation, result PurchaseResult, status string, dryRun bool, source string) AuditRecord

NewAuditRecord constructs an AuditRecord from a Recommendation and a PurchaseResult. status must be one of: "success", "error", "skipped" (dry-run), "skipped_covered" (idempotency). source is the CUDly surface that triggered the run — copied into the JSONL so CLI audit logs can be reconciled against the DB's purchase_history.source column.

type CacheDetails

type CacheDetails struct {
	Engine   string `json:"engine"` // redis, memcached
	NodeType string `json:"node_type"`
	Shards   int    `json:"shards,omitempty"`
}

CacheDetails represents cache-specific details (ElastiCache, Azure Cache, Memorystore)

func (CacheDetails) GetDetailDescription

func (d CacheDetails) GetDetailDescription() string

func (CacheDetails) GetServiceType

func (d CacheDetails) GetServiceType() ServiceType

type Commitment

type Commitment struct {
	Provider       ProviderType   `json:"provider"`
	Account        string         `json:"account"`
	CommitmentID   string         `json:"commitment_id"`
	CommitmentType CommitmentType `json:"commitment_type"`
	Service        ServiceType    `json:"service"`
	Region         string         `json:"region"`
	ResourceType   string         `json:"resource_type"`
	Engine         string         `json:"engine,omitempty"`     // Database engine for RDS/ElastiCache (e.g., "mysql", "aurora-postgresql")
	Deployment     string         `json:"deployment,omitempty"` // RDS Multi-AZ vs Single-AZ; empty for non-RDS
	Count          int            `json:"count"`
	StartDate      time.Time      `json:"start_date"`
	EndDate        time.Time      `json:"end_date"`
	State          string         `json:"state"`
	Cost           float64        `json:"cost"`
}

Commitment represents an existing commitment (RI/SP/CUD/etc).

Deployment is RDS-specific (Multi-AZ vs Single-AZ); it stays empty for services that don't have a deployment dimension (EC2, ElastiCache, etc). When populated, it carries the same vocabulary as DatabaseDetails.AZConfig on Recommendation ("single-az" / "multi-az") so pool-key matching can collapse both sides via normaliseDeployment in the recommendations package. Without this field, RDS expiry adjustments would silently miss because Recommendation lookup keys are deployment-aware while commitment keys defaulted to empty.

type CommitmentType

type CommitmentType string

CommitmentType represents different commitment types across clouds

const (
	CommitmentReservedInstance CommitmentType = "reserved-instance" // AWS RI, Azure RI
	CommitmentSavingsPlan      CommitmentType = "savings-plan"      // AWS Savings Plans
	CommitmentCUD              CommitmentType = "committed-use"     // GCP CUD
	CommitmentReservedCapacity CommitmentType = "reserved-capacity" // Azure/GCP storage
)

func (CommitmentType) String

func (c CommitmentType) String() string

String returns the string representation of the commitment type

type ComputeDetails

type ComputeDetails struct {
	InstanceType string  `json:"instance_type"`
	Platform     string  `json:"platform"`            // linux, windows
	Tenancy      string  `json:"tenancy"`             // default, dedicated, host
	Scope        string  `json:"scope"`               // regional, zonal
	VCPU         int     `json:"vcpu,omitempty"`      // 0 = unknown
	MemoryGB     float64 `json:"memory_gb,omitempty"` // 0 = unknown
}

ComputeDetails represents compute-specific details (EC2, VM, Compute Engine).

VCPU + MemoryGB are populated by per-provider catalogue lookups when available (Azure: armcompute.ResourceSKU.Capabilities; AWS: ec2:DescribeInstanceTypes; GCP: machine-type catalogue). They are optional — converters that don't yet wire a catalogue leave them at the zero value, and the JSON tag uses omitempty so unknown values don't pollute the API payload.

func (ComputeDetails) GetDetailDescription

func (d ComputeDetails) GetDetailDescription() string

GetDetailDescription returns a short human description of the compute recommendation. The base form is "<platform>/<tenancy>"; when both VCPU and MemoryGB are populated (>0) the size is appended as " (<vcpu> vCPU / <memory> GB)" to give the UI a one-line summary without forcing the caller to inspect the struct.

func (ComputeDetails) GetServiceType

func (d ComputeDetails) GetServiceType() ServiceType

type DataWarehouseDetails

type DataWarehouseDetails struct {
	NodeType      string `json:"node_type"`
	NumberOfNodes int    `json:"number_of_nodes"`
	ClusterType   string `json:"cluster_type,omitempty"` // single-node, multi-node
}

DataWarehouseDetails represents data warehouse-specific details (Redshift, Synapse, BigQuery)

func (DataWarehouseDetails) GetDetailDescription

func (d DataWarehouseDetails) GetDetailDescription() string

func (DataWarehouseDetails) GetServiceType

func (d DataWarehouseDetails) GetServiceType() ServiceType

type DatabaseDetails

type DatabaseDetails struct {
	Engine        string `json:"engine"` // mysql, postgres, sqlserver, etc.
	EngineVersion string `json:"engine_version,omitempty"`
	AZConfig      string `json:"az_config"` // single-az, multi-az
	InstanceClass string `json:"instance_class"`
	Deployment    string `json:"deployment,omitempty"` // Azure: single, pool
}

DatabaseDetails represents database-specific details (RDS, Azure SQL, Cloud SQL)

func (DatabaseDetails) GetDetailDescription

func (d DatabaseDetails) GetDetailDescription() string

func (DatabaseDetails) GetServiceType

func (d DatabaseDetails) GetServiceType() ServiceType

type DropSummary

type DropSummary struct {
	// contains filtered or unexported fields
}

DropSummary accumulates the count of recommendations dropped per reason across the full fetch-filter-size pipeline. It is not safe for concurrent use from multiple goroutines; the main pipeline is sequential per service and region so no synchronization is needed.

func NewDropSummary

func NewDropSummary() *DropSummary

NewDropSummary returns a zero-valued DropSummary ready to record drops.

func (*DropSummary) Add

func (d *DropSummary) Add(reason string, n int)

Add increments the drop count for the given reason by n. Safe to call on a zero-value DropSummary (e.g. var d DropSummary); the underlying map is lazily initialized on first use.

func (*DropSummary) FormatOneLine

func (d *DropSummary) FormatOneLine() string

FormatOneLine returns a compact single-line summary such as:

Dropped 14 recs: --min-pool-size=8, target-already-met=4, duplicate-dedup=2

Returns an empty string when no drops have been recorded.

func (*DropSummary) IsEmpty

func (d *DropSummary) IsEmpty() bool

IsEmpty reports whether no drops have been recorded.

func (*DropSummary) Total

func (d *DropSummary) Total() int

Total returns the sum of all drops recorded.

type ExchangeOrigin

type ExchangeOrigin string

ExchangeOrigin identifies the task that created an RI exchange record so that pending-cancellation can be scoped by origin (standalone vs ladder) through a typed value rather than a bare bool. It lives in pkg/common because both pkg/exchange (the caller) and internal/config (the store implementation) need it, and pkg/common carries no heavy provider-SDK dependencies.

const (
	// ExchangeOriginStandalone is the standalone ri_exchange_reshape scheduled
	// task. Its pending records have ladder_run_id IS NULL in the database.
	ExchangeOriginStandalone ExchangeOrigin = "standalone-ri-exchange"
	// ExchangeOriginLadder is the cudly-ladder engine (ladder run reshape
	// phase). Its pending records have ladder_run_id IS NOT NULL.
	ExchangeOriginLadder ExchangeOrigin = "cudly-ladder"
)

func (ExchangeOrigin) String

func (o ExchangeOrigin) String() string

String returns the string representation of the exchange origin.

func (ExchangeOrigin) Validate

func (o ExchangeOrigin) Validate() error

Validate returns an error when the origin is not a known value. Callers on the money path (pending cancellation) must fail loud on an unexpected origin rather than silently defaulting to the wrong partition.

type MuteNotifScope

type MuteNotifScope string

MuteNotifScope is the set of valid notification scopes for per-recipient muting (issue #297). Each scope corresponds to one category of outbound email; a mute row suppresses only that category for its holder.

const (
	// ScopePurchaseApprovals suppresses purchase-approval-request emails.
	ScopePurchaseApprovals MuteNotifScope = "purchase_approvals"
	// ScopeRIExchangeApprovals suppresses RI-exchange pending-approval emails.
	ScopeRIExchangeApprovals MuteNotifScope = "ri_exchange_approvals"
)

type NoSQLDetails

type NoSQLDetails struct {
	Engine          string `json:"engine"`
	APIType         string `json:"api_type,omitempty"`
	ThroughputUnits int    `json:"throughput_units,omitempty"`
}

NoSQLDetails represents NoSQL-specific details (Cosmos DB, DynamoDB, Firestore). Engine is the provider-level family ("cosmos", "dynamodb", "firestore"); APIType is the cosmos-specific sub-API (sql, mongodb, cassandra, gremlin, table) and stays empty for non-cosmos engines. ThroughputUnits is the reserved-throughput unit (RU/s for cosmos). Zero-value fields indicate the source payload didn't supply the data (e.g. SKU string lacks a throughput tier or API-type hint) — do NOT treat zero as "definitely zero", only as "unknown".

func (NoSQLDetails) GetDetailDescription

func (d NoSQLDetails) GetDetailDescription() string

func (NoSQLDetails) GetServiceType

func (d NoSQLDetails) GetServiceType() ServiceType

type OfferingDetails

type OfferingDetails struct {
	OfferingID          string  `json:"offering_id"`
	ResourceType        string  `json:"resource_type"`
	Term                string  `json:"term"`
	PaymentOption       string  `json:"payment_option"`
	UpfrontCost         float64 `json:"upfront_cost"`
	RecurringCost       float64 `json:"recurring_cost"`
	TotalCost           float64 `json:"total_cost"`
	EffectiveHourlyRate float64 `json:"effective_hourly_rate"`
	Currency            string  `json:"currency"`
}

OfferingDetails represents cloud provider offering details

type ProviderType

type ProviderType string

ProviderType identifies the cloud provider

const (
	ProviderAWS   ProviderType = "aws"
	ProviderAzure ProviderType = "azure"
	ProviderGCP   ProviderType = "gcp"
)

func (ProviderType) String

func (p ProviderType) String() string

String returns the string representation of the provider type

type PurchaseOptions

type PurchaseOptions struct {
	Source string
	// ReservationID, when set, is used as the provider-side commitment
	// identifier (e.g. RDS ReservedDBInstanceId) so purchased commitments
	// carry a descriptive, account/engine/region-aware name instead of a
	// generic auto-generated one. Providers sanitize it to their ID rules.
	ReservationID string
	// IdempotencyToken, when non-empty, is a deterministic token (see
	// DeriveIdempotencyToken) that makes commitment creation idempotent across
	// re-drives of the same execution (issue #636). Savings Plans pass it as
	// the native CreateSavingsPlan ClientToken; EC2 RIs (which have no native
	// token) check for an existing RI tagged with it before purchasing and tag
	// the new RI with it afterwards. Empty means no idempotency guard (the CLI
	// purchase path, which has no owning execution, leaves it empty and keeps
	// its prior non-idempotent behaviour).
	IdempotencyToken string
	// ExecutionID, when non-empty, is the purchase_executions row UUID that
	// owns this purchase attempt. Carried so the purchase-execution flow can
	// emit log lines tagged with the execution ID for correlation with the
	// CloudWatch / DB execution row (issue #667). The CLI purchase path has
	// no owning execution and leaves it empty.
	ExecutionID string
	// OfferingClass is the EC2 Reserved Instance offering class for this
	// purchase: "convertible" (exchangeable) or "standard" (locked, ~5%
	// cheaper). Empty means the caller has not set one; the EC2 client
	// defaults to "convertible" to preserve pre-694 behaviour.
	// Only meaningful for EC2 RI purchases; ignored by other providers.
	OfferingClass string
}

PurchaseOptions carries per-execution metadata threaded through ServiceClient.PurchaseCommitment. Source is the CUDly surface that triggered the purchase (CLI vs web); every provider stamps it onto the commitment it creates (as a tag, label, or — where the cloud API permits nothing else — encoded in the commitment description).

type PurchaseResult

type PurchaseResult struct {
	Recommendation Recommendation `json:"recommendation"`
	Success        bool           `json:"success"`
	CommitmentID   string         `json:"commitment_id,omitempty"`
	Error          error          `json:"error,omitempty"`
	Cost           float64        `json:"cost"`
	DryRun         bool           `json:"dry_run"`
	Timestamp      time.Time      `json:"timestamp"`
}

PurchaseResult represents the outcome of a commitment purchase

type RIUtilization

type RIUtilization struct {
	ReservedInstanceID string  `json:"reserved_instance_id"`
	UtilizationPercent float64 `json:"utilization_percent"`
	PurchasedHours     float64 `json:"purchased_hours"`
	TotalActualHours   float64 `json:"total_actual_hours"`
	UnusedHours        float64 `json:"unused_hours"`
	// SKUName is the Azure reservation SKU (e.g. "Standard_D2s_v3"). It is
	// empty for AWS RIs because Cost Explorer does not return the instance
	// type on the utilization response. Consumers that need it for Azure
	// can populate downstream display from this field; AWS consumers can
	// join on ReservedInstanceID via the EC2 DescribeReservedInstances API.
	SKUName string `json:"sku_name,omitempty"`
}

RIUtilization holds utilization data for a single Reserved Instance over a lookback period. The struct is defined here (rather than in a provider package) so both AWS and Azure can return the same type and callers can treat either provider's results uniformly without a cross-provider import.

AWS field mapping (Cost Explorer GetReservationUtilization):

ReservedInstanceID  <- dimension key (SUBSCRIPTION_ID group value)
PurchasedHours      <- UtilizationsByTime[].Groups[].Utilization.PurchasedHours
TotalActualHours    <- UtilizationsByTime[].Groups[].Utilization.TotalActualHours
UnusedHours         <- UtilizationsByTime[].Groups[].Utilization.UnusedHours
UtilizationPercent  <- derived: (TotalActualHours / PurchasedHours) * 100

Azure field mapping (Consumption ReservationsSummaries, monthly grain):

ReservedInstanceID  <- Properties.ReservationID
PurchasedHours      <- Properties.ReservedHours  (sum across periods)
TotalActualHours    <- Properties.UsedHours       (sum across periods)
UnusedHours         <- PurchasedHours - TotalActualHours
UtilizationPercent  <- derived: (TotalActualHours / PurchasedHours) * 100
SKUName             <- Properties.SKUName (Azure-only; empty string for AWS)

type Recommendation

type Recommendation struct {
	// Provider identification
	Provider    ProviderType `json:"provider" csv:"Provider"`
	Account     string       `json:"account" csv:"Account"`
	AccountName string       `json:"account_name" csv:"AccountName"`

	// Service identification
	Service ServiceType `json:"service" csv:"Service"`
	Region  string      `json:"region" csv:"Region"`

	// Resource details
	ResourceType string `json:"resource_type" csv:"ResourceType"` // Instance type, node type, VM size, etc.
	Count        int    `json:"count" csv:"Count"`

	// Commitment details
	CommitmentType CommitmentType `json:"commitment_type" csv:"CommitmentType"` // RI, SP, CUD, etc.
	Term           string         `json:"term" csv:"Term"`                      // 1yr, 3yr
	PaymentOption  string         `json:"payment_option" csv:"PaymentOption"`   // all-upfront, partial, no-upfront, monthly

	// Cost information
	OnDemandCost      float64 `json:"on_demand_cost" csv:"OnDemandCost"`
	CommitmentCost    float64 `json:"commitment_cost" csv:"CommitmentCost"`
	EstimatedSavings  float64 `json:"estimated_savings" csv:"EstimatedSavings"`
	SavingsPercentage float64 `json:"savings_percentage" csv:"SavingsPercentage"`
	// RecurringMonthlyCost is the recurring monthly charge for this commitment
	// (i.e. the part the user pays every month after any upfront payment).
	// nil means the provider API did not return a monthly breakdown — the
	// frontend renders nil as "—" rather than "$0" to avoid misleading users.
	// Populated by cloud parsers when the API exposes it; left nil otherwise.
	RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty" csv:"RecurringMonthlyCost"`

	// Service-specific details (polymorphic)
	Details ServiceDetails `json:"details,omitempty" csv:"-"`

	// Metadata
	SourceRecommendation string    `json:"source_recommendation,omitempty" csv:"SourceRecommendation"`
	Timestamp            time.Time `json:"timestamp,omitempty" csv:"Timestamp"`

	// Break-even in months (populated by cloud parsers where available; used by scorer filter)
	BreakEvenMonths float64 `json:"break_even_months,omitempty" csv:"BreakEvenMonths"`

	// Utilization signals — populated by cloud parsers when the API exposes them.
	// Used by --target-coverage sizing (see cmd/helpers.go: ApplyTargetCoverage).
	// AverageInstancesUsedPerHour is RI-only (zero for SPs and other commitment types).
	// RecommendedUtilization is "what AWS projects for the full recommendation"
	// (%). ProjectedUtilization / ProjectedCoverage are populated by the sizing
	// step after we pick our own quantity.
	// RecommendedCount is AWS's pre-sizing count (mirrors Count before
	// ApplyCoverage / ApplyTargetCoverage mutates Count); zero for SPs since
	// the SP commitment is dollar-denominated rather than count-denominated.
	// ExistingCoveragePct is the share of demand already covered by existing
	// commitments in the same pool (from CE GetReservationCoverage /
	// GetSavingsPlansCoverage). Zero = "no signal" (CE returned nothing for
	// this pool, or the fetch step wasn't run); sizing then degenerates to
	// the no-existing-commitments path. See cmd/helpers.go.
	AverageInstancesUsedPerHour float64 `json:"average_instances_used_per_hour,omitempty" csv:"AverageInstancesUsedPerHour"`
	RecommendedUtilization      float64 `json:"recommended_utilization,omitempty" csv:"RecommendedUtilization"`
	RecommendedCount            int     `json:"recommended_count,omitempty" csv:"RecommendedCount"`
	ExistingCoveragePct         float64 `json:"existing_coverage_pct,omitempty" csv:"ExistingCoveragePct"`
	// ExistingCoverageKnown distinguishes "CE returned a value for this
	// pool" (Known=true, Pct possibly 0.0 meaning the pool has running
	// instances but no RI coverage yet) from "CE has no data for this
	// pool" (Known=false, Pct=0.0 by default). Set by
	// ApplyCoverageMapToRecommendations whenever a pool lookup hits, and
	// by family-NU sizing when a family-level existing% lands on the rec.
	// CSV writers use this to render "n/a" for unknown vs "0.0" for
	// genuine zero-coverage pools.
	ExistingCoverageKnown bool    `json:"existing_coverage_known,omitempty" csv:"-"`
	ProjectedUtilization  float64 `json:"projected_utilization,omitempty" csv:"ProjectedUtilization"`
	ProjectedCoverage     float64 `json:"projected_coverage,omitempty" csv:"ProjectedCoverage"`

	// RawRecommendation holds the original cloud API response bytes for audit/debugging.
	// omitempty ensures nil is absent from JSON (not written as null).
	RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty" csv:"-"`

	// UsageHistory is an ordered slice of daily coverage/utilisation
	// percentages (0-100) for the last N days of the lookback window
	// (oldest-to-newest). Populated by cloud collectors that can source the
	// signal from the provider API; nil when not yet wired or when the
	// provider returned no data (frontend renders "—" in that case).
	// Not written to CSV (no column heading — enrichment-only field).
	UsageHistory []float64 `json:"usage_history,omitempty" csv:"-"`
}

Recommendation represents a commitment purchase recommendation across any cloud provider

func ScaleRecommendationCosts

func ScaleRecommendationCosts(rec Recommendation, ratio float64) Recommendation

ScaleRecommendationCosts multiplies all cost-bearing fields of rec by ratio and returns the result. RecurringMonthlyCost is allocated as a new pointer when present so callers don't mutate the upstream rec's pointer target. Used by sizing paths (ApplyCoverage, ApplyTargetCoverage, family-NU) to keep Count and cost in sync when a recommendation is sized down (or up) from AWS's proposal — without this helper the same four-field scaling pattern was duplicated at every sizing site.

type RecommendationParams

type RecommendationParams struct {
	Service        ServiceType
	Region         string
	LookbackPeriod string // 7d, 30d, 60d
	Term           string // 1yr, 3yr
	PaymentOption  string
	AccountFilter  []string
	IncludeRegions []string
	ExcludeRegions []string
	// Savings Plans specific filters
	IncludeSPTypes []string // Compute, EC2Instance, SageMaker, Database
	ExcludeSPTypes []string
}

RecommendationParams represents parameters for fetching recommendations

type Region

type Region struct {
	Provider    ProviderType `json:"provider"`
	ID          string       `json:"id"`
	Name        string       `json:"name"`
	DisplayName string       `json:"display_name"`
}

Region represents a cloud region/location

type ReservationNameFields

type ReservationNameFields struct {
	// Service is the short identifier for the AWS service, e.g. "rds",
	// "cache", "memdb", "opensearch", "redshift". Set per-call by the
	// service client; the builder treats it as opaque and sanitizes it.
	Service string

	// Region is the AWS region string (e.g. "us-east-1", "eu-west-1").
	Region string

	// ResourceType is the AWS instance/node type (e.g. "db.t4g.medium",
	// "m5.large"). Dots are converted to hyphens by SanitizeReservationID.
	ResourceType string

	// Count is the reservation quantity. Always rendered as "{N}x".
	Count int

	// Term is the commitment term, normalized to "1yr" / "3yr" by upstream
	// recommendation parsers. The builder collapses it to "1yr"/"3yr" when
	// possible, and sanitizes otherwise.
	Term string

	// Payment is the payment-option string from the recommendation
	// ("all-upfront", "no-upfront", "partial-upfront"). The builder
	// normalizes to a short form ("allup", "noup", "partup") so the
	// segment stays under 8 chars.
	Payment string

	// Now is the timestamp baseline. Must be non-zero for production calls.
	// Tests inject a fixed value for determinism.
	Now time.Time
	// contains filtered or unexported fields
}

ReservationNameFields carries the inputs needed by BuildReservationName.

Now and randSource are exposed so tests can pin time and randomness for deterministic assertions. Production callers leave randSource nil (the builder then uses crypto/rand) and pass time.Now().

func (ReservationNameFields) WithRandSource

func (f ReservationNameFields) WithRandSource(b []byte) ReservationNameFields

WithRandSource returns a copy of f with the given bytes used as the random suffix source (test hook). Production code does not call this.

type SavingsPlanDetails

type SavingsPlanDetails struct {
	PlanType         string  `json:"plan_type"` // Compute, EC2Instance, SageMaker
	HourlyCommitment float64 `json:"hourly_commitment"`
	Coverage         string  `json:"coverage,omitempty"`
	// InstanceFamily is the EC2 instance family recommended by Cost Explorer
	// (e.g. "m5"). Only populated for EC2Instance Savings Plans; empty for
	// Compute, SageMaker and Database plans which are family-agnostic. Used at
	// purchase time to add an instanceFamily filter to DescribeSavingsPlansOfferings
	// so the offering resolves to the correct family rather than the
	// lexicographically-smallest across all families in the region.
	InstanceFamily string `json:"instance_family,omitempty"`
	// Region is the AWS region recommended by Cost Explorer for this EC2Instance
	// Savings Plan (e.g. "us-east-1"). Only populated for EC2Instance plans;
	// Compute, SageMaker and Database plans are global and carry no region.
	// Used at purchase time as the region filter for DescribeSavingsPlansOfferings
	// instead of the client's configured region, which may differ.
	Region string `json:"region,omitempty"`
	// OfferingID is the exact Savings Plans offering ID returned by Cost Explorer
	// in SavingsPlansDetails.OfferingId. When non-empty the purchase path uses it
	// directly and skips DescribeSavingsPlansOfferings entirely, which is the
	// safest possible resolution. Only populated when CE provides it.
	OfferingID string `json:"offering_id,omitempty"`
}

SavingsPlanDetails represents AWS Savings Plans specific details

func (SavingsPlanDetails) GetDetailDescription

func (d SavingsPlanDetails) GetDetailDescription() string

func (SavingsPlanDetails) GetServiceType

func (d SavingsPlanDetails) GetServiceType() ServiceType

type SearchDetails

type SearchDetails struct {
	InstanceType    string `json:"instance_type"`
	MasterNodeCount int    `json:"master_node_count,omitempty"`
	MasterNodeType  string `json:"master_node_type,omitempty"`
}

SearchDetails represents search-specific details (OpenSearch, Azure Search)

func (SearchDetails) GetDetailDescription

func (d SearchDetails) GetDetailDescription() string

func (SearchDetails) GetServiceType

func (d SearchDetails) GetServiceType() ServiceType

type ServiceDetails

type ServiceDetails interface {
	GetServiceType() ServiceType
	GetDetailDescription() string
}

ServiceDetails is an interface for service-specific details

func DecodeServiceDetailsFor

func DecodeServiceDetailsFor(service string, raw json.RawMessage) (ServiceDetails, error)

DecodeServiceDetailsFor reconstructs a typed *Details pointer from a raw JSON payload, dispatched by the service string from RecommendationRecord. Service accepts the same strings persisted by the scheduler's converter (e.g. "ec2", "rds", "elasticache", "opensearch", "redshift", "memorydb", "savingsplans", "savings-plans-compute" and the dash-free spellings — see internal/purchase/execution.go: mapServiceType / mapSavingsPlansSlug).

Behavior:

  • raw is empty AND service maps to a known *Details type → returns a zero-valued typed pointer (the legacy / pre-#453 fallback). The downstream service client's buildOfferingFilters tolerates zero- valued Platform / Tenancy / Scope etc. by substituting defaults.
  • raw is empty AND service maps to no *Details type (services we don't know about, plus AWS services whose clients don't currently type-assert Details and accept nil) → returns (nil, nil).
  • raw is non-empty → unmarshals into the typed pointer for the service. Unmarshal errors surface as errors (don't fall back silently — that's how #453's mis-purchases hid for a release).
  • service is unknown → returns (nil, nil) so callers fall through to their existing nil-details paths rather than refusing a purchase on a service we never had a *Details type for in the first place.

type ServiceType

type ServiceType string

ServiceType identifies the service type across clouds

const (
	// Compute
	ServiceCompute ServiceType = "compute" // EC2, VM, Compute Engine

	// Database
	ServiceRelationalDB ServiceType = "relational-db" // RDS, Azure SQL, Cloud SQL
	ServiceNoSQL        ServiceType = "nosql"         // DynamoDB, CosmosDB, Firestore

	// Cache
	ServiceCache ServiceType = "cache" // ElastiCache, Azure Cache, Memorystore

	// Search
	ServiceSearch ServiceType = "search" // OpenSearch, Azure Search

	// Data Warehouse
	ServiceDataWarehouse ServiceType = "data-warehouse" // Redshift, Synapse, BigQuery

	// Storage
	ServiceStorage ServiceType = "storage" // S3, Blob Storage, Cloud Storage

	// Savings/Commitments
	//
	// ServiceSavingsPlansAll is the umbrella sentinel for all AWS Savings Plans.
	// Its string value "savingsplans" (no hyphen) matches the frontend identifier
	// and the value persisted in service_configs.service / purchase_history.service
	// so that direct comparisons work without a normaliser. See issue #85 for the
	// rationale (frontend chosen as canonical to avoid a SQL data migration).
	//
	// This is NOT a per-plan-type slug. The four granular forms are:
	//   ServiceSavingsPlansCompute, ServiceSavingsPlansEC2Instance,
	//   ServiceSavingsPlansSageMaker, ServiceSavingsPlansDatabase.
	// Use SavingsPlansPlanTypes() to iterate over them in canonical order.
	//
	// Code that needs to recognise pre-#85 persisted "savings-plans" rows
	// (e.g. purchase_executions JSONB blobs) goes through the mapper in
	// internal/purchase/execution.go.
	ServiceSavingsPlansAll ServiceType = "savingsplans" // umbrella sentinel for "any SP"

	// Per-plan-type Savings Plans slugs. Each maps 1:1 to an AWS
	// types.SupportedSavingsPlansType so users can configure term/payment
	// defaults independently per plan type. These were introduced after the
	// umbrella was normalised; the dash-form slugs intentionally differ from
	// the umbrella's "savingsplans" so a generic-vs-specific comparison is
	// unambiguous (use IsSavingsPlan to recognise the family).
	ServiceSavingsPlansCompute     ServiceType = "savings-plans-compute"     // ComputeSp: EC2, Fargate, Lambda
	ServiceSavingsPlansEC2Instance ServiceType = "savings-plans-ec2instance" // Ec2InstanceSp: specific EC2 families
	ServiceSavingsPlansSageMaker   ServiceType = "savings-plans-sagemaker"   // SagemakerSp
	ServiceSavingsPlansDatabase    ServiceType = "savings-plans-database"    // DatabaseSp: RDS
	ServiceCommitments             ServiceType = "commitments"               // Generic commitments

	// Other
	ServiceOther ServiceType = "other" // Catch-all for unclassified services

	// Legacy AWS service types (for backward compatibility)
	ServiceEC2         ServiceType = "ec2"
	ServiceRDS         ServiceType = "rds"
	ServiceElastiCache ServiceType = "elasticache"
	ServiceOpenSearch  ServiceType = "opensearch"
	// ServiceElasticsearch is a typed alias of ServiceOpenSearch — a future
	// const declared with the same string value but different intent will
	// now produce a compile error rather than silently equal.
	ServiceElasticsearch             = ServiceOpenSearch
	ServiceRedshift      ServiceType = "redshift"
	ServiceMemoryDB      ServiceType = "memorydb"
)

func SavingsPlansPlanTypes

func SavingsPlansPlanTypes() []ServiceType

SavingsPlansPlanTypes returns the four per-plan-type SP slugs in canonical iteration order (Compute -> EC2Instance -> SageMaker -> Database). Use this for any fan-out over "all SP plan types"; it makes the 4-way expansion visible at the call site instead of relying on the umbrella sentinel (ServiceSavingsPlansAll) to expand implicitly inside the rec collector.

func (ServiceType) String

func (s ServiceType) String() string

String returns the string representation of the service type

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL