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
- Variables
- func BuildReservationName(f ReservationNameFields, fallbackPrefix string) string
- func DeriveIdempotencyToken(executionID string, recIndex int) string
- func DeriveMuteToken(key []byte, email, scope string) string
- func EngineFromDetails(details ServiceDetails) string
- func GenerateApprovalToken() (string, error)
- func IdempotencyGUID(token string) string
- func IdempotentReservationID(prefix, token string) string
- func IsSavingsPlan(s ServiceType) bool
- func MarshalServiceDetails(details ServiceDetails) (json.RawMessage, error)
- func MaskToken(token string) string
- func Matches(rec Recommendation, c Commitment) bool
- func NormalizeEngineName(engine string) string
- func NormalizeSource(s string) (string, error)
- func ReservationOrderID(token, fallback string) string
- func ResolveMuteSecret() ([]byte, error)
- func SanitizeReservationID(id, fallbackPrefix string) string
- func VerifyMuteToken(key []byte, email, scope, token string) bool
- func WriteAuditRecord(record AuditRecord, path string) error
- type Account
- type AuditRecord
- type CacheDetails
- type Commitment
- type CommitmentType
- type ComputeDetails
- type DataWarehouseDetails
- type DatabaseDetails
- type DropSummary
- type ExchangeOrigin
- type MuteNotifScope
- type NoSQLDetails
- type OfferingDetails
- type ProviderType
- type PurchaseOptions
- type PurchaseResult
- type RIUtilization
- type Recommendation
- type RecommendationParams
- type Region
- type ReservationNameFields
- type SavingsPlanDetails
- type SearchDetails
- type ServiceDetails
- type ServiceType
Constants ¶
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.
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.
const ArcheraEnrollmentWindowDays = 7
ArcheraEnrollmentWindowDays is how long after a purchase a buyer has to enroll that commitment in Archera's coverage.
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.
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.
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.
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.
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).
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 ¶
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).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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