Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Fixed

- A partial- or all-upfront dry run whose offering cannot be priced now writes an
audit record (`status: "error"`, `dry_run: true`) instead of returning without
one, as the README promises for previews (Refs #129).
- A real purchase the provider refuses before any purchase call (for example the
EC2 approved-upfront price guard, which wraps
`common.ErrRefusedBeforePurchase`) no longer stays counted in
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Auditing is **on by default**. When auditing is enabled and a write succeeds, ea
- **Default path**: `$XDG_STATE_HOME/cudly/mcp-audit.jsonl`, falling back to `~/.local/state/cudly/mcp-audit.jsonl` when `XDG_STATE_HOME` is unset, empty, or relative.
- **`CUDLY_MCP_AUDIT_LOG`** overrides the path. Setting it to an **empty or whitespace-only string disables the log entirely** -- that is the explicit opt-out; leaving the variable unset is not the same thing and still uses the default path.
- A preview (`dry_run=true`) is recorded with `status: "skipped"`, `dry_run: true` -- it spent nothing, but it was still a decision worth reconstructing later.
- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the reason (including any provider error text) in `error_message`, as for other provider errors; it writes no `REFUSED` line, and the tool call still returns the error.
- When a credential scope is supplied, `credential_scope` records that routing identifier: an AWS profile, Azure subscription, or GCP project. It identifies how CUDly selected the target; it is not a verified provider account ID. Previews may omit it because they do not require a target.
- A real purchase that a gate refuses before any provider purchase call is recorded `"refused"`, with the refusal text in `error_message` and the requested provider, service, region, resource, count, term and `credential_scope` on the record. This covers real purchases disabled, the count, hourly, memory, USD and cumulative budget limits, a missing credential scope, a failed credential/client resolution, a missing `ResolveClient` (internal error) and an unpriceable offering. Each refusal writes exactly one record and one `mcp purchase REFUSED` line on stderr. Argument-validation errors (including an upfront EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase missing `approved_upfront`/`approved_currency`) and an unconfirmed `dry_run=false` call are not refusals and write nothing. A purchase the provider refuses for exceeding `approved_upfront` is recorded like any other provider error after the purchase call (`status: "error"`), and like those it counts against `CUDLY_MCP_BUDGET_USD`.
- A real purchase is recorded `"success"` only when the provider reports `Success: true` with no embedded error. A failed provider call, `Success: false`, or a result containing an error is recorded as `"error"`, except a failure that wraps the library's outcome-unknown error (an EC2 or Redshift purchase whose response was lost), which is recorded as `"unknown"`: the request may have reached the cloud and the commitment may exist, so reconcile with `DescribeReservedInstances` / `DescribeReservedNodes` before retrying. The stderr line for it reads `mcp purchase OUTCOME UNKNOWN` instead of `FAILED`.
Expand Down
27 changes: 14 additions & 13 deletions tools/aws_ec2_ri.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,12 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques
if err != nil {
return nil, PurchaseResponse{}, err
}
// A confirmed upfront purchase carries the dry run's figure into
// rec.CommitmentCost so the provider's go#611 price guard runs.
err = applyApprovedUpfront(&rec, args, !dryRun && confirm)
if err != nil {
return nil, PurchaseResponse{}, err
}
quote, err := t.previewUpfront(ctx, args, region, rec, string(offeringClass), dryRun)
if err != nil {
return nil, PurchaseResponse{}, err
}
// The audit record has no offering-class field, so record it in the raw
// request payload alongside the other purchase parameters.
if rec.RawRecommendation, err = json.Marshal(map[string]string{"offering_class": string(offeringClass)}); err != nil {
return nil, PurchaseResponse{}, fmt.Errorf("encode offering_class for audit: %w", err)
}

resp, err := ExecutePurchase(ctx, PurchaseRequest{
req := PurchaseRequest{
Region: region,
Recommendation: rec,
DryRun: dryRun,
Expand All @@ -137,7 +126,19 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques
Nonce: args.IdempotencyNonce,
OfferingClass: string(offeringClass),
CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"),
})
}
// A confirmed upfront purchase carries the dry run's figure into
// the recommendation so the provider's go#611 price guard runs.
err = applyApprovedUpfront(&req.Recommendation, args, !dryRun && confirm)
if err != nil {
return nil, PurchaseResponse{}, err
}
quote, err := t.previewUpfront(ctx, args, region, &req, string(offeringClass))
if err != nil {
return nil, PurchaseResponse{}, err
}

resp, err := ExecutePurchase(ctx, req)
if err != nil {
return nil, PurchaseResponse{}, err
}
Expand Down
109 changes: 3 additions & 106 deletions tools/aws_ec2_ri_upfront.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,10 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// approvedCurrency is the only currency the approved-upfront guard
// (cloud-commitments-go#611) can compare an offering against.
const approvedCurrency = "USD"

// upfrontQuote is what a partial- or all-upfront RI dry run reports: the
// offering's total upfront (FixedPrice x count) and its currency. The caller
// copies both into approved_upfront/approved_currency on the confirming call,
// and the purchase is refused if the offering it would buy then costs more
// than that figure plus go#611's tolerance.
type upfrontQuote struct {
currency string
total float64
}

// requiresApprovedUpfront reports whether a confirmed purchase with this
// payment option must carry an approved upfront. Everything except
// no-upfront does, so a payment option added later fails closed (it needs a
// figure) instead of silently skipping the guard.
func requiresApprovedUpfront(paymentOption string) bool {
return paymentOption != string(PaymentOptionNoUpfront)
}

// validateApprovedFigures checks approved_upfront and approved_currency in
// isolation: when present, the upfront must be a finite amount above zero
// (go#611 skips its check for zero or less, so accepting one would buy
// unguarded) and the currency must be exactly USD. service only words the error.
func validateApprovedFigures(upfront *float64, currency, service string) error {
if currency != "" && currency != approvedCurrency {
return fmt.Errorf("invalid approved_currency %q: must be %s, the only currency the %s price check supports",
currency, approvedCurrency, service)
}
if upfront == nil {
return nil
}
if v := *upfront; math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 {
return fmt.Errorf("invalid approved_upfront %v: must be the positive amount from the dry run's approved_upfront", v)
}
return nil
}

// applyApprovedUpfront validates the approved-upfront arguments and, for a
// confirmed real purchase of a partial- or all-upfront RI, copies them into
// rec.CommitmentCost/CommitmentCurrency so the provider's go#611 guard
Expand All @@ -60,38 +18,12 @@ func applyApprovedUpfront(rec *common.Recommendation, args ec2RIPurchaseArgs, co
return applyApprovedFigures(rec, args.ApprovedUpfront, args.ApprovedCurrency, "EC2", confirmedPurchase)
}

// applyApprovedFigures is applyApprovedUpfront for any service.
func applyApprovedFigures(rec *common.Recommendation, upfront *float64, currency, service string, confirmedPurchase bool) error {
if err := validateApprovedFigures(upfront, currency, service); err != nil {
return err
}
supplied := upfront != nil || currency != ""
if !requiresApprovedUpfront(rec.PaymentOption) {
if supplied {
return fmt.Errorf("approved_upfront and approved_currency apply only to %s and %s purchases; omit them for payment_option=%s",
PaymentOptionPartialUpfront, PaymentOptionAllUpfront, rec.PaymentOption)
}
return nil
}
if !confirmedPurchase {
return nil
}
if upfront == nil || currency == "" {
return fmt.Errorf("refusing real purchase: a payment_option=%s purchase needs approved_upfront and approved_currency; "+
"run this call with dry_run=true first and copy approved_upfront and approved_currency from its response into this call",
rec.PaymentOption)
}
rec.CommitmentCost = *upfront
rec.CommitmentCurrency = currency
return nil
}

// previewUpfront prices the offering for a partial- or all-upfront dry run.
// It returns nil (and contacts nothing) for a real purchase or a no-upfront
// dry run. The lookup is read-only (DescribeReservedInstancesOfferings) and
// needs credentials, but not the real-purchase opt-in or the spend caps.
func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPurchaseArgs, region string, rec common.Recommendation, offeringClass string, dryRun bool) (*upfrontQuote, error) {
return previewUpfrontQuote(ctx, rec, dryRun, "EC2", func(ctx context.Context) (*common.OfferingDetails, error) {
func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPurchaseArgs, region string, req *PurchaseRequest, offeringClass string) (*upfrontQuote, error) {
return previewUpfrontQuote(ctx, req, "EC2", func(ctx context.Context) (*common.OfferingDetails, error) {
client, err := t.resolveClient(args, region)(ctx)
if err != nil {
return nil, fmt.Errorf("dry run: resolve the AWS EC2 client to price the offering: %w", err)
Expand All @@ -102,50 +34,15 @@ func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPur
if !ok {
return nil, errors.New("dry run: the EC2 client cannot price an offering class (GetOfferingDetailsForClass missing, cloud-commitments-go#574)")
}
od, err := pricer.GetOfferingDetailsForClass(ctx, rec, offeringClass)
od, err := pricer.GetOfferingDetailsForClass(ctx, req.Recommendation, offeringClass)
if err != nil {
return nil, fmt.Errorf("dry run: cannot fetch the EC2 offering's upfront price: %w", err)
}
return od, nil
})
}

// previewUpfrontQuote is previewUpfront for any service: price resolves the
// client and fetches the offering, and must word its own errors.
func previewUpfrontQuote(ctx context.Context, rec common.Recommendation, dryRun bool, service string, price func(context.Context) (*common.OfferingDetails, error)) (*upfrontQuote, error) {
if !dryRun || !requiresApprovedUpfront(rec.PaymentOption) {
return nil, nil
}
od, err := price(ctx)
if err != nil {
return nil, err
}
return quoteUpfront(od, rec, service)
}

// quoteFromOffering is quoteUpfront for EC2.
func quoteFromOffering(od *common.OfferingDetails, rec common.Recommendation) (*upfrontQuote, error) {
return quoteUpfront(od, rec, "EC2")
}

// quoteUpfront turns the per-reservation offering price into the
// total upfront for rec.Count, rounded to cents. An upfront offering with
// no positive upfront, or one not priced in USD, cannot produce a figure
// the confirming call could pass, so the dry run fails instead.
func quoteUpfront(od *common.OfferingDetails, rec common.Recommendation, service string) (*upfrontQuote, error) {
if od == nil {
return nil, fmt.Errorf("dry run: the provider returned no %s offering details", service)
}
unit := od.UpfrontCost
if math.IsNaN(unit) || math.IsInf(unit, 0) || unit <= 0 {
return nil, fmt.Errorf("dry run: the %s %s offering reports upfront %v; expected a positive amount", rec.PaymentOption, service, unit)
}
if cur := strings.TrimSpace(od.Currency); cur != approvedCurrency {
return nil, fmt.Errorf("dry run: the %s offering is priced in %q; only %s purchases can be approved", service, od.Currency, approvedCurrency)
}
total := math.Round(unit*float64(rec.Count)*100) / 100
if math.IsInf(total, 0) || total <= 0 {
return nil, fmt.Errorf("dry run: the %s offering's total upfront %v is not a usable amount", service, total)
}
return &upfrontQuote{total: total, currency: approvedCurrency}, nil
}
29 changes: 15 additions & 14 deletions tools/aws_rds_ri.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,28 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques
return nil, PurchaseResponse{}, err
}

resolve := t.resolveClient(args, region)
req := PurchaseRequest{
Region: region,
Recommendation: rec,
DryRun: dryRun,
Confirm: confirm,
ResolveClient: resolve,
Nonce: args.IdempotencyNonce,
CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"),
}
// A confirmed upfront purchase carries the dry run's figure into
// rec.CommitmentCost so the provider's go#611 price guard runs.
err = applyApprovedFigures(&rec, args.ApprovedUpfront, args.ApprovedCurrency, "RDS", !dryRun && confirm)
// the recommendation so the provider's go#611 price guard runs.
err = applyApprovedFigures(&req.Recommendation, args.ApprovedUpfront, args.ApprovedCurrency, "RDS", !dryRun && confirm)
if err != nil {
return nil, PurchaseResponse{}, err
}
resolve := t.resolveClient(args, region)
quote, err := previewUpfrontQuote(ctx, rec, dryRun, "RDS", func(ctx context.Context) (*common.OfferingDetails, error) {
quote, err := previewUpfrontQuote(ctx, &req, "RDS", func(ctx context.Context) (*common.OfferingDetails, error) {
client, resolveErr := resolve(ctx)
if resolveErr != nil {
return nil, fmt.Errorf("dry run: resolve the AWS RDS client to price the offering: %w", resolveErr)
}
od, priceErr := client.GetOfferingDetails(ctx, rec)
od, priceErr := client.GetOfferingDetails(ctx, req.Recommendation)
if priceErr != nil {
return nil, fmt.Errorf("dry run: cannot fetch the RDS offering's upfront price: %w", priceErr)
}
Expand All @@ -121,15 +130,7 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques
return nil, PurchaseResponse{}, err
}

resp, err := ExecutePurchase(ctx, PurchaseRequest{
Region: region,
Recommendation: rec,
DryRun: dryRun,
Confirm: confirm,
ResolveClient: resolve,
Nonce: args.IdempotencyNonce,
CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"),
})
resp, err := ExecutePurchase(ctx, req)
if err != nil {
return nil, PurchaseResponse{}, err
}
Expand Down
29 changes: 15 additions & 14 deletions tools/aws_simple_ri.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,29 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq
return nil, PurchaseResponse{}, err
}

resolve := t.resolveClient(args, region)
req := PurchaseRequest{
Region: region,
Recommendation: rec,
DryRun: dryRun,
Confirm: confirm,
ResolveClient: resolve,
Nonce: args.IdempotencyNonce,
CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"),
}
// A confirmed upfront purchase carries the dry run's figure into
// rec.CommitmentCost so the provider's go#611 price guard runs.
// the recommendation so the provider's go#611 price guard runs.
label := t.spec.displayName
err = applyApprovedFigures(&rec, args.ApprovedUpfront, args.ApprovedCurrency, label, !dryRun && confirm)
err = applyApprovedFigures(&req.Recommendation, args.ApprovedUpfront, args.ApprovedCurrency, label, !dryRun && confirm)
if err != nil {
return nil, PurchaseResponse{}, err
}
resolve := t.resolveClient(args, region)
quote, err := previewUpfrontQuote(ctx, rec, dryRun, label, func(ctx context.Context) (*common.OfferingDetails, error) {
quote, err := previewUpfrontQuote(ctx, &req, label, func(ctx context.Context) (*common.OfferingDetails, error) {
client, resolveErr := resolve(ctx)
if resolveErr != nil {
return nil, fmt.Errorf("dry run: resolve the AWS %s client to price the offering: %w", label, resolveErr)
}
od, priceErr := client.GetOfferingDetails(ctx, rec)
od, priceErr := client.GetOfferingDetails(ctx, req.Recommendation)
if priceErr != nil {
return nil, fmt.Errorf("dry run: cannot fetch the %s offering's upfront price: %w", label, priceErr)
}
Expand All @@ -181,15 +190,7 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq
return nil, PurchaseResponse{}, err
}

resp, err := ExecutePurchase(ctx, PurchaseRequest{
Region: region,
Recommendation: rec,
DryRun: dryRun,
Confirm: confirm,
ResolveClient: resolve,
Nonce: args.IdempotencyNonce,
CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"),
})
resp, err := ExecutePurchase(ctx, req)
if err != nil {
return nil, PurchaseResponse{}, err
}
Expand Down
Loading
Loading