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
97 changes: 97 additions & 0 deletions cmd/ec2_zonal_az_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"path/filepath"
"sync"
"testing"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/aws/aws-sdk-go-v2/aws"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// ec2FormRecorder is an offline RoundTripper that records the Action and
// AvailabilityZone of every EC2 query request and answers each with a
// non-retryable 400, so no purchase can ever succeed.
type ec2FormRecorder struct {
mu sync.Mutex
actions []string
zones []string
}

func (r *ec2FormRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
r.mu.Lock()
r.actions = append(r.actions, values.Get("Action"))
r.zones = append(r.zones, values.Get("AvailabilityZone"))
r.mu.Unlock()
payload := `<Response><Errors><Error><Code>InvalidParameterValue</Code><Message>stubbed</Message></Error></Errors></Response>`
return &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"text/xml"}},
Body: io.NopCloser(bytes.NewReader([]byte(payload))), Request: req}, nil
}

func zonalEC2Rec(scope ec2types.Scope, az string) common.Recommendation {
rec := validEC2Rec()
rec.Term = "1yr"
rec.PaymentOption = "partial-upfront"
rec.Details = &common.ComputeDetails{InstanceType: "m5.large", Platform: "Linux/UNIX", Tenancy: "default",
Scope: string(scope), AvailabilityZone: az}
return rec
}

func purchaseEC2WithRecorder(t *testing.T, rec common.Recommendation) (common.PurchaseResult, *ec2FormRecorder) {
t.Helper()
t.Setenv("DISABLE_PURCHASE_DELAY", "true")
recorder := &ec2FormRecorder{}
awsCfg := aws.Config{
HTTPClient: &http.Client{Transport: recorder},
Credentials: aws.AnonymousCredentials{},
Retryer: func() aws.Retryer { return aws.NopRetryer{} },
}
cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")}
result, _ := purchaseSingleRec(context.Background(), awsCfg, rec, 1, false, cfg)
return result, recorder
}

func TestPurchaseSingleRec_ZonalEC2KeepsItsAvailabilityZone(t *testing.T) {
result, recorder := purchaseEC2WithRecorder(t, zonalEC2Rec(ec2types.ScopeAvailabilityZone, "us-east-1b"))

require.Error(t, result.Error)
assert.Contains(t, recorder.actions, "DescribeReservedInstancesOfferings")
assert.NotContains(t, recorder.actions, "PurchaseReservedInstancesOffering")
for i, action := range recorder.actions {
if action == "DescribeReservedInstancesOfferings" {
assert.Equal(t, "us-east-1b", recorder.zones[i], "the offering query must be pinned to the rec's AZ")
}
}
}

func TestPurchaseSingleRec_RefusesInconsistentEC2ScopeBeforeAnyAWSCall(t *testing.T) {
for name, rec := range map[string]common.Recommendation{
"zonal without AZ": zonalEC2Rec(ec2types.ScopeAvailabilityZone, ""),
"regional with AZ": zonalEC2Rec(ec2types.ScopeRegional, "us-east-1b"),
"zonal blank AZ": zonalEC2Rec(ec2types.ScopeAvailabilityZone, " "),
} {
t.Run(name, func(t *testing.T) {
result, recorder := purchaseEC2WithRecorder(t, rec)

require.Error(t, result.Error)
assert.Contains(t, result.Error.Error(), "AvailabilityZone")
assert.Empty(t, recorder.actions, "no AWS call may precede the refusal")
})
}
}
7 changes: 7 additions & 0 deletions cmd/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ func TestApplyTargetCoverage_RI(t *testing.T) {
CommitmentCost: 1000,
OnDemandCost: 2000,
EstimatedSavings: 500,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: avg,
RecommendedUtilization: recUtil,
}
Expand Down Expand Up @@ -1030,6 +1031,7 @@ func TestApplyTargetCoverage_RI_CostScaling(t *testing.T) {
OnDemandCost: 2000,
EstimatedSavings: 500,
SavingsPercentage: 25,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: 8,
}
// target=80, existing=0, avg=8 → gap=80.
Expand Down Expand Up @@ -1086,6 +1088,7 @@ func TestApplyTargetCoverage_RI_ExistingCoverage(t *testing.T) {
CommitmentCost: 1000,
OnDemandCost: 2000,
EstimatedSavings: 500,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: avg,
ExistingCoveragePct: existingCov,
}
Expand Down Expand Up @@ -1233,6 +1236,7 @@ func TestApplySizing(t *testing.T) {
Service: common.ServiceEC2,
Count: 10,
CommitmentType: common.CommitmentReservedInstance,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: 8,
}

Expand Down Expand Up @@ -1271,6 +1275,7 @@ func TestApplyTargetCoverage_RI_Target100(t *testing.T) {
ResourceType: "t3.medium",
Count: count,
CommitmentType: common.CommitmentReservedInstance,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: avg,
}
}
Expand Down Expand Up @@ -1358,6 +1363,7 @@ func TestApplyTargetCoverage_DropTargetAlreadyMet(t *testing.T) {
ResourceType: "t3.medium",
Count: 5,
CommitmentType: common.CommitmentReservedInstance,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: 8.0,
ExistingCoveragePct: 90.0,
}
Expand All @@ -1384,6 +1390,7 @@ func TestApplyTargetCoverage_DropTargetSizedToZero(t *testing.T) {
ResourceType: "t3.micro",
Count: 1,
CommitmentType: common.CommitmentReservedInstance,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: 0.4,
ExistingCoveragePct: 0.0,
}
Expand Down
1 change: 1 addition & 0 deletions cmd/purchase_safeguards_2121_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func TestApplyTargetCoverage_NonPositiveCountDropped_2121(t *testing.T) {
ResourceType: "db.r6g.large",
CommitmentType: common.CommitmentReservedInstance,
Count: count,
ExistingCoverageKnown: true,
AverageInstancesUsedPerHour: 4,
ExistingCoveragePct: 0,
CommitmentCost: 1000,
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ require (

require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008151559-73d3366903bc
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261006205158-7ff8c1aee1bb
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261009181525-30bf383795db
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb
github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008151559-73d3366903bc h1:GTjjeD1cGMn+LfTSRB7CfO6kJBrfQyHu3kZAtHx1TJk=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261008151559-73d3366903bc/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261006205158-7ff8c1aee1bb h1:gtfWXNBATJDSS+q3+5Jxt2+HYijOSVEnpGkXmeCkudY=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261006205158-7ff8c1aee1bb/go.mod h1:zgv5QhLOIk9Hqw1xcmr1lSFg4ZeChwTCMwxETFVcfj4=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261009181525-30bf383795db h1:d/eTZP31KewqE049NlQM3nZIUygRudHJ25Im2ibJk58=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261009181525-30bf383795db/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab h1:E6cZ5WllhWGR8EgONN8MwWaGD3qLCCwYIXWgIysnfYU=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab/go.mod h1:C0ildZeRds0aRvDZp49MLY0kFWh/4FiM4lPWWWd3HFA=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b h1:yurugmfdZjf+dqjfqfDyfoBR3IAiFUpMKQ8URnZnJzQ=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b/go.mod h1:iIvPuWLWd+SXqLHAQNdodYMUAAcq7PnwfScXWgARt5A=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb h1:AJ4nKDtttjiTDemo9PDp2esoc35ymVaiCSr0zIewwpY=
Expand Down
Loading