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
2 changes: 2 additions & 0 deletions internal/api/execution_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"

"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -160,6 +161,7 @@ func scopeTestExecution(k scopeTestKind, status string) *config.PurchaseExecutio
CloudAccountID: k.cloudAccountID,
Recommendations: k.recs,
CreatedByUserID: &creator,
ScheduledDate: time.Now().Add(time.Hour), // not due, so resume needs no reschedule
}
}

Expand Down
30 changes: 30 additions & 0 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ func (h *Handler) resumePlannedPurchase(ctx context.Context, req *events.LambdaF
return nil, err
}

// A recommendation-bearing row that is past its date is moved to
// now + NotificationDaysBefore on resume; a plan without a veto window
// would move it to "now", so refuse before changing anything.
if checkErr := h.requireResumeVetoWindow(ctx, executionID, time.Now()); checkErr != nil {
return nil, checkErr
}

// Atomically transition from paused back to pending
resumed, transitionErr := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending", resolveCreatorUserID(session))
if transitionErr != nil {
Expand All @@ -240,6 +247,26 @@ func (h *Handler) resumePlannedPurchase(ctx context.Context, req *events.LambdaF
return &StatusResponse{Status: "resumed"}, nil
}

// requireResumeVetoWindow refuses to resume a recommendation-bearing row whose
// scheduled_date has passed when its plan has no veto window (S13).
func (h *Handler) requireResumeVetoWindow(ctx context.Context, executionID string, now time.Time) error {
row, err := h.config.GetExecutionByID(ctx, executionID)
if err != nil {
return fmt.Errorf("failed to read execution %s before resume: %w", executionID, err)
}
if len(row.Recommendations) == 0 || !row.ScheduledDate.Before(now) {
return nil
}
plan, err := h.config.GetPurchasePlan(ctx, row.PlanID)
if err != nil {
return fmt.Errorf("failed to read plan %s before resuming execution %s: %w", row.PlanID, executionID, err)
}
if vetoErr := plan.RequireVetoWindow(); vetoErr != nil {
return NewClientError(409, fmt.Sprintf("execution %s cannot be resumed: %v", executionID, vetoErr))
}
return nil
}

// pushPastScheduledDate moves a resumed recommendation-bearing row whose
// scheduled_date has already passed to now + the plan's NotificationDaysBefore
// days, so that once it is re-notified the scheduler cannot buy it before the
Expand All @@ -255,6 +282,9 @@ func (h *Handler) pushPastScheduledDate(ctx context.Context, row *config.Purchas
if err != nil {
return fmt.Errorf("failed to read plan %s to reschedule resumed execution %s: %w", row.PlanID, row.ExecutionID, err)
}
if vetoErr := plan.RequireVetoWindow(); vetoErr != nil {
return NewClientError(409, fmt.Sprintf("execution %s was resumed but cannot be rescheduled: %v", row.ExecutionID, vetoErr))
}
// Conditional single-column update, not a full-row save: a cancel or
// approve landing after the resume must not be overwritten. A lost
// condition is a benign no-op (the row is no longer awaiting approval).
Expand Down
13 changes: 13 additions & 0 deletions internal/api/handler_purchases_notified_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,16 @@ func TestNotifiedRow_DeleteReleasesSuppressions(t *testing.T) {
assert.Equal(t, "canceled", f.state(t).Status)
assert.Zero(t, f.suppressionCount(t))
}

// S13 on resume: without a veto window a due recommendation-bearing row stays paused.
func TestNotifiedRow_ResumeRefusedWithoutVetoWindow(t *testing.T) {
f := newPauseClaimFixture(t)
plan, err := f.store.GetPurchasePlan(f.ctx, f.execution.PlanID)
require.NoError(t, err)
plan.NotificationDaysBefore = 0
require.NoError(t, f.store.UpdatePurchasePlan(f.ctx, plan))
f.setRow(t, "paused", time.Now().Add(-48*time.Hour))

assertPauseConflict(t, f.resume())
assert.Equal(t, "paused", f.state(t).Status)
}
37 changes: 37 additions & 0 deletions internal/api/handler_purchases_notified_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func TestResumePlannedPurchase_PastScheduledDate(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
handler, store, req := notifiedAdminHandler(ctx)
row := &config.PurchaseExecution{ExecutionID: notifiedExecID, PlanID: "plan-1", Status: "pending", Recommendations: tc.recs, ScheduledDate: tc.date}
store.On("GetExecutionByID", ctx, notifiedExecID).Return(row, nil).Maybe()
store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"paused"}, "pending", mock.Anything).Return(row, nil)
store.On("GetPurchasePlan", ctx, "plan-1").Return(&config.PurchasePlan{ID: "plan-1", NotificationDaysBefore: 3}, nil).Maybe()
var movedTo *time.Time
Expand Down Expand Up @@ -87,3 +88,39 @@ func TestCancelOrRecoverExecution_ReleasesSuppressionsOfNotifiedRow(t *testing.T
require.NoError(t, err)
store.AssertCalled(t, "DeleteSuppressionsByExecution", ctx, notifiedExecID)
}

// S13 on the resume path: a plan without a veto window must not have a due
// recommendation-bearing row moved to "now"; nothing is changed.
func TestResumePlannedPurchase_RefusesWithoutVetoWindow(t *testing.T) {
ctx := context.Background()
recs := []config.RecommendationRecord{{ID: "rec-1", Count: 1}}
for _, tc := range []struct {
name string
recs []config.RecommendationRecord
date time.Time
refused bool
}{
{"rec-bearing past date, 0 days", recs, time.Now().Add(-time.Hour), true},
{"rec-bearing future date, 0 days", recs, time.Now().Add(time.Hour), false},
{"empty row past date, 0 days", nil, time.Now().Add(-time.Hour), false},
} {
t.Run(tc.name, func(t *testing.T) {
handler, store, req := notifiedAdminHandler(ctx)
row := &config.PurchaseExecution{ExecutionID: notifiedExecID, PlanID: "plan-1", Status: "paused", Recommendations: tc.recs, ScheduledDate: tc.date}
store.On("GetExecutionByID", ctx, notifiedExecID).Return(row, nil)
store.On("GetPurchasePlan", ctx, "plan-1").Return(&config.PurchasePlan{ID: "plan-1", NotificationDaysBefore: 0}, nil).Maybe()
store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"paused"}, "pending", mock.Anything).Return(row, nil).Maybe()
store.On("RescheduleAwaitingExecution", ctx, notifiedExecID, mock.Anything).Return(true, nil).Maybe()

_, err := handler.resumePlannedPurchase(ctx, req, notifiedExecID)
if !tc.refused {
require.NoError(t, err)
return
}
ce, ok := IsClientError(err)
require.True(t, ok, "got %v", err)
assert.Equal(t, 409, ce.code)
store.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func newPauseClaimFixture(t *testing.T) *pauseClaimFixture {
plan, err := f.store.GetPurchasePlan(ctx, f.planID)
require.NoError(t, err)
plan.AutoPurchase = true
plan.NotificationDaysBefore = 3
plan.MaxStepCommitment = &pauseClaimCap
require.NoError(t, f.store.UpdatePurchasePlan(ctx, plan))
globalCfg, err := f.store.GetGlobalConfig(ctx)
Expand Down
2 changes: 2 additions & 0 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,7 @@ func TestHandler_resumePlannedPurchase(t *testing.T) {
mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockAuth.grantAdmin()
mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending", mock.Anything).Return(resumed, nil)
mockStore.On("GetExecutionByID", mock.Anything, mock.Anything).Return(resumed, nil).Maybe()

handler := &Handler{config: mockStore, auth: mockAuth}

Expand Down Expand Up @@ -2172,6 +2173,7 @@ func TestHandler_resumePlannedPurchase_NilExecution(t *testing.T) {
mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockAuth.grantAdmin()
mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"paused"}, "pending", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999"))
mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(&config.PurchaseExecution{}, nil).Maybe()

handler := &Handler{config: mockStore, auth: mockAuth}

Expand Down
1 change: 1 addition & 0 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,7 @@ func TestHandler_HandleRequest_ResumePlannedPurchase(t *testing.T) {

resumed := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "pending"}
mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending", mock.Anything).Return(resumed, nil)
mockStore.On("GetExecutionByID", mock.Anything, mock.Anything).Return(resumed, nil).Maybe()

handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"}

Expand Down
21 changes: 21 additions & 0 deletions internal/config/plan_veto_window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package config

import (
"errors"
"fmt"
)

// ErrNoVetoWindow marks a plan whose notification window is empty: with
// NotificationDaysBefore < 1 the email goes out when the purchase is already
// due, so the human veto the notified status stands for has no time to happen.
var ErrNoVetoWindow = errors.New("plan has no veto window")

// RequireVetoWindow refuses a plan that cannot give an emailed purchase at
// least one day before it runs. Attaching recommendations to a plan step and
// re-scheduling a resumed recommendation-bearing row both call it.
func (p *PurchasePlan) RequireVetoWindow() error {
if p.NotificationDaysBefore < 1 {
return fmt.Errorf("%w: plan %s has notification_days_before=%d, need at least 1", ErrNoVetoWindow, p.ID, p.NotificationDaysBefore)
}
return nil
}
32 changes: 32 additions & 0 deletions internal/config/plan_veto_window_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package config

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRequireVetoWindow(t *testing.T) {
for days, wantErr := range map[int]bool{-1: true, 0: true, 1: false, 7: false} {
err := (&PurchasePlan{ID: "p", NotificationDaysBefore: days}).RequireVetoWindow()
if wantErr {
assert.ErrorIs(t, err, ErrNoVetoWindow, "days=%d", days)
} else {
assert.NoError(t, err, "days=%d", days)
}
}
}

func TestPlanStepLockKeys_SortedDedupedValidated(t *testing.T) {
keys, err := planStepLockKeys([]string{"gcp", "aws", "azure", "aws"})
require.NoError(t, err)
assert.Equal(t, []string{"cudly:planstep:aws", "cudly:planstep:azure", "cudly:planstep:gcp"}, keys)

_, err = planStepLockKeys(nil)
assert.Error(t, err)
_, err = planStepLockKeys([]string{"aws", ""})
assert.Error(t, err)
assert.Error(t, (&PostgresStore{}).LockPlanProvidersTx(context.Background(), nil, nil))
}
33 changes: 21 additions & 12 deletions internal/config/store_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -1680,28 +1680,37 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) (
// planStepProviderLockPrefix namespaces the per-provider plan-step advisory lock.
const planStepProviderLockPrefix = "cudly:planstep:"

// LockPlanProvidersTx implements StoreInterface.LockPlanProvidersTx. Sorted
// acquisition keeps two callers with overlapping provider sets from
// deadlocking on each other.
func (s *PostgresStore) LockPlanProvidersTx(ctx context.Context, tx pgx.Tx, providers []string) error {
// planStepLockKeys returns the advisory-lock keys for providers: validated,
// de-duplicated and sorted. Sorted acquisition keeps two callers with
// overlapping provider sets from deadlocking on each other.
func planStepLockKeys(providers []string) ([]string, error) {
if len(providers) == 0 {
return errors.New("lock plan providers: no providers given")
return nil, errors.New("lock plan providers: no providers given")
}
sorted := make([]string, 0, len(providers))
keys := make([]string, 0, len(providers))
seen := make(map[string]struct{}, len(providers))
for _, p := range providers {
if p == "" {
return errors.New("lock plan providers: empty provider name")
return nil, errors.New("lock plan providers: empty provider name")
}
if _, dup := seen[p]; !dup {
seen[p] = struct{}{}
sorted = append(sorted, p)
keys = append(keys, planStepProviderLockPrefix+p)
}
}
sort.Strings(sorted)
for _, p := range sorted {
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", planStepProviderLockPrefix+p); err != nil {
return fmt.Errorf("lock plan provider %s: %w", p, err)
sort.Strings(keys)
return keys, nil
}

// LockPlanProvidersTx implements StoreInterface.LockPlanProvidersTx.
func (s *PostgresStore) LockPlanProvidersTx(ctx context.Context, tx pgx.Tx, providers []string) error {
keys, err := planStepLockKeys(providers)
if err != nil {
return err
}
for _, key := range keys {
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", key); err != nil {
return fmt.Errorf("lock plan step key %s: %w", key, err)
}
}
return nil
Expand Down
17 changes: 17 additions & 0 deletions internal/email/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,14 @@ type NotificationData struct {
DaysUntilPurchase int
TotalUpfrontCost float64
TotalSavings float64
// TotalCommitment, TotalRecurring, TotalUnits and StepCap describe what a
// scheduled plan step will buy. TotalCommitment is upfront plus the
// recurring charges over each term; StepCap is the plan's per-step cap, 0
// when none is shown. A zero TotalCommitment omits the block.
TotalCommitment float64
TotalRecurring float64
TotalUnits int
StepCap float64
// RevocationToken is the one-time token embedded in the revocation link
// of a post-execution notification email. When non-empty, the template
// renders a "Revoke this purchase" CTA that hits
Expand Down Expand Up @@ -607,6 +615,15 @@ type RecommendationSummary struct {
MonthlySavings float64
Term int
UpfrontCost float64
// MonthlyCost, Commitment, AvailabilityZone, Platform and Tenancy are the
// per-row detail of a scheduled plan step. Commitment is upfront plus
// monthly over the term, so a no-upfront (Azure monthly) row does not read
// as $0.
MonthlyCost float64
Commitment float64
AvailabilityZone string
Platform string
Tenancy string
}

// RIExchangeNotificationData holds data for RI exchange email templates.
Expand Down
9 changes: 6 additions & 3 deletions internal/email/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ const scheduledPurchaseTemplate = `CUDly - Scheduled Purchase in {{.DaysUntilPur
=========================================

Based on your Purchase Plan "{{.PlanName}}", the following commitments will be purchased on {{.PurchaseDate}}:

{{if gt .TotalCommitment 0.0}}
Will buy {{len .Recommendations}} recommendation(s) ({{.TotalUnits}} units) for ${{printf "%.2f" .TotalCommitment}} total commitment (upfront ${{printf "%.2f" .TotalUpfrontCost}} + ${{printf "%.2f" .TotalRecurring}} recurring over the terms){{if gt .StepCap 0.0}}, cap ${{printf "%.2f" .StepCap}}{{end}}, on {{.PurchaseDate}} unless canceled.
{{end}}
{{range .Recommendations}}
- {{.Count}}x {{.ResourceType}}{{if .Engine}} ({{.Engine}}){{end}} in {{.Region}}
Service: {{.Service}} | Est. Savings: ${{printf "%.2f" .MonthlySavings}}/month
- {{.Count}}x {{.ResourceType}}{{if .Engine}} ({{.Engine}}){{end}} in {{.Region}}{{if .AvailabilityZone}} ({{.AvailabilityZone}}){{end}}{{if .Platform}} | {{.Platform}}{{end}}{{if .Tenancy}} | {{.Tenancy}}{{end}}
Service: {{.Service}} | Est. Savings: ${{printf "%.2f" .MonthlySavings}}/month{{if gt .Commitment 0.0}}
Term: {{.Term}} yr {{.Payment}} | Upfront ${{printf "%.2f" .UpfrontCost}} + ${{printf "%.2f" .MonthlyCost}}/month | Commitment ${{printf "%.2f" .Commitment}}{{end}}
{{end}}

Summary:
Expand Down
48 changes: 48 additions & 0 deletions internal/purchase/notification_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package purchase

import (
"encoding/json"
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/email"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Fixture rows: nothing attaches recommendations to a plan step yet.
func TestBuildNotificationData_ShowsTotalCommitmentAndCap(t *testing.T) {
monthly := 100.0
capAmount := 5000.0
plan := config.PurchasePlan{ID: "plan-1", Name: "Azure ramp", MaxStepCommitment: &capAmount}
exec := &config.PurchaseExecution{
ExecutionID: "exec-1", PlanID: "plan-1", ScheduledDate: time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC),
TotalUpfrontCost: 300,
Recommendations: []config.RecommendationRecord{
// Azure monthly billing: no upfront, the commitment is all recurring.
{Service: "compute", ResourceType: "Standard_D2s_v3", Region: "westeurope", Count: 2, Term: 1, Payment: "monthly", MonthlyCost: &monthly},
{Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", AvailabilityZone: "us-east-1a", Count: 3, Term: 3, Payment: "partial-upfront", UpfrontCost: 300,
Details: json.RawMessage(`{"platform":"Linux/UNIX","tenancy":"default"}`)},
},
}
m := &Manager{}

data := m.buildNotificationData(plan, exec, "tok", 3, "ops@example.invalid")

assert.InDelta(t, 1500.0, data.TotalCommitment, 1e-9)
assert.InDelta(t, 1200.0, data.TotalRecurring, 1e-9)
assert.Equal(t, 5, data.TotalUnits)
assert.InDelta(t, 5000.0, data.StepCap, 1e-9)
require.Len(t, data.Recommendations, 2)
assert.InDelta(t, 1200.0, data.Recommendations[0].Commitment, 1e-9, "an Azure monthly row is not $0")
assert.Equal(t, "us-east-1a", data.Recommendations[1].AvailabilityZone)
assert.Equal(t, "Linux/UNIX", data.Recommendations[1].Platform)
assert.Equal(t, "default", data.Recommendations[1].Tenancy)

body, err := email.RenderScheduledPurchaseEmail(data)
require.NoError(t, err)
assert.Contains(t, body, "Will buy 2 recommendation(s) (5 units) for $1500.00 total commitment (upfront $300.00 + $1200.00 recurring over the terms), cap $5000.00, on November 3, 2026 unless canceled.")
assert.Contains(t, body, "Term: 1 yr monthly | Upfront $0.00 + $100.00/month | Commitment $1200.00")
assert.Contains(t, body, "in us-east-1 (us-east-1a) | Linux/UNIX | default")
}
Loading
Loading