diff --git a/server/aws/savingsplans/handler.go b/server/aws/savingsplans/handler.go index f10e76306..a7266c66d 100644 --- a/server/aws/savingsplans/handler.go +++ b/server/aws/savingsplans/handler.go @@ -143,7 +143,7 @@ func (h *Handler) describeSavingsPlans(w http.ResponseWriter, r *http.Request) { SavingsPlanIDs []string `json:"savingsPlanIds"` SavingsPlanArns []string `json:"savingsPlanArns"` States []string `json:"states"` - MaxResults int `json:"maxResults"` + MaxResults *int `json:"maxResults"` NextToken string `json:"nextToken"` } @@ -152,54 +152,99 @@ func (h *Handler) describeSavingsPlans(w http.ResponseWriter, r *http.Request) { } f := newPlanFilter(req.SavingsPlanIDs, req.SavingsPlanArns, req.States, req.Filters) - plans := h.store.describe(f) + + // DescribeSavingsPlans documents maxResults 1-1000, so an explicit 0 is + // invalid; omitted returns every plan. + pr := pageRequest{minResults: 1, nextToken: req.NextToken} + if req.MaxResults != nil { + pr.maxResults, pr.set = *req.MaxResults, true + } + + plans, next, err := paginate(h.store.describe(f), func(p *savingsPlan) string { return p.ID }, pr) + if err != nil { + writeErr(w, err) + + return + } out := make([]map[string]any, 0, len(plans)) for _, p := range plans { out = append(out, planToWire(p)) } - wire.WriteJSON(w, map[string]any{"savingsPlans": out}) + writePage(w, "savingsPlans", out, next) } func (h *Handler) describeOfferings(w http.ResponseWriter, r *http.Request) { var req struct { - OfferingIDs []string `json:"offeringIds"` - PlanTypes []string `json:"planTypes"` - Filters []wireFilter `json:"filters"` - ProductType string `json:"productType"` - MaxResults int `json:"maxResults"` - NextToken string `json:"nextToken"` + OfferingIDs []string `json:"offeringIds"` + PaymentOptions []string `json:"paymentOptions"` + ProductType string `json:"productType"` + PlanTypes []string `json:"planTypes"` + Durations []int64 `json:"durations"` + Currencies []string `json:"currencies"` + Descriptions []string `json:"descriptions"` + ServiceCodes []string `json:"serviceCodes"` + UsageTypes []string `json:"usageTypes"` + Operations []string `json:"operations"` + Filters []wireFilter `json:"filters"` + MaxResults int `json:"maxResults"` + NextToken string `json:"nextToken"` } if !wire.DecodeJSON(w, r, &req) { return } - ids := toSet(req.OfferingIDs) - types := toSet(req.PlanTypes) + f := &offeringFilter{ + ids: toSet(req.OfferingIDs), + planTypes: toSet(req.PlanTypes), + paymentOptions: toSet(req.PaymentOptions), + durations: toSet(int64sToStrings(req.Durations)), + currencies: toSet(req.Currencies), + descriptions: toSet(req.Descriptions), + serviceCodes: toSet(req.ServiceCodes), + usageTypes: toSet(req.UsageTypes), + operations: toSet(req.Operations), + productType: req.ProductType, + attrs: req.Filters, + } - out := make([]map[string]any, 0, len(h.store.offerings)) + matched, err := h.store.describeOfferings(f) + if err != nil { + writeErr(w, err) - for i := range h.store.offerings { - o := &h.store.offerings[i] + return + } - if len(ids) > 0 { - if _, ok := ids[o.id]; !ok { - continue - } - } + // DescribeSavingsPlansOfferings documents maxResults 0-1000 (0 = all). + offerings, next, err := paginate(matched, func(o *offering) string { return o.id }, pageRequest{ + maxResults: req.MaxResults, set: true, nextToken: req.NextToken, + }) + if err != nil { + writeErr(w, err) - if len(types) > 0 { - if _, ok := types[o.planType]; !ok { - continue - } - } + return + } + out := make([]map[string]any, 0, len(offerings)) + for _, o := range offerings { out = append(out, offeringToWire(o)) } - wire.WriteJSON(w, map[string]any{"searchResults": out}) + writePage(w, "searchResults", out, next) +} + +// writePage writes a describe response: the result list under key, plus +// nextToken only when more results remain (AWS documents it as null on the last +// page). +func writePage(w http.ResponseWriter, key string, items []map[string]any, next string) { + resp := map[string]any{key: items} + if next != "" { + resp["nextToken"] = next + } + + wire.WriteJSON(w, resp) } func (h *Handler) describeOfferingRates(w http.ResponseWriter, r *http.Request) { diff --git a/server/aws/savingsplans/offerings.go b/server/aws/savingsplans/offerings.go new file mode 100644 index 000000000..72739677b --- /dev/null +++ b/server/aws/savingsplans/offerings.go @@ -0,0 +1,140 @@ +package savingsplans + +import ( + "strconv" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// Offering filter / property names. SavingsPlanOfferingFilterAttribute and +// SavingsPlanOfferingPropertyKey are the same closed enum: region | +// instanceFamily. The region name is the shared filterRegion constant. +// https://docs.aws.amazon.com/savingsplans/latest/APIReference/API_SavingsPlanOfferingFilterElement.html +const offeringPropInstanceFamily = "instanceFamily" + +// propertyName / propertyValue are the SavingsPlanOfferingProperty member names. +const ( + propertyName = "name" + propertyValue = "value" +) + +// offeringFilter is the parsed DescribeSavingsPlansOfferings predicate. Every +// populated dimension must match (AND across dimensions, OR within a list). +// https://docs.aws.amazon.com/savingsplans/latest/APIReference/API_DescribeSavingsPlansOfferings.html +type offeringFilter struct { + ids map[string]struct{} + planTypes map[string]struct{} + paymentOptions map[string]struct{} + durations map[string]struct{} + currencies map[string]struct{} + descriptions map[string]struct{} + serviceCodes map[string]struct{} + usageTypes map[string]struct{} + operations map[string]struct{} + productType string + attrs []wireFilter +} + +// validate rejects a filters[] name outside the closed +// SavingsPlanOfferingFilterAttribute enum with ValidationException, so a typo +// (e.g. "instancefamily") fails instead of silently matching every offering. +func (f *offeringFilter) validate() error { + for _, attr := range f.attrs { + if attr.Name != filterRegion && attr.Name != offeringPropInstanceFamily { + return cerrors.Newf(cerrors.InvalidArgument, + "invalid filter name %q: must be one of [region, instanceFamily]", attr.Name) + } + } + + return nil +} + +// matches reports whether offering o satisfies the filter. +func (f *offeringFilter) matches(o *offering) bool { + dims := []struct { + set map[string]struct{} + got string + }{ + {f.ids, o.id}, + {f.planTypes, o.planType}, + {f.paymentOptions, o.paymentOption}, + {f.durations, strconv.FormatInt(o.durationSecs, 10)}, + {f.currencies, currencyUSD}, + {f.descriptions, o.description}, + {f.serviceCodes, o.serviceCode}, + {f.usageTypes, o.usageType}, + {f.operations, o.operation}, + } + + for _, d := range dims { + if !inSet(d.set, d.got) { + return false + } + } + + if f.productType != "" && !containsValue(o.productTypes, f.productType) { + return false + } + + for _, attr := range f.attrs { + if !offeringAttrMatches(o, attr) { + return false + } + } + + return true +} + +// offeringAttrMatches applies one filters[] element. The filter names are the +// offering property keys, so an offering matches when it carries that property +// with one of the requested values; an offering without the property (e.g. a +// region-spanning Compute offering under a region filter) does not match. +// Filter names are checked by validate before matching. +func offeringAttrMatches(o *offering, attr wireFilter) bool { + got, ok := offeringProperties(o)[attr.Name] + + return ok && containsValue(attr.Values, got) +} + +// offeringProperties returns the SavingsPlanOfferingProperty set for o: region +// and instanceFamily for a region/family-scoped EC2Instance offering, none for +// region-spanning offerings. +func offeringProperties(o *offering) map[string]string { + props := map[string]string{} + + if o.region != "" { + props[filterRegion] = o.region + } + + if o.ec2Family != "" { + props[offeringPropInstanceFamily] = o.ec2Family + } + + return props +} + +// inSet reports whether v is in set; an empty set means the dimension is +// unfiltered. +func inSet(set map[string]struct{}, v string) bool { + if len(set) == 0 { + return true + } + + _, ok := set[v] + + return ok +} + +// int64sToStrings renders durations so they share the string-set matcher. +func int64sToStrings(in []int64) []string { + if len(in) == 0 { + return nil + } + + out := make([]string, len(in)) + for i, v := range in { + out[i] = strconv.FormatInt(v, 10) + } + + return out +} diff --git a/server/aws/savingsplans/pagination_test.go b/server/aws/savingsplans/pagination_test.go new file mode 100644 index 000000000..0d1040f3f --- /dev/null +++ b/server/aws/savingsplans/pagination_test.go @@ -0,0 +1,77 @@ +package savingsplans + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" +) + +// describePage drives DescribeSavingsPlans through the handler and returns the +// page's plan ids and nextToken. +func describePage(t *testing.T, h *Handler, body string) (ids []string, next string) { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/DescribeSavingsPlans", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("DescribeSavingsPlans %s: status %d: %s", body, rec.Code, rec.Body.String()) + } + + var out struct { + SavingsPlans []struct { + ID string `json:"savingsPlanId"` + } `json:"savingsPlans"` + NextToken string `json:"nextToken"` + } + + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + + for _, p := range out.SavingsPlans { + ids = append(ids, p.ID) + } + + return ids, out.NextToken +} + +func putPlan(h *Handler, id string, now time.Time) { + h.store.plans.Set(id, &savingsPlan{ + ID: id, ARN: h.store.planARN(id), OfferingID: storeTestOffering, Commitment: "1", + Currency: currencyUSD, Region: storeTestRegion, TermSeconds: termOneYear, + Start: now, End: now.Add(time.Hour), State: stateActive, + }) +} + +// TestDescribeSavingsPlansPagesStayStableAcrossInserts is the regression test +// for offset tokens over the id-sorted plan list: a plan created between page +// calls that sorts before the cursor must not shift the next page (repeating an +// already-returned plan). +func TestDescribeSavingsPlansPagesStayStableAcrossInserts(t *testing.T) { + clock := config.NewFakeClock(time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)) + h := New(storeTestAccount, storeTestRegion, clock) + + for _, id := range []string{"b", "d", "f"} { + putPlan(h, id, clock.Now()) + } + + first, next := describePage(t, h, `{"maxResults":2}`) + if strings.Join(first, ",") != "b,d" || next == "" { + t.Fatalf("page1 = %v next=%q", first, next) + } + + // A plan that sorts before the cursor arrives between the two calls. + putPlan(h, "a", clock.Now()) + + second, last := describePage(t, h, `{"maxResults":2,"nextToken":"`+next+`"}`) + if strings.Join(second, ",") != "f" || last != "" { + t.Fatalf("page2 = %v next=%q, want [f] and no token", second, last) + } +} diff --git a/server/aws/savingsplans/parity_sdk_test.go b/server/aws/savingsplans/parity_sdk_test.go new file mode 100644 index 000000000..0c521f3e2 --- /dev/null +++ b/server/aws/savingsplans/parity_sdk_test.go @@ -0,0 +1,195 @@ +package savingsplans_test + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/savingsplans" + sptypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" + "github.com/aws/smithy-go" +) + +func wantValidation(t *testing.T, what string, err error) { + t.Helper() + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != "ValidationException" { + t.Fatalf("%s: err = %v, want ValidationException", what, err) + } +} + +// TestCreateSavingsPlanRejectsInvalidCommitment: commitment must be within +// [0.001, 1000000] with at most five decimals (CreateSavingsPlan API reference). +func TestCreateSavingsPlanRejectsInvalidCommitment(t *testing.T) { + c, _ := newClient(t) + + for _, commitment := range []string{"0", "-1", "1.123456", "1000001"} { + _, err := c.CreateSavingsPlan(context.Background(), &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String(commitment), + }) + wantValidation(t, "commitment "+commitment, err) + } +} + +func TestTagResourceRejectsEmptyKey(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + out, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String("1"), + }) + if err != nil { + t.Fatalf("CreateSavingsPlan: %v", err) + } + + desc, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{ + SavingsPlanIds: []string{aws.ToString(out.SavingsPlanId)}, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlans: %v", err) + } + + _, err = c.TagResource(ctx, &savingsplans.TagResourceInput{ + ResourceArn: desc.SavingsPlans[0].SavingsPlanArn, + Tags: map[string]string{"": "v"}, + }) + wantValidation(t, "empty tag key", err) +} + +func TestDescribeOfferingsAppliesProductTypeAndFilters(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + byProduct, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{ + ProductType: sptypes.SavingsPlanProductTypeSagemaker, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings(productType): %v", err) + } + + if len(byProduct.SearchResults) != 1 || + aws.ToString(byProduct.SearchResults[0].OfferingId) != "sp-offering-sagemaker-1yr-no" { + t.Fatalf("productType=SageMaker returned %+v", byProduct.SearchResults) + } + + byFamily, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{ + Filters: []sptypes.SavingsPlanOfferingFilterElement{ + {Name: sptypes.SavingsPlanOfferingFilterAttributeInstanceFamily, Values: []string{"m5"}}, + {Name: sptypes.SavingsPlanOfferingFilterAttributeRegion, Values: []string{"us-east-1"}}, + }, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings(filters): %v", err) + } + + if len(byFamily.SearchResults) != 1 { + t.Fatalf("instanceFamily=m5,region=us-east-1 returned %d offerings", len(byFamily.SearchResults)) + } + + props := map[string]string{} + for _, p := range byFamily.SearchResults[0].Properties { + props[string(p.Name)] = aws.ToString(p.Value) + } + + if props["instanceFamily"] != "m5" || props["region"] != "us-east-1" { + t.Fatalf("offering properties = %v", props) + } + + none, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{ + Filters: []sptypes.SavingsPlanOfferingFilterElement{ + {Name: sptypes.SavingsPlanOfferingFilterAttributeInstanceFamily, Values: []string{"c5"}}, + }, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings(c5): %v", err) + } + + if len(none.SearchResults) != 0 { + t.Fatalf("instanceFamily=c5 should match nothing, got %d", len(none.SearchResults)) + } +} + +func TestDescribeSavingsPlansPagination(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + for range 3 { + if _, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String("1"), + }); err != nil { + t.Fatalf("CreateSavingsPlan: %v", err) + } + } + + seen := map[string]bool{} + pages := 0 + + var token *string + + for { + out, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{ + MaxResults: aws.Int32(2), + NextToken: token, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlans page %d: %v", pages, err) + } + + pages++ + + if len(out.SavingsPlans) > 2 { + t.Fatalf("page %d has %d plans, want <= 2", pages, len(out.SavingsPlans)) + } + + for _, p := range out.SavingsPlans { + seen[aws.ToString(p.SavingsPlanId)] = true + } + + if out.NextToken == nil { + break + } + + token = out.NextToken + } + + if pages != 2 || len(seen) != 3 { + t.Fatalf("paginated %d pages / %d distinct plans, want 2 / 3", pages, len(seen)) + } + + _, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{NextToken: aws.String("garbage")}) + wantValidation(t, "bad nextToken", err) + + _, err = c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{MaxResults: aws.Int32(1001)}) + wantValidation(t, "maxResults 1001", err) +} + +func TestDescribeOfferingsPagination(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + first, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{MaxResults: 3}) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings: %v", err) + } + + if len(first.SearchResults) != 3 || first.NextToken == nil { + t.Fatalf("offerings page1 = %d results, nextToken=%v", len(first.SearchResults), first.NextToken) + } + + second, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{ + MaxResults: 3, + NextToken: first.NextToken, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings page2: %v", err) + } + + if len(second.SearchResults) != 1 || second.NextToken != nil { + t.Fatalf("offerings page2 = %d results, nextToken=%v", len(second.SearchResults), second.NextToken) + } +} diff --git a/server/aws/savingsplans/review_sdk_test.go b/server/aws/savingsplans/review_sdk_test.go new file mode 100644 index 000000000..6fa77f559 --- /dev/null +++ b/server/aws/savingsplans/review_sdk_test.go @@ -0,0 +1,118 @@ +package savingsplans_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/savingsplans" + sptypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" +) + +// TestCreateSavingsPlanRejectsNonDecimalCommitment: commitment is a plain +// decimal amount; exponent, hex-float and signed forms that strconv.ParseFloat +// would accept are ValidationException, and a plain decimal is stored verbatim. +func TestCreateSavingsPlanRejectsNonDecimalCommitment(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + for _, commitment := range []string{"1e2", "0x1p3", "+5", "1.23456e2", " 1", "1."} { + _, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String(commitment), + }) + wantValidation(t, "commitment "+commitment, err) + } + + out, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String("123.456"), + }) + if err != nil { + t.Fatalf("CreateSavingsPlan(123.456): %v", err) + } + + desc, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{ + SavingsPlanIds: []string{aws.ToString(out.SavingsPlanId)}, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlans: %v", err) + } + + if got := aws.ToString(desc.SavingsPlans[0].Commitment); got != "123.456" { + t.Fatalf("stored commitment = %q, want 123.456", got) + } +} + +// TestDescribeSavingsPlansRejectsZeroMaxResults: DescribeSavingsPlans documents +// maxResults 1-1000, while DescribeSavingsPlansOfferings documents 0-1000. +func TestDescribeSavingsPlansRejectsZeroMaxResults(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + _, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{MaxResults: aws.Int32(0)}) + wantValidation(t, "DescribeSavingsPlans maxResults 0", err) + + if _, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{}); err != nil { + t.Fatalf("DescribeSavingsPlans without maxResults: %v", err) + } + + out, err := c.DescribeSavingsPlansOfferings(ctx, &savingsplans.DescribeSavingsPlansOfferingsInput{MaxResults: 0}) + if err != nil { + t.Fatalf("DescribeSavingsPlansOfferings maxResults 0: %v", err) + } + + if len(out.SearchResults) == 0 || out.NextToken != nil { + t.Fatalf("offerings maxResults 0 = %d results, nextToken=%v", len(out.SearchResults), out.NextToken) + } +} + +// TestDescribeOfferingsRejectsUnknownFilterName: filters[].name is the closed +// SavingsPlanOfferingFilterAttribute enum (region | instanceFamily). +func TestDescribeOfferingsRejectsUnknownFilterName(t *testing.T) { + c, _ := newClient(t) + + _, err := c.DescribeSavingsPlansOfferings(context.Background(), &savingsplans.DescribeSavingsPlansOfferingsInput{ + Filters: []sptypes.SavingsPlanOfferingFilterElement{ + {Name: "instancefamily", Values: []string{"m5"}}, + }, + }) + wantValidation(t, "unknown offering filter name", err) +} + +// TestTagsRejectReservedAWSPrefix: the aws: tag key prefix is reserved for AWS +// (case-insensitive) on both TagResource and CreateSavingsPlan. +func TestTagsRejectReservedAWSPrefix(t *testing.T) { + c, _ := newClient(t) + ctx := context.Background() + + _, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String("1"), + Tags: map[string]string{"aws:team": "x"}, + }) + wantValidation(t, "create with aws: tag", err) + + out, err := c.CreateSavingsPlan(ctx, &savingsplans.CreateSavingsPlanInput{ + SavingsPlanOfferingId: aws.String(computeOfferingID), + Commitment: aws.String("1"), + }) + if err != nil { + t.Fatalf("CreateSavingsPlan: %v", err) + } + + desc, err := c.DescribeSavingsPlans(ctx, &savingsplans.DescribeSavingsPlansInput{ + SavingsPlanIds: []string{aws.ToString(out.SavingsPlanId)}, + }) + if err != nil { + t.Fatalf("DescribeSavingsPlans: %v", err) + } + + for _, key := range []string{"aws:x", "AWS:x", "Aws:Cost"} { + _, err = c.TagResource(ctx, &savingsplans.TagResourceInput{ + ResourceArn: desc.SavingsPlans[0].SavingsPlanArn, + Tags: map[string]string{key: "v"}, + }) + wantValidation(t, "tag key "+key, err) + } +} diff --git a/server/aws/savingsplans/store.go b/server/aws/savingsplans/store.go index 18011f23a..413de7e63 100644 --- a/server/aws/savingsplans/store.go +++ b/server/aws/savingsplans/store.go @@ -87,6 +87,9 @@ type offering struct { usageType string operation string ec2Family string + // region is the region an EC2Instance offering is scoped to. Compute and + // SageMaker offerings span regions and carry no region property. + region string } // store is the in-memory backing state for the Savings Plans wire handler. The @@ -116,15 +119,17 @@ func newStore(accountID, region string, clock config.Clock) *store { accountID: accountID, region: region, plans: memstore.New[*savingsPlan](), - offerings: seedOfferings(), + offerings: seedOfferings(region), byToken: map[string]string{}, } } // seedOfferings returns a representative Savings Plans offering catalog spanning // the Compute / EC2Instance / SageMaker plan types, both term lengths and all -// three payment options. -func seedOfferings() []offering { +// three payment options. The EC2Instance offering is scoped to region (an EC2 +// Instance Savings Plan is bought per instance family per region), which is what +// its region / instanceFamily offering properties report. +func seedOfferings(region string) []offering { return []offering{ { id: "sp-offering-compute-1yr-no", description: "Compute Savings Plan, 1 year, No Upfront", @@ -142,7 +147,7 @@ func seedOfferings() []offering { id: "sp-offering-ec2-1yr-partial", description: "EC2 Instance Savings Plan, 1 year, Partial Upfront", durationSecs: termOneYear, paymentOption: paymentPartialUpfront, planType: planTypeEC2Instance, productTypes: []string{"EC2"}, serviceCode: "AmazonEC2", usageType: "EC2SP:1yrPartialUpfront", - operation: "RunInstances", ec2Family: "m5", + operation: "RunInstances", ec2Family: "m5", region: region, }, { id: "sp-offering-sagemaker-1yr-no", description: "SageMaker Savings Plan, 1 year, No Upfront", @@ -177,12 +182,12 @@ func (s *store) create(in *createInput) (string, error) { return "", cerrors.Newf(cerrors.NotFound, "offering not found: %s", in.savingsPlanOfferingID) } - if in.commitment == "" { - return "", cerrors.New(cerrors.InvalidArgument, "commitment is required") + if err := validateCommitment(in.commitment); err != nil { + return "", err } - if _, err := strconv.ParseFloat(in.commitment, 64); err != nil { - return "", cerrors.Newf(cerrors.InvalidArgument, "invalid commitment: %s", in.commitment) + if err := validateTags(in.tags); err != nil { + return "", err } s.mu.Lock() @@ -314,6 +319,27 @@ func (s *store) describe(f planFilter) []*savingsPlan { return out } +// describeOfferings returns the seeded catalog offerings matching the filter, in +// ascending offering-id order (the key the nextToken cursor pages on). A filter +// name outside the documented enum is a ValidationException. +func (s *store) describeOfferings(f *offeringFilter) ([]*offering, error) { + if err := f.validate(); err != nil { + return nil, err + } + + out := make([]*offering, 0, len(s.offerings)) + + for i := range s.offerings { + if f.matches(&s.offerings[i]) { + out = append(out, &s.offerings[i]) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) + + return out, nil +} + // tagsOf returns a copy of a plan's tags, or nil if the plan is unknown. func (s *store) tagsOf(arn string) (map[string]string, bool) { p, ok := s.byARN(arn) @@ -326,6 +352,10 @@ func (s *store) tagsOf(arn string) (map[string]string, bool) { // tag merges tags onto the plan named by arn. func (s *store) tag(arn string, tags map[string]string) error { + if err := validateTags(tags); err != nil { + return err + } + s.mu.Lock() defer s.mu.Unlock() @@ -392,9 +422,10 @@ func (s *store) planARN(id string) string { // out at its End. A later Cost Explorer coverage/utilization handler // (billing-parity step 3) reads real purchased commitments through this. func (s *store) ListActive(_ context.Context, at time.Time) ([]cost.Commitment, error) { - var out []cost.Commitment + all := s.plans.All() + out := make([]cost.Commitment, 0, len(all)) - for _, p := range s.plans.All() { + for _, p := range all { if effectiveState(p, at) != stateActive { continue } diff --git a/server/aws/savingsplans/types.go b/server/aws/savingsplans/types.go index 4046b65e7..dc2f3a6d5 100644 --- a/server/aws/savingsplans/types.go +++ b/server/aws/savingsplans/types.go @@ -179,7 +179,7 @@ func planToWire(p *savingsPlan) map[string]any { // offeringToWire renders a catalog offering in the wire (SavingsPlanOffering) // JSON shape. func offeringToWire(o *offering) map[string]any { - return map[string]any{ + out := map[string]any{ "offeringId": o.id, "description": o.description, "durationSeconds": o.durationSecs, @@ -191,4 +191,21 @@ func offeringToWire(o *offering) map[string]any { "usageType": o.usageType, "operation": o.operation, } + + // properties carries the offering's region / instanceFamily scope (the same + // keys the offering filters[] match on); region-spanning offerings have none. + props := offeringProperties(o) + if len(props) > 0 { + list := make([]map[string]any, 0, len(props)) + + for _, name := range []string{filterRegion, offeringPropInstanceFamily} { + if v, ok := props[name]; ok { + list = append(list, map[string]any{propertyName: name, propertyValue: v}) + } + } + + out["properties"] = list + } + + return out } diff --git a/server/aws/savingsplans/validation.go b/server/aws/savingsplans/validation.go new file mode 100644 index 000000000..b76c150c1 --- /dev/null +++ b/server/aws/savingsplans/validation.go @@ -0,0 +1,143 @@ +package savingsplans + +import ( + "encoding/base64" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// CreateSavingsPlan commitment bounds. The API reference documents commitment as +// "a value between 0.001 and 1 million. You cannot specify more than five digits +// after the decimal point." +// https://docs.aws.amazon.com/savingsplans/latest/APIReference/API_CreateSavingsPlan.html +const ( + minCommitment = 0.001 + maxCommitment = 1_000_000 + maxTagKeyLength = 128 + maxTagValueLength = 256 + maxDescribePageResults = 1000 + reservedTagPrefix = "aws:" +) + +// commitmentPattern is a plain decimal amount with at most five digits after +// the point. It is matched before parsing so the exponent, hex-float and signed +// forms strconv.ParseFloat also accepts ("1e2", "0x1p3", "+5") are rejected +// rather than stored verbatim. +var commitmentPattern = regexp.MustCompile(`^\d+(\.\d{1,5})?$`) + +// validateCommitment enforces the documented CreateSavingsPlan commitment +// constraints: present, a plain decimal with at most five fractional digits, +// and within [0.001, 1000000]. Violations are ValidationException. +func validateCommitment(commitment string) error { + if commitment == "" { + return cerrors.New(cerrors.InvalidArgument, "commitment is required") + } + + if !commitmentPattern.MatchString(commitment) { + return cerrors.Newf(cerrors.InvalidArgument, + "commitment must be a decimal amount with at most 5 digits after the decimal point, got %s", commitment) + } + + v, err := strconv.ParseFloat(commitment, 64) + if err != nil || v < minCommitment || v > maxCommitment { + return cerrors.Newf(cerrors.InvalidArgument, + "commitment must be between 0.001 and 1000000, got %s", commitment) + } + + return nil +} + +// validateTags enforces the AWS-wide tag naming requirements: a tag key is 1-128 +// Unicode characters and may not use the reserved aws: prefix (in any case), +// and a value is 0-256 characters. +// https://docs.aws.amazon.com/tag-editor/latest/userguide/best-practices-and-strats.html#tag-conventions +func validateTags(tags map[string]string) error { + for k, v := range tags { + if k == "" { + return cerrors.New(cerrors.InvalidArgument, "tag key must not be empty") + } + + if utf8.RuneCountInString(k) > maxTagKeyLength { + return cerrors.Newf(cerrors.InvalidArgument, + "tag key %q exceeds the maximum length of %d characters", k, maxTagKeyLength) + } + + if strings.HasPrefix(strings.ToLower(k), reservedTagPrefix) { + return cerrors.Newf(cerrors.InvalidArgument, + "tag key %q uses the reserved prefix %q", k, reservedTagPrefix) + } + + if utf8.RuneCountInString(v) > maxTagValueLength { + return cerrors.Newf(cerrors.InvalidArgument, + "tag value for key %q exceeds the maximum length of %d characters", k, maxTagValueLength) + } + } + + return nil +} + +// cursorPrefix marks a nextToken as one this handler issued. +const cursorPrefix = "after:" + +// pageRequest is a describe call's paging input: the requested page size, the +// operation's documented minimum (1 for DescribeSavingsPlans, 0 for +// DescribeSavingsPlansOfferings), and whether maxResults was sent at all. +type pageRequest struct { + maxResults int + minResults int + set bool + nextToken string +} + +// paginate returns one page of items, which must be sorted ascending by key, +// plus the token for the following page ("" on the last page). The token +// encodes the last returned key rather than an index, so items created or +// removed between calls cannot shift the next page (no skips or repeats). +// An omitted maxResults, or 0 where the operation allows it, returns every +// remaining item. A maxResults outside [minResults, 1000] or a token this +// handler did not issue is a ValidationException. +func paginate[T any](items []T, key func(T) string, pr pageRequest) (page []T, next string, err error) { + if pr.set && (pr.maxResults < pr.minResults || pr.maxResults > maxDescribePageResults) { + return nil, "", cerrors.Newf(cerrors.InvalidArgument, + "maxResults must be between %d and %d, got %d", pr.minResults, maxDescribePageResults, pr.maxResults) + } + + start := 0 + + if pr.nextToken != "" { + after, ok := decodeCursor(pr.nextToken) + if !ok { + return nil, "", cerrors.Newf(cerrors.InvalidArgument, "invalid nextToken: %s", pr.nextToken) + } + + start = sort.Search(len(items), func(i int) bool { return key(items[i]) > after }) + } + + end := len(items) + if pr.maxResults > 0 && start+pr.maxResults < end { + end = start + pr.maxResults + next = encodeCursor(key(items[end-1])) + } + + return items[start:end], next, nil +} + +// encodeCursor renders a key as an opaque nextToken. Standard base64 fits the +// documented PaginationToken pattern ^[A-Za-z0-9/=\+]+$. +func encodeCursor(key string) string { + return base64.StdEncoding.EncodeToString([]byte(cursorPrefix + key)) +} + +func decodeCursor(token string) (string, bool) { + raw, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return "", false + } + + return strings.CutPrefix(string(raw), cursorPrefix) +} diff --git a/server/aws/savingsplans/validation_test.go b/server/aws/savingsplans/validation_test.go new file mode 100644 index 000000000..7ad045822 --- /dev/null +++ b/server/aws/savingsplans/validation_test.go @@ -0,0 +1,183 @@ +package savingsplans + +import ( + "strings" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +func TestCreateRejectsOutOfRangeCommitment(t *testing.T) { + s, _ := newTestStore(t) + + bad := []string{ + "0", "0.000", "-1", "-0.5", "0.0009", "1000000.01", "NaN", "Inf", "1.123456", "abc", "", + "1e2", "0x1p3", "+5", "1.23456e2", " 1", "1.", ".5", + } + for _, c := range bad { + t.Run(c, func(t *testing.T) { + _, err := s.create(&createInput{savingsPlanOfferingID: storeTestOffering, commitment: c}) + if !cerrors.IsInvalidArgument(err) { + t.Fatalf("commitment %q: err = %v, want InvalidArgument", c, err) + } + }) + } + + good := []string{"0.001", "1", "1.500", "0.12345", "1000000", "123.456"} + for _, c := range good { + t.Run("ok_"+c, func(t *testing.T) { + if _, err := s.create(&createInput{savingsPlanOfferingID: storeTestOffering, commitment: c}); err != nil { + t.Fatalf("commitment %q: unexpected error %v", c, err) + } + }) + } +} + +func TestTagsRejectInvalidKeys(t *testing.T) { + s, _ := newTestStore(t) + + _, err := s.create(&createInput{ + savingsPlanOfferingID: storeTestOffering, + commitment: "1", + tags: map[string]string{"": "v"}, + }) + if !cerrors.IsInvalidArgument(err) { + t.Fatalf("create with empty tag key: err = %v, want InvalidArgument", err) + } + + if _, err := s.create(&createInput{savingsPlanOfferingID: storeTestOffering, commitment: "1"}); err != nil { + t.Fatalf("create: %v", err) + } + + arn := s.describe(planFilter{})[0].ARN + + cases := map[string]map[string]string{ + "empty key": {"": "v"}, + "long key": {strings.Repeat("k", maxTagKeyLength+1): "v"}, + "long value": {"k": strings.Repeat("v", maxTagValueLength+1)}, + "aws: key": {"aws:team": "v"}, + "AWS: key": {"AWS:team": "v"}, + "Aws: key": {"Aws:Team": "v"}, + } + for name, tags := range cases { + if err := s.tag(arn, tags); !cerrors.IsInvalidArgument(err) { + t.Fatalf("%s: err = %v, want InvalidArgument", name, err) + } + } + + if tags, _ := s.tagsOf(arn); len(tags) != 0 { + t.Fatalf("rejected tags must not be stored: %v", tags) + } + + if err := s.tag(arn, map[string]string{"k": ""}); err != nil { + t.Fatalf("empty tag value is valid: %v", err) + } +} + +func TestPaginate(t *testing.T) { + items := []string{"a", "b", "c", "d", "e"} + key := func(s string) string { return s } + pr := func(n int, token string) pageRequest { + return pageRequest{maxResults: n, minResults: 1, set: true, nextToken: token} + } + + page, next, err := paginate(items, key, pr(2, "")) + if err != nil || strings.Join(page, "") != "ab" || next == "" { + t.Fatalf("page1 = %v next=%q err=%v", page, next, err) + } + + page, next, err = paginate(items, key, pr(2, next)) + if err != nil || strings.Join(page, "") != "cd" || next == "" { + t.Fatalf("page2 = %v next=%q err=%v", page, next, err) + } + + page, next, err = paginate(items, key, pr(2, next)) + if err != nil || strings.Join(page, "") != "e" || next != "" { + t.Fatalf("page3 = %v next=%q err=%v", page, next, err) + } + + // Omitted maxResults returns everything. + if page, next, err = paginate(items, key, pageRequest{minResults: 1}); err != nil || len(page) != 5 || next != "" { + t.Fatalf("omitted = %v next=%q err=%v", page, next, err) + } + + // 0 is valid where the operation's minimum is 0 (offerings) and means all. + if page, _, err = paginate(items, key, pageRequest{set: true}); err != nil || len(page) != 5 { + t.Fatalf("maxResults 0 with min 0 = %v err=%v", page, err) + } + + for _, n := range []int{-1, 0, maxDescribePageResults + 1} { + if _, _, err := paginate(items, key, pr(n, "")); !cerrors.IsInvalidArgument(err) { + t.Fatalf("maxResults %d (min 1): err = %v, want InvalidArgument", n, err) + } + } + + // "Mg==" is valid base64 ("2") but not a token this handler issued. + for _, tok := range []string{"!!not-a-token", "Mg=="} { + if _, _, err := paginate(items, key, pr(1, tok)); !cerrors.IsInvalidArgument(err) { + t.Fatalf("bad token %q: err = %v, want InvalidArgument", tok, err) + } + } +} + +func TestDescribeOfferingsRejectsUnknownFilterNameStore(t *testing.T) { + s, _ := newTestStore(t) + + for _, name := range []string{"instancefamily", "Region", "bogus", ""} { + _, err := s.describeOfferings(&offeringFilter{attrs: []wireFilter{{Name: name, Values: []string{"x"}}}}) + if !cerrors.IsInvalidArgument(err) { + t.Fatalf("filter name %q: err = %v, want InvalidArgument", name, err) + } + } +} + +func TestDescribeOfferingsFilters(t *testing.T) { + s, _ := newTestStore(t) + + ids := func(f *offeringFilter) []string { + matched, err := s.describeOfferings(f) + if err != nil { + t.Fatalf("describeOfferings: %v", err) + } + + var out []string + for _, o := range matched { + out = append(out, o.id) + } + + return out + } + + cases := []struct { + name string + f *offeringFilter + want []string + }{ + {"productType SageMaker", &offeringFilter{productType: "SageMaker"}, []string{"sp-offering-sagemaker-1yr-no"}}, + {"productType Fargate", &offeringFilter{productType: "Fargate"}, + []string{"sp-offering-compute-1yr-no", "sp-offering-compute-3yr-all"}}, + {"productType RDS", &offeringFilter{productType: "RDS"}, nil}, + {"instanceFamily m5", &offeringFilter{attrs: []wireFilter{{Name: "instanceFamily", Values: []string{"m5"}}}}, + []string{"sp-offering-ec2-1yr-partial"}}, + {"instanceFamily c5", &offeringFilter{attrs: []wireFilter{{Name: "instanceFamily", Values: []string{"c5"}}}}, nil}, + {"region match", &offeringFilter{attrs: []wireFilter{{Name: "region", Values: []string{storeTestRegion}}}}, + []string{"sp-offering-ec2-1yr-partial"}}, + {"region miss", &offeringFilter{attrs: []wireFilter{{Name: "region", Values: []string{"eu-west-1"}}}}, nil}, + {"paymentOptions", &offeringFilter{paymentOptions: toSet([]string{paymentAllUpfront})}, + []string{"sp-offering-compute-3yr-all"}}, + {"durations", &offeringFilter{durations: toSet(int64sToStrings([]int64{termThreeYear}))}, + []string{"sp-offering-compute-3yr-all"}}, + {"currencies EUR", &offeringFilter{currencies: toSet([]string{"EUR"})}, nil}, + {"serviceCodes", &offeringFilter{serviceCodes: toSet([]string{"AmazonSageMaker"})}, + []string{"sp-offering-sagemaker-1yr-no"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ids(tc.f) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +}