From d59fd706ffdc45d8c5a342927a6a7f66b9bdeed0 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 27 Sep 2026 18:36:46 +0530 Subject: [PATCH] feat(cloudformation): exports, deletion policies and DELETE_FAILED (CFN-5) --- compat/aws/cloudformation_compat_test.go | 95 ++++ docs/compat/README.md | 7 +- docs/compat/compat.json | 65 +++ docs/coverage/README.md | 2 +- docs/coverage/aws/README.md | 2 +- docs/coverage/aws/cloudformation.md | 7 +- docs/coverage/coverage.json | 15 + providers/aws/cloudformation/changeset.go | 17 +- .../aws/cloudformation/changeset_test.go | 4 +- .../aws/cloudformation/cloudformation.go | 52 +- .../aws/cloudformation/cloudformation_test.go | 2 +- providers/aws/cloudformation/converge.go | 140 +++++- providers/aws/cloudformation/deletion.go | 189 ++++++++ providers/aws/cloudformation/deletion_test.go | 445 ++++++++++++++++++ providers/aws/cloudformation/exports.go | 256 ++++++++++ providers/aws/cloudformation/exports_test.go | 255 ++++++++++ providers/aws/cloudformation/orchestrate.go | 55 ++- providers/aws/cloudformation/snapshot.go | 7 + providers/aws/cloudformation/stack_id_test.go | 2 +- providers/aws/cloudformation/update.go | 64 ++- server/aws/cloudformation/changeset.go | 7 +- server/aws/cloudformation/exports.go | 182 +++++++ server/aws/cloudformation/exports_sdk_test.go | 243 ++++++++++ server/aws/cloudformation/handler.go | 14 + server/aws/cloudformation/operations.go | 8 +- server/aws/cloudformation/types.go | 23 +- services/cloudformation/changeplan.go | 17 +- services/cloudformation/changeset.go | 12 +- services/cloudformation/intrinsics.go | 6 + services/cloudformation/policies.go | 202 ++++++++ services/cloudformation/policies_test.go | 184 ++++++++ services/cloudformation/sections.go | 25 +- services/cloudformation/template.go | 5 + services/cloudformation/types.go | 92 +++- services/cloudformation/validate.go | 8 + 35 files changed, 2635 insertions(+), 74 deletions(-) create mode 100644 providers/aws/cloudformation/deletion.go create mode 100644 providers/aws/cloudformation/deletion_test.go create mode 100644 providers/aws/cloudformation/exports.go create mode 100644 providers/aws/cloudformation/exports_test.go create mode 100644 server/aws/cloudformation/exports.go create mode 100644 server/aws/cloudformation/exports_sdk_test.go create mode 100644 services/cloudformation/policies.go create mode 100644 services/cloudformation/policies_test.go diff --git a/compat/aws/cloudformation_compat_test.go b/compat/aws/cloudformation_compat_test.go index 35531af60..8d66f4024 100644 --- a/compat/aws/cloudformation_compat_test.go +++ b/compat/aws/cloudformation_compat_test.go @@ -117,6 +117,7 @@ func TestAWSCloudFormationCompat(t *testing.T) { }) changeSetOps(ctx, sess, client) + exportOps(ctx, sess, client) sess.Op(svc, "DeleteStack", func() error { _, err := client.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String(stack)}) @@ -206,6 +207,100 @@ func changeSetOps(ctx context.Context, sess *compat.AWSSession, client *awscfn.C }) } +// exportOps exports a bucket name from one stack, imports it into another, +// then protects the importer and checks the account operations. +func exportOps(ctx context.Context, sess *compat.AWSSession, client *awscfn.Client) { + const svc = "cloudformation" + + sess.Op(svc, "ListExports", func() error { + if _, err := client.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("compat-exporter"), TemplateBody: aws.String(exporterTemplate), + }); err != nil { + return err + } + + out, err := client.ListExports(ctx, &awscfn.ListExportsInput{}) + if err != nil { + return err + } + + if len(out.Exports) != 1 || aws.ToString(out.Exports[0].Value) != "compat-exported" { + return errCompat("expected 1 export") + } + + return nil + }) + + sess.Op(svc, "ListImports", func() error { + if _, err := client.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("compat-importer"), TemplateBody: aws.String(importerTemplate), + }); err != nil { + return err + } + + out, err := client.ListImports(ctx, &awscfn.ListImportsInput{ExportName: aws.String("compat-export")}) + if err != nil { + return err + } + + if len(out.Imports) != 1 || out.Imports[0] != "compat-importer" { + return errCompat("expected 1 import") + } + + return nil + }) + + sess.Op(svc, "UpdateTerminationProtection", func() error { + _, err := client.UpdateTerminationProtection(ctx, &awscfn.UpdateTerminationProtectionInput{ + StackName: aws.String("compat-importer"), EnableTerminationProtection: aws.Bool(true), + }) + if err != nil { + return err + } + + if _, err = client.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("compat-importer")}); err == nil { + return errCompat("protected stack deleted") + } + + return nil + }) + + sess.Op(svc, "DescribeAccountLimits", func() error { + out, err := client.DescribeAccountLimits(ctx, &awscfn.DescribeAccountLimitsInput{}) + if err != nil { + return err + } + + if len(out.AccountLimits) != 3 { + return errCompat("expected 3 limits") + } + + return nil + }) + + sess.Op(svc, "EstimateTemplateCost", func() error { + out, err := client.EstimateTemplateCost(ctx, &awscfn.EstimateTemplateCostInput{TemplateBody: aws.String(exporterTemplate)}) + if err != nil { + return err + } + + if aws.ToString(out.Url) == "" { + return errCompat("empty url") + } + + return nil + }) +} + +const exporterTemplate = `{ + "Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"compat-exported"}}}, + "Outputs":{"Name":{"Value":{"Ref":"Bucket"},"Export":{"Name":"compat-export"}}} +}` + +const importerTemplate = `{"Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{ + "BucketName":{"Fn::Join":["-",[{"Fn::ImportValue":"compat-export"},"copy"]]} +}}}}` + const changeSetTemplate = `{"Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"compat-cs-bucket"}}}}` // continueUpdateRollback drives a stack into UPDATE_ROLLBACK_FAILED, where diff --git a/docs/compat/README.md b/docs/compat/README.md index 48d7bbcd3..900333117 100644 --- a/docs/compat/README.md +++ b/docs/compat/README.md @@ -42,21 +42,26 @@ Each service's summary breaks the verified counts out per SDK/language. Only lan | CreateStack | ✅ | | DeleteChangeSet | ✅ | | DeleteStack | ✅ | +| DescribeAccountLimits | ✅ | | DescribeChangeSet | ✅ | | DescribeStackEvents | ✅ | | DescribeStackResources | ✅ | | DescribeStacks | ✅ | +| EstimateTemplateCost | ✅ | | ExecuteChangeSet | ✅ | | GetTemplate | ✅ | | GetTemplateSummary | ✅ | | ListChangeSets | ✅ | +| ListExports | ✅ | +| ListImports | ✅ | | ListStackResources | ✅ | | ListStacks | ✅ | | UpdateStack | ✅ | +| UpdateTerminationProtection | ✅ | | ValidateTemplate | ✅ | **cloudformation verified per language:** -- Go: AWS 17/17 +- Go: AWS 22/22 ## compute diff --git a/docs/compat/compat.json b/docs/compat/compat.json index 716f5f55f..17dbaec85 100644 --- a/docs/compat/compat.json +++ b/docs/compat/compat.json @@ -381,6 +381,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "DescribeAccountLimits", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "DescribeChangeSet", @@ -433,6 +446,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "EstimateTemplateCost", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "ExecuteChangeSet", @@ -485,6 +511,32 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "ListExports", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, + { + "service": "cloudformation", + "operation": "ListImports", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "ListStackResources", @@ -524,6 +576,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "UpdateTerminationProtection", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "ValidateTemplate", diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 379b02027..49405fef2 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -45,7 +45,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `cloudasset` | - | - | [Cloudasset](./gcp/cloudasset.md) | - | 11 | | `cloudbilling` | - | - | [Cloudbilling](./gcp/cloudbilling.md) | - | 14 | | `clouddeploy` | - | - | [CloudDeploy](./gcp/clouddeploy.md) | - | 11 | -| `cloudformation` | [CloudFormation](./aws/cloudformation.md) | - | - | - | 17 | +| `cloudformation` | [CloudFormation](./aws/cloudformation.md) | - | - | - | 22 | | `cloudfront` | [CloudFront](./aws/cloudfront.md) | - | - | - | 11 | | `cloudids` | - | - | [CloudIDS](./gcp/cloudids.md) | - | 6 | | `cloudrun` | - | - | [CloudRun](./gcp/cloudrun.md) | - | 18 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index 5227fdf33..4b943ca38 100644 --- a/docs/coverage/aws/README.md +++ b/docs/coverage/aws/README.md @@ -19,7 +19,7 @@ Services cloudemu emulates for AWS, by native name. Back to the [cross-provider | [Bedrock](./bedrock.md) | `bedrock` | 65 | | [BedrockAgent](./bedrockagent.md) | `bedrockagent` | 32 | | [BedrockAgentRuntime](./bedrockagentruntime.md) | `bedrockagentruntime` | 3 | -| [CloudFormation](./cloudformation.md) | (provider-native) | 17 | +| [CloudFormation](./cloudformation.md) | (provider-native) | 22 | | [CloudFront](./cloudfront.md) | `cloudfront` | 11 | | [CloudTrail](./cloudtrail.md) | `cloudtrail` | 60 | | [CloudWatch](./cloudwatch.md) | `monitoring` | 12 | diff --git a/docs/coverage/aws/cloudformation.md b/docs/coverage/aws/cloudformation.md index d507b2fbd..98faaaeb5 100644 --- a/docs/coverage/aws/cloudformation.md +++ b/docs/coverage/aws/cloudformation.md @@ -3,7 +3,7 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver · [AWS index](./README.md) -## Operations (17) +## Operations (22) | Operation | Description | | --- | --- | @@ -12,17 +12,22 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver | `CreateStack` | | | `DeleteChangeSet` | | | `DeleteStack` | | +| `DescribeAccountLimits` | | | `DescribeChangeSet` | | | `DescribeStackEvents` | | | `DescribeStackResources` | | | `DescribeStacks` | | +| `EstimateTemplateCost` | | | `ExecuteChangeSet` | | | `GetTemplate` | | | `GetTemplateSummary` | | | `ListChangeSets` | | +| `ListExports` | | +| `ListImports` | | | `ListStackResources` | | | `ListStacks` | | | `UpdateStack` | | +| `UpdateTerminationProtection` | | | `ValidateTemplate` | | ## Not in scope diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 8235169db..a7c5f2fbc 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -3149,6 +3149,9 @@ { "name": "DeleteStack" }, + { + "name": "DescribeAccountLimits" + }, { "name": "DescribeChangeSet" }, @@ -3161,6 +3164,9 @@ { "name": "DescribeStacks" }, + { + "name": "EstimateTemplateCost" + }, { "name": "ExecuteChangeSet" }, @@ -3173,6 +3179,12 @@ { "name": "ListChangeSets" }, + { + "name": "ListExports" + }, + { + "name": "ListImports" + }, { "name": "ListStackResources" }, @@ -3182,6 +3194,9 @@ { "name": "UpdateStack" }, + { + "name": "UpdateTerminationProtection" + }, { "name": "ValidateTemplate" } diff --git a/providers/aws/cloudformation/changeset.go b/providers/aws/cloudformation/changeset.go index 58678f07a..39e59041d 100644 --- a/providers/aws/cloudformation/changeset.go +++ b/providers/aws/cloudformation/changeset.go @@ -215,6 +215,10 @@ func (m *Mock) planCreateChangeSet(ctx context.Context, in *cfn.CreateChangeSetI return nil, nil, err } + if lerr := m.checkStackLimit(); lerr != nil { + return nil, nil, lerr + } + sd = &stackData{ resolved: map[string]cfn.ResolvedResource{}, deleteIDs: map[string]string{}, @@ -604,7 +608,10 @@ func (m *Mock) ExecuteChangeSet(ctx context.Context, in *cfn.ExecuteChangeSetInp return err } - run := execution{rec: rec, onFailure: onFailure, token: in.ClientRequestToken} + run := execution{ + rec: rec, onFailure: onFailure, token: in.ClientRequestToken, + retainExceptOnCreate: in.RetainExceptOnCreate != nil && *in.RetainExceptOnCreate, + } if rec.ChangeSet.Type == cfn.ChangeSetTypeCreate { return m.executeCreate(ctx, sd, &run) @@ -615,9 +622,10 @@ func (m *Mock) ExecuteChangeSet(ctx context.Context, in *cfn.ExecuteChangeSetInp // execution is one ExecuteChangeSet call. type execution struct { - rec *changeSetRecord - onFailure string - token string + rec *changeSetRecord + onFailure string + token string + retainExceptOnCreate bool } // executable rejects a change set that cannot run. The caller holds the @@ -741,6 +749,7 @@ func (m *Mock) beginExecute( sd.stack.ChangeSetID = rec.ChangeSet.ID sd.stack.DisableRollback = run.onFailure == cfn.OnStackFailureDoNothing + sd.stack.RetainExceptOnCreate = run.retainExceptOnCreate sd.stack.Status = status sd.stack.StatusReason = reasonUserInitiated sd.stack.Events = append(sd.stack.Events, diff --git a/providers/aws/cloudformation/changeset_test.go b/providers/aws/cloudformation/changeset_test.go index 6dcfa0d63..83491f9db 100644 --- a/providers/aws/cloudformation/changeset_test.go +++ b/providers/aws/cloudformation/changeset_test.go @@ -482,7 +482,7 @@ func TestDoNothingKeepsReplacedResources(t *testing.T) { restored := newParamMock(p) requireNoError(t, restored.Restore(ctx, json.RawMessage(data))) - requireNoError(t, restored.DeleteStack(ctx, "s")) + requireNoError(t, restored.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s"})) for _, name := range []string{"/a", "/a2", "/b"} { if _, ok := p.values[name]; ok { @@ -686,7 +686,7 @@ func TestDeleteChangeSet(t *testing.T) { assertEqual(t, again.StackID, st.ID, "same review stack") // DeleteStack removes the review stack and its change sets. - requireNoError(t, m.DeleteStack(ctx, "s")) + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s"})) _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: again.ID}) assertException(t, err, cfn.ExceptionChangeSetNotFound, "") diff --git a/providers/aws/cloudformation/cloudformation.go b/providers/aws/cloudformation/cloudformation.go index 839353fba..c585a3dc9 100644 --- a/providers/aws/cloudformation/cloudformation.go +++ b/providers/aws/cloudformation/cloudformation.go @@ -30,6 +30,10 @@ type Mock struct { fetchTemplate TemplateFetcher // readParameter reads Parameter Store for SSM parameter types. readParameter ParameterReader + // exportMu serializes the export checks of different stacks, so two + // stacks cannot claim one export name and an export cannot lose its + // last guard while a stack starts importing it. + exportMu sync.Mutex } // stackData is the stored state of one stack, guarded by its own mutex. @@ -58,6 +62,49 @@ type stackData struct { // failed update that was not rolled back. The next successful update // deletes them in its cleanup phase, and DeleteStack deletes them. retained []retainedResource + // imports lists the export names the stack imports with + // Fn::ImportValue. An export in the list cannot be changed or deleted. + imports []string + // policies maps a logical ID to the DeletionPolicy and + // UpdateReplacePolicy it was last applied with. + policies map[string]resourcePolicy +} + +// resourcePolicy is the effective DeletionPolicy and UpdateReplacePolicy of +// a provisioned resource. +type resourcePolicy struct { + Deletion string `json:"deletion,omitempty"` + Replace string `json:"replace,omitempty"` +} + +// policyOf returns the policies CloudFormation applies to rdef. +func policyOf(rdef *cfn.ResourceDef) resourcePolicy { + return resourcePolicy{Deletion: rdef.EffectiveDeletionPolicy(), Replace: rdef.EffectiveReplacePolicy()} +} + +// policy returns the policies a resource was applied with. A resource +// without a record, such as one restored from an older snapshot, has the +// defaults. +func (sd *stackData) policy(id string) resourcePolicy { + sd.mu.RLock() + defer sd.mu.RUnlock() + + if p, ok := sd.policies[id]; ok { + return p + } + + return resourcePolicy{Deletion: cfn.PolicyValueDelete, Replace: cfn.PolicyValueDelete} +} + +func (sd *stackData) setPolicy(id string, p resourcePolicy) { + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.policies == nil { + sd.policies = map[string]resourcePolicy{} + } + + sd.policies[id] = p } // retainedResource is the old physical resource of a replacement that is @@ -68,10 +115,12 @@ type retainedResource struct { Resolved cfn.ResolvedResource `json:"resolved"` Props map[string]any `json:"props,omitempty"` DeleteID string `json:"deleteId"` + // ReplacePolicy is the UpdateReplacePolicy the cleanup applies. + ReplacePolicy string `json:"replacePolicy,omitempty"` } func (r *retainedResource) replacement() replacement { - return replacement{id: r.LogicalID, old: liveResource{ + return replacement{id: r.LogicalID, policy: r.ReplacePolicy, old: liveResource{ typ: r.Type, resolved: r.Resolved, props: r.Props, deleteID: r.DeleteID, }} } @@ -85,6 +134,7 @@ func (sd *stackData) retain(replaced []replacement) { old := &replaced[i].old sd.retained = append(sd.retained, retainedResource{ LogicalID: replaced[i].id, Type: old.typ, Resolved: old.resolved, Props: old.props, DeleteID: old.deleteID, + ReplacePolicy: replaced[i].policy, }) } } diff --git a/providers/aws/cloudformation/cloudformation_test.go b/providers/aws/cloudformation/cloudformation_test.go index 8c5e6c72a..562cfb4c3 100644 --- a/providers/aws/cloudformation/cloudformation_test.go +++ b/providers/aws/cloudformation/cloudformation_test.go @@ -180,7 +180,7 @@ func TestDeleteStackTearsDownResources(t *testing.T) { _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "demo", TemplateBody: twoResourceTemplate}) requireNoError(t, err) - requireNoError(t, m.DeleteStack(ctx, "demo")) + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "demo"})) if len(store.items) != 0 { t.Fatalf("expected all resources removed, still have %v", store.items) diff --git a/providers/aws/cloudformation/converge.go b/providers/aws/cloudformation/converge.go index c9db33542..48db7ab08 100644 --- a/providers/aws/cloudformation/converge.go +++ b/providers/aws/cloudformation/converge.go @@ -13,6 +13,7 @@ import ( const ( verbCreate = "create" verbUpdate = "update" + verbDelete = "delete" ) // convergeOpts tunes one converge pass. @@ -28,6 +29,9 @@ type convergeOpts struct { // cleanRetained has the cleanup phase also delete the old resources // retained from earlier replacements. cleanRetained bool + // rollback marks a pass that rolls an update back. Its cleanup deletes + // the resources the update created, and it does not check exports. + rollback bool } // applyFailure is one resource a converge pass could not bring to its target. @@ -54,6 +58,8 @@ type replacement struct { // the retained old resources. A rollback retains it again instead of // deleting it. reclaimed bool + // policy is the UpdateReplacePolicy the cleanup applies to old. + policy string } // Resource status reasons CloudFormation records on a replacement. @@ -87,7 +93,7 @@ func (m *Mock) converge( } if len(failures) == 0 { - failures = m.setOutputs(sd, res, t) + failures = m.setOutputs(sd, res, t, !o.rollback) if len(failures) > 0 && o.stopOnFailure { return failures, replaced } @@ -102,7 +108,7 @@ func (m *Mock) converge( retained = sd.drainRetained() } - m.cleanup(ctx, sd, t, o.skip, append(retained, replaced...)) + m.cleanup(ctx, sd, t, &o, append(retained, replaced...)) return failures, nil } @@ -122,25 +128,42 @@ func (m *Mock) applyAll( continue } - if f := m.applyOne(ctx, sd, res, id, t.Resources[id], &replaced); f != nil { + rdef := t.Resources[id] + + if f := m.applyOne(ctx, sd, res, id, rdef, &replaced); f != nil { failures = append(failures, *f) if o.stopOnFailure { break } + + continue } + + sd.setPolicy(id, policyOf(&rdef)) } return failures, replaced } -// setOutputs resolves and stores the template outputs. -func (*Mock) setOutputs(sd *stackData, res *cfn.Resolver, t *cfn.Template) []applyFailure { +// setOutputs resolves and stores the template outputs. With checkExports +// set, an export another stack owns, or a change to an export another stack +// imports, fails instead. +func (m *Mock) setOutputs(sd *stackData, res *cfn.Resolver, t *cfn.Template, checkExports bool) []applyFailure { outputs, err := resolveOutputs(res, t) if err != nil { return []applyFailure{{verb: verbUpdate, err: err}} } + m.exportMu.Lock() + defer m.exportMu.Unlock() + + if checkExports { + if cerr := m.checkExports(sd, outputs); cerr != nil { + return []applyFailure{{verb: verbUpdate, err: cerr}} + } + } + sd.mu.Lock() sd.stack.Outputs = outputs sd.mu.Unlock() @@ -233,7 +256,7 @@ func (m *Mock) replaceOne( return f } - *replaced = append(*replaced, replacement{id: id, old: *live}) + *replaced = append(*replaced, replacement{id: id, old: *live, policy: rdef.EffectiveReplacePolicy()}) return nil } @@ -340,7 +363,7 @@ func (m *Mock) reclaimRetained( m.recordCreated(sd, res, id, rdef.Type, out, props, cfn.ResourceUpdateComplete) case cerrors.IsAlreadyExists(err): m.record(sd, res, id, old.resolved, old.props, old.deleteID) - *replaced = append(*replaced, replacement{id: id, old: *live, reclaimed: true}) + *replaced = append(*replaced, replacement{id: id, old: *live, reclaimed: true, policy: rdef.EffectiveReplacePolicy()}) return m.updateOne(ctx, sd, res, id, rdef, &old, props), true default: @@ -350,7 +373,7 @@ func (m *Mock) reclaimRetained( return &applyFailure{logicalID: id, verb: verbUpdate, err: err}, true } - *replaced = append(*replaced, replacement{id: id, old: *live}) + *replaced = append(*replaced, replacement{id: id, old: *live, policy: rdef.EffectiveReplacePolicy()}) return nil, true } @@ -418,37 +441,81 @@ func (*Mock) record( // cleanup deletes the old resources of replacements, then, newest first, // the live resources t no longer declares. A failed delete is reported in the // events. A dropped resource whose delete fails is kept, so a later update -// retries it. +// retries it. UpdateReplacePolicy Retain keeps an old resource, and +// DeletionPolicy decides whether a dropped one is deleted or only removed +// from the stack. func (m *Mock) cleanup( - ctx context.Context, sd *stackData, t *cfn.Template, skip map[string]bool, replaced []replacement, + ctx context.Context, sd *stackData, t *cfn.Template, o *convergeOpts, replaced []replacement, ) { for i := len(replaced) - 1; i >= 0; i-- { - _ = m.deletePhysical(ctx, sd, replaced[i].id, &replaced[i].old) + m.dropReplaced(ctx, sd, &replaced[i]) } + flag := sd.retainExceptOnCreate() + sd.mu.RLock() order := append([]string(nil), sd.provisionOrder...) sd.mu.RUnlock() for i := len(order) - 1; i >= 0; i-- { id := order[i] - if _, keep := t.Resources[id]; keep || skip[id] { + if _, keep := t.Resources[id]; keep || o.skip[id] { continue } - if live, ok := sd.live(id); ok { - _ = m.deleteOne(ctx, sd, id, &live) + live, ok := sd.live(id) + if !ok { + continue } + + if cfn.KeepsOnDelete(sd.policy(id).Deletion, o.rollback, flag) { + m.skipDelete(sd, id, &live) + continue + } + + _ = m.deleteOne(ctx, sd, id, &live) } } +// dropReplaced deletes the old resource of a replacement, or with +// UpdateReplacePolicy Retain leaves it in place outside the stack. +func (m *Mock) dropReplaced(ctx context.Context, sd *stackData, r *replacement) { + if r.policy == cfn.PolicyValueRetain { + m.emitResourceEvent(sd, r.id, r.old.resolved.RefValue, r.old.typ, cfn.ResourceDeleteSkipped, "") + return + } + + _ = m.deletePhysical(ctx, sd, r.id, &r.old) +} + +// skipDelete removes a resource from the stack without deleting it. +func (m *Mock) skipDelete(sd *stackData, id string, live *liveResource) { + m.emitResourceEvent(sd, id, live.resolved.RefValue, live.typ, cfn.ResourceDeleteSkipped, "") + m.forget(sd, id) +} + +// teardownOpts tunes one teardown. +type teardownOpts struct { + // rollbackOfCreate marks the rollback of the create that made the + // resources, where RetainExceptOnCreate deletes. + rollbackOfCreate bool + // retain names resources DeleteStack's RetainResources keeps. + retain map[string]bool + // force keeps a resource whose delete fails instead of failing the + // stack, as DeletionMode FORCE_DELETE_STACK does. + force bool +} + // teardown deletes every provisioned resource in reverse creation order, -// then any old resources retained from replacements. -func (m *Mock) teardown(ctx context.Context, sd *stackData) { +// then any old resources retained from replacements. A resource its +// DeletionPolicy or RetainResources keeps is removed from the stack without +// being deleted. It returns the resources that failed to delete, which stay +// in the stack as DELETE_FAILED. +func (m *Mock) teardown(ctx context.Context, sd *stackData, o teardownOpts) []applyFailure { defer func() { retained := sd.drainRetained() for i := len(retained) - 1; i >= 0; i-- { - _ = m.deletePhysical(ctx, sd, retained[i].id, &retained[i].old) + m.dropReplaced(ctx, sd, &retained[i]) } }() @@ -456,13 +523,43 @@ func (m *Mock) teardown(ctx context.Context, sd *stackData) { order := append([]string(nil), sd.provisionOrder...) sd.mu.RUnlock() + flag := sd.retainExceptOnCreate() + + var failures []applyFailure + for i := len(order) - 1; i >= 0; i-- { - if live, ok := sd.live(order[i]); ok { - _ = m.deleteOne(ctx, sd, order[i], &live) + id := order[i] + + live, ok := sd.live(id) + if !ok { + continue } + + if o.retain[id] || cfn.KeepsOnDelete(sd.policy(id).Deletion, o.rollbackOfCreate, flag) { + m.skipDelete(sd, id, &live) + continue + } + + err := m.deleteOne(ctx, sd, id, &live) + + switch { + case err == nil: + case o.force: + m.skipDelete(sd, id, &live) + default: + failures = append(failures, applyFailure{logicalID: id, verb: verbDelete, err: err}) + m.upsertResource(sd, &cfn.StackResource{ + LogicalID: id, PhysicalID: live.resolved.RefValue, Type: live.typ, + Status: cfn.ResourceDeleteFailed, StatusReason: cerrors.Message(err), Timestamp: m.clock.Now(), + }) + } + } + + if len(failures) == 0 { + m.forgetAll(sd) } - m.forgetAll(sd) + return failures } // deleteOne deletes one live resource and drops its bookkeeping. @@ -534,6 +631,7 @@ func (m *Mock) forget(sd *stackData, id string) { delete(sd.resolved, id) delete(sd.deleteIDs, id) delete(sd.props, id) + delete(sd.policies, id) sd.provisionOrder = slices.DeleteFunc(sd.provisionOrder, func(s string) bool { return s == id }) } @@ -571,7 +669,7 @@ func failureSummary(failures []applyFailure) string { var parts []string - for _, verb := range []string{verbCreate, verbUpdate} { + for _, verb := range []string{verbCreate, verbUpdate, verbDelete} { if ids := byVerb[verb]; len(ids) > 0 { parts = append(parts, "The following resource(s) failed to "+verb+": ["+strings.Join(ids, ", ")+"].") } diff --git a/providers/aws/cloudformation/deletion.go b/providers/aws/cloudformation/deletion.go new file mode 100644 index 000000000..1d6e0ea4a --- /dev/null +++ b/providers/aws/cloudformation/deletion.go @@ -0,0 +1,189 @@ +package cloudformation + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "slices" + "sort" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// Error texts of DeleteStack, CreateStack's failure options and the +// account limits. +const ( + msgTerminationProtected = "Stack [%s] cannot be deleted while TerminationProtection is enabled" + msgRetainNotFailed = "Invalid operation on stack [%s]. When you delete a stack, " + + "specify which resources to retain only when the stack is in the DELETE_FAILED state." + msgFailureAndRollback = "Either DisableRollback or OnFailure can be specified, not both." + msgStackLimit = "Limit for stacks has been exceeded" +) + +// The account limits DescribeAccountLimits reports. They are the default +// CloudFormation quotas. +const ( + limitStacks = 2000 + limitStackOutputs = cfn.MaxOutputs + limitConcurrentResources = 2500 +) + +// costCalculatorURL is the page EstimateTemplateCost links to. +const ( + costCalculatorURL = "https://calculator.aws/#/estimate?id=" + costIDLen = 32 +) + +// DeleteStack deletes a stack. Each resource is deleted in reverse creation +// order unless its DeletionPolicy keeps it. When a resource fails to delete +// the stack ends DELETE_FAILED. A retry may then name the resources to keep +// in RetainResources, or use DeletionMode FORCE_DELETE_STACK to keep every +// one that still fails. A stack whose exports another stack imports ends +// DELETE_FAILED without deleting anything. Termination protection refuses +// the call. Deleting an absent or already deleted stack is a successful +// no-op, as in CloudFormation. +func (m *Mock) DeleteStack(ctx context.Context, in *cfn.DeleteStackInput) error { + modes := []string{cfn.DeletionModeStandard, cfn.DeletionModeForceDelete} + if in.DeletionMode != "" && !slices.Contains(modes, in.DeletionMode) { + return cerrors.Newf(cerrors.InvalidArgument, msgFieldEnum, in.DeletionMode, "deletionMode", strings.Join(modes, ", ")) + } + + sd, _, ok := m.findStack(in.StackName) + if !ok || sd.status() == cfn.StatusDeleteComplete { + return nil + } + + wasFailed, err := checkDeletable(sd, in) + if err != nil { + return err + } + + m.exportMu.Lock() + reason := m.exportInUse(sd) + m.startDelete(sd, in.DeletionMode) + m.exportMu.Unlock() + + if reason != "" { + m.emitStackEvent(sd, cfn.StatusDeleteFailed, reason) + return nil + } + + retain := make(map[string]bool, len(in.RetainResources)) + for _, id := range in.RetainResources { + retain[id] = true + } + + m.finishDelete(ctx, sd, teardownOpts{retain: retain, force: wasFailed && in.DeletionMode == cfn.DeletionModeForceDelete}) + + return nil +} + +// checkDeletable refuses a protected stack, and RetainResources on a stack +// that is not DELETE_FAILED. It reports whether the stack is DELETE_FAILED. +func checkDeletable(sd *stackData, in *cfn.DeleteStackInput) (bool, error) { + sd.mu.RLock() + defer sd.mu.RUnlock() + + if sd.stack.EnableTerminationProtection { + return false, cerrors.Newf(cerrors.InvalidArgument, msgTerminationProtected, sd.stack.Name) + } + + failed := sd.stack.Status == cfn.StatusDeleteFailed + if len(in.RetainResources) > 0 && !failed { + return false, cerrors.Newf(cerrors.InvalidArgument, msgRetainNotFailed, sd.stack.ID) + } + + return failed, nil +} + +// startDelete moves the stack to DELETE_IN_PROGRESS, which also withdraws +// its exports. The caller holds exportMu. +func (m *Mock) startDelete(sd *stackData, mode string) { + if mode == "" { + mode = cfn.DeletionModeStandard + } + + sd.mu.Lock() + sd.stack.DeletionMode = mode + sd.mu.Unlock() + + m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reasonUserInitiated) +} + +// UpdateTerminationProtection turns a stack's termination protection on or +// off and returns the stack id. +func (m *Mock) UpdateTerminationProtection(_ context.Context, in *cfn.UpdateTerminationProtectionInput) (string, error) { + sd, err := m.activeStack(in.StackName) + if err != nil { + return "", err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.stack.EnableTerminationProtection = in.Enable + + return sd.stack.ID, nil +} + +// checkStackLimit refuses a new stack once the account holds StackLimit +// stacks that are not deleted. +func (m *Mock) checkStackLimit() error { + n := 0 + + for _, sd := range m.stacks.All() { + if sd.status() != cfn.StatusDeleteComplete { + n++ + } + } + + if n >= limitStacks { + return cfn.NewException(cfn.ExceptionLimitExceeded, cerrors.New(cerrors.FailedPrecondition, msgStackLimit)) + } + + return nil +} + +// DescribeAccountLimits returns the account's CloudFormation quotas. They +// fit one page, so a token is ignored. +func (*Mock) DescribeAccountLimits(_ context.Context, _ string) ([]cfn.AccountLimit, error) { + return []cfn.AccountLimit{ + {Name: "StackLimit", Value: limitStacks}, + {Name: "StackOutputsLimit", Value: limitStackOutputs}, + {Name: "ConcurrentResourcesLimit", Value: limitConcurrentResources}, + }, nil +} + +// EstimateTemplateCost checks a template and its parameters and returns a +// cost calculator link. The link id is a hash of the template and +// parameters, so the same input gives the same link. +func (m *Mock) EstimateTemplateCost(ctx context.Context, in *cfn.EstimateTemplateCostInput) (string, error) { + body, err := m.templateBody(ctx, in.TemplateBody, in.TemplateURL) + if err != nil { + return "", err + } + + t, err := cfn.ParseTemplate(body) + if err != nil { + return "", err + } + + for _, p := range in.Parameters { + if _, ok := t.Parameters[p.Key]; !ok { + return "", cerrors.Newf(cerrors.InvalidArgument, "Parameters: [%s] do not exist in the template", p.Key) + } + } + + params := make([]string, 0, len(in.Parameters)) + for _, p := range in.Parameters { + params = append(params, p.Key+"="+p.Value) + } + + sort.Strings(params) + + sum := sha256.Sum256([]byte(body + "\x00" + strings.Join(params, "\x00"))) + + return costCalculatorURL + hex.EncodeToString(sum[:])[:costIDLen], nil +} diff --git a/providers/aws/cloudformation/deletion_test.go b/providers/aws/cloudformation/deletion_test.go new file mode 100644 index 000000000..40da3ebee --- /dev/null +++ b/providers/aws/cloudformation/deletion_test.go @@ -0,0 +1,445 @@ +package cloudformation + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// stuckProv is a backend whose delete of a name in stuck fails, the way S3 +// refuses to delete a bucket that still holds objects. +type stuckProv struct { + store *backing + stuck map[string]bool +} + +func (p stuckProv) Create(_ context.Context, req cfn.ResourceRequest) (*cfn.ProvisionedResource, error) { + name := cfn.PropString(req.Properties, "Name") + p.store.items[name] = true + + return &cfn.ProvisionedResource{PhysicalID: name}, nil +} + +func (p stuckProv) Delete(_ context.Context, physicalID string, _ map[string]any) error { + if p.stuck[physicalID] { + return cerrors.New(cerrors.FailedPrecondition, "The bucket you tried to delete is not empty") + } + + delete(p.store.items, physicalID) + + return nil +} + +func newStuckMock(store *backing, stuck ...string) *Mock { + m := newTestMock(store) + + p := stuckProv{store: store, stuck: map[string]bool{}} + for _, s := range stuck { + p.stuck[s] = true + } + + m.registry["Test::Stuck"] = p + + return m +} + +// eventStatuses lists the statuses of a resource's events, oldest first. +func eventStatuses(t *testing.T, m *Mock, stack, logicalID string) []string { + t.Helper() + + events, err := m.DescribeStackEvents(context.Background(), stack) + requireNoError(t, err) + + var out []string + + for i := len(events) - 1; i >= 0; i-- { + if events[i].LogicalID == logicalID { + out = append(out, events[i].Status) + } + } + + return out +} + +const retainTemplate = `{"Resources":{ + "Kept":{"Type":"Test::Bucket","DeletionPolicy":"Retain","Properties":{"Name":"kept"}}, + "Gone":{"Type":"Test::Bucket","Properties":{"Name":"gone"}} +}}` + +func TestDeletionPolicyRetainOnStackDelete(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + st := createOK(t, m, "s", retainTemplate) + deleteOK(t, m, "s") + + assertEqual(t, lastStatus(t, m, st.ID).Status, cfn.StatusDeleteComplete, "status") + assertEqual(t, store.items["kept"], true, "retained resource") + assertEqual(t, store.items["gone"], false, "deleted resource") + + statuses := eventStatuses(t, m, st.ID, "Kept") + assertEqual(t, statuses[len(statuses)-1], cfn.ResourceDeleteSkipped, "retained event") +} + +func TestDeletionPolicyOnUpdateRemoval(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "s", `{"Resources":{ + "Kept":{"Type":"Test::Bucket","DeletionPolicy":"RetainExceptOnCreate","Properties":{"Name":"kept"}}, + "Gone":{"Type":"Test::Bucket","Properties":{"Name":"gone"}}, + "Stay":{"Type":"Test::Bucket","Properties":{"Name":"stay"}} + }}`) + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: `{"Resources":{"Stay":{"Type":"Test::Bucket","Properties":{"Name":"stay"}}}}`, + }) + requireNoError(t, err) + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "status") + assertEqual(t, store.items["kept"], true, "retained on removal") + assertEqual(t, store.items["gone"], false, "deleted on removal") + + res, err := m.DescribeStackResources(ctx, "s") + requireNoError(t, err) + assertEqual(t, len(res), 1, "resources left in the stack") +} + +func TestDeletionPolicyOnCreateRollback(t *testing.T) { + const body = `{"Resources":{ + "Retain":{"Type":"Test::Bucket","DeletionPolicy":"Retain","Properties":{"Name":"retain"}}, + "Except":{"Type":"Test::Bucket","DeletionPolicy":"RetainExceptOnCreate","Properties":{"Name":"except"}}, + "Bad":{"Type":"Test::Boom","DependsOn":["Retain","Except"]} + }}` + + cases := []struct { + name string + flag bool + retainExists bool + }{ + {name: "retain kept", retainExists: true}, + {name: "RetainExceptOnCreate flag deletes retain", flag: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + _, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{ + StackName: "s", TemplateBody: body, RetainExceptOnCreate: tc.flag, + }) + requireNoError(t, err) + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusRollbackComplete, "status") + assertEqual(t, st.RetainExceptOnCreate, tc.flag, "flag on the stack") + assertEqual(t, store.items["retain"], tc.retainExists, "Retain resource") + assertEqual(t, store.items["except"], false, "RetainExceptOnCreate resource") + }) + } +} + +func TestUpdateRollbackWithRetainExceptOnCreate(t *testing.T) { + const next = `{"Resources":{ + "Old":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v1"}}, + "New":{"Type":"Test::Param","DeletionPolicy":"Retain","DependsOn":"Old","Properties":{"Name":"/n","Value":"new"}}, + "Bad":{"Type":"Test::Boom","DependsOn":"New"} + }}` + + for _, flag := range []bool{false, true} { + p := newParamProv() + m := newParamMock(p) + + createOK(t, m, "s", paramV1) + + _, err := m.UpdateStack(context.Background(), &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: next, RetainExceptOnCreate: flag, + }) + requireNoError(t, err) + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackComplete, "status") + + _, kept := p.values["/n"] + assertEqual(t, kept, !flag, "new Retain resource after rollback") + } +} + +func TestSnapshotPolicyOnATypeWithoutSnapshotsDeletes(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "s", `{"Resources":{"B":{"Type":"Test::Bucket","DeletionPolicy":"Snapshot","Properties":{"Name":"b"}}}}`) + deleteOK(t, m, "s") + + assertEqual(t, store.items["b"], false, "Snapshot falls back to Delete") +} + +func TestUpdateReplacePolicyRetainKeepsTheOldResource(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + + createOK(t, m, "s", `{"Resources":{ + "P":{"Type":"Test::Param","UpdateReplacePolicy":"Retain","Properties":{"Name":"/a","Value":"v"}} + }}`) + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: `{"Resources":{ + "P":{"Type":"Test::Param","UpdateReplacePolicy":"Retain","Properties":{"Name":"/b","Value":"v"}} + }}`}) + requireNoError(t, err) + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "status") + + _, oldKept := p.values["/a"] + _, newMade := p.values["/b"] + assertEqual(t, oldKept, true, "old resource retained") + assertEqual(t, newMade, true, "replacement created") + + statuses := eventStatuses(t, m, "s", "P") + assertEqual(t, statuses[len(statuses)-1], cfn.ResourceDeleteSkipped, "old resource skipped") + + deleteOK(t, m, "s") + + _, oldKept = p.values["/a"] + _, newMade = p.values["/b"] + assertEqual(t, oldKept, true, "retained old resource outlives the stack") + assertEqual(t, newMade, false, "stack resource deleted") +} + +func TestDeletionPolicyOnlyChangeIsAnUpdate(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "s", `{"Resources":{"B":{"Type":"Test::Bucket","Properties":{"Name":"b"}}}}`) + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: `{"Resources":{"B":{"Type":"Test::Bucket","DeletionPolicy":"Retain","Properties":{"Name":"b"}}}}`, + }) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "status") + + deleteOK(t, m, "s") + assertEqual(t, store.items["b"], true, "new DeletionPolicy applies") +} + +func TestDeleteFailedThenRetainResources(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newStuckMock(store, "full") + + st := createOK(t, m, "s", `{"Resources":{ + "Full":{"Type":"Test::Stuck","Properties":{"Name":"full"}}, + "Empty":{"Type":"Test::Stuck","Properties":{"Name":"empty"}} + }}`) + + err := m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", RetainResources: []string{"Full"}}) + assertErrorContains(t, err, "Invalid operation on stack ["+st.ID+"]. When you delete a stack, "+ + "specify which resources to retain only when the stack is in the DELETE_FAILED state.") + + deleteOK(t, m, "s") + + st = stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusDeleteFailed, "status") + assertEqual(t, st.StatusReason, "The following resource(s) failed to delete: [Full].", "reason") + assertEqual(t, store.items["empty"], false, "deletable resource deleted") + + res, err := m.DescribeStackResources(ctx, "s") + requireNoError(t, err) + assertEqual(t, len(res), 1, "failed resource kept") + assertEqual(t, res[0].Status, cfn.ResourceDeleteFailed, "resource status") + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", UsePreviousTemplate: true}) + assertErrorContains(t, err, "is in DELETE_FAILED state and can not be updated") + + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", RetainResources: []string{"Full"}})) + assertEqual(t, lastStatus(t, m, st.ID).Status, cfn.StatusDeleteComplete, "retry status") + assertEqual(t, store.items["full"], true, "retained resource") +} + +func TestForceDeleteStackKeepsWhatCannotBeDeleted(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newStuckMock(store, "full") + + st := createOK(t, m, "s", `{"Resources":{"Full":{"Type":"Test::Stuck","Properties":{"Name":"full"}}}}`) + + deleteOK(t, m, "s") + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusDeleteFailed, "first delete") + + err := m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", DeletionMode: "WRONG"}) + assertErrorContains(t, err, "Member must satisfy enum value set: [STANDARD, FORCE_DELETE_STACK]") + + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", DeletionMode: cfn.DeletionModeForceDelete})) + + final := lastStatus(t, m, st.ID) + assertEqual(t, final.Status, cfn.StatusDeleteComplete, "forced status") + assertEqual(t, final.DeletionMode, cfn.DeletionModeForceDelete, "deletion mode") + assertEqual(t, store.items["full"], true, "undeletable resource kept") +} + +func TestTerminationProtectionBlocksDelete(t *testing.T) { + ctx := context.Background() + m := newTestMock(newBacking()) + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "s", TemplateBody: twoResourceTemplate, EnableTerminationProtection: true, + }) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").EnableTerminationProtection, true, "protection on") + + err = m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s"}) + assertErrorContains(t, err, "Stack [s] cannot be deleted while TerminationProtection is enabled") + + if !cerrors.IsInvalidArgument(err) { + t.Fatalf("want a ValidationError, got %v", err) + } + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusCreateComplete, "stack unchanged") + + id, err := m.UpdateTerminationProtection(ctx, &cfn.UpdateTerminationProtectionInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, id, stackStatus(t, m, "s").ID, "returned id") + + deleteOK(t, m, "s") + assertEqual(t, lastStatus(t, m, id).Status, cfn.StatusDeleteComplete, "deleted") + + _, err = m.UpdateTerminationProtection(ctx, &cfn.UpdateTerminationProtectionInput{StackName: "s", Enable: true}) + assertErrorContains(t, err, "does not exist") +} + +func TestCreateStackFailureOptions(t *testing.T) { + const body = `{"Resources":{ + "A":{"Type":"Test::Bucket","Properties":{"Name":"a"}}, + "Bad":{"Type":"Test::Boom","DependsOn":"A"} + }}` + + cases := []struct { + name string + in cfn.CreateStackInput + status string + kept bool + disableRollback bool + }{ + {name: "default rolls back", status: cfn.StatusRollbackComplete}, + {name: "DO_NOTHING", in: cfn.CreateStackInput{OnFailure: cfn.OnStackFailureDoNothing}, + status: cfn.StatusCreateFailed, kept: true, disableRollback: true}, + {name: "DisableRollback", in: cfn.CreateStackInput{DisableRollback: true}, + status: cfn.StatusCreateFailed, kept: true, disableRollback: true}, + {name: "DELETE", in: cfn.CreateStackInput{OnFailure: cfn.OnStackFailureDelete}, status: cfn.StatusDeleteComplete}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + in := tc.in + in.StackName, in.TemplateBody = "s", body + + out, err := m.CreateStack(context.Background(), &in) + requireNoError(t, err) + + st := lastStatus(t, m, out.ID) + assertEqual(t, st.Status, tc.status, "status") + assertEqual(t, st.DisableRollback, tc.disableRollback, "DisableRollback") + assertEqual(t, store.items["a"], tc.kept, "created resource") + }) + } +} + +func TestCreateStackFailureOptionErrors(t *testing.T) { + m := newTestMock(newBacking()) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "s", TemplateBody: twoResourceTemplate, OnFailure: cfn.OnStackFailureDelete, DisableRollback: true, + }) + assertErrorContains(t, err, "Either DisableRollback or OnFailure can be specified, not both.") + + _, err = m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: twoResourceTemplate, OnFailure: "KEEP"}) + assertErrorContains(t, err, "Value 'KEEP' at 'onFailure' failed to satisfy constraint") +} + +func TestStackLimitIsEnforced(t *testing.T) { + m := newTestMock(newBacking()) + + for i := range limitStacks { + m.stacks.Set(fmt.Sprintf("filler-%d", i), &stackData{ + stack: cfn.Stack{Name: "filler", Status: cfn.StatusCreateComplete}, + }) + } + + _, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{StackName: "one-more", TemplateBody: twoResourceTemplate}) + assertErrorContains(t, err, "Limit for stacks has been exceeded") + + var named *cfn.ExceptionError + if !errors.As(err, &named) || named.Exception() != cfn.ExceptionLimitExceeded { + t.Fatalf("want LimitExceededException, got %v", err) + } +} + +func TestDescribeAccountLimitsAndEstimateTemplateCost(t *testing.T) { + ctx := context.Background() + m := newTestMock(newBacking()) + + limits, err := m.DescribeAccountLimits(ctx, "") + requireNoError(t, err) + assertEqual(t, len(limits), 3, "limits") + assertEqual(t, limits[0].Name, "StackLimit", "first limit") + assertEqual(t, limits[0].Value, 2000, "stack limit") + assertEqual(t, limits[1].Value, 200, "outputs limit") + assertEqual(t, limits[2].Name, "ConcurrentResourcesLimit", "third limit") + + in := &cfn.EstimateTemplateCostInput{TemplateBody: twoResourceTemplate} + + u1, err := m.EstimateTemplateCost(ctx, in) + requireNoError(t, err) + + u2, err := m.EstimateTemplateCost(ctx, in) + requireNoError(t, err) + assertEqual(t, u1, u2, "same input, same link") + + if !strings.HasPrefix(u1, "https://calculator.aws/#/estimate?id=") { + t.Fatalf("url = %s", u1) + } + + _, err = m.EstimateTemplateCost(ctx, &cfn.EstimateTemplateCostInput{TemplateBody: "{"}) + if err == nil { + t.Fatal("bad template accepted") + } +} + +func TestChangeSetReportsPolicyActions(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + + createOK(t, m, "s", `{"Resources":{ + "Dropped":{"Type":"Test::Param","DeletionPolicy":"Retain","Properties":{"Name":"/d","Value":"v"}}, + "Replaced":{"Type":"Test::Param","UpdateReplacePolicy":"Retain","Properties":{"Name":"/r","Value":"v"}} + }}`) + + _, err := m.CreateChangeSet(ctx, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "cs", TemplateBody: `{"Resources":{ + "Replaced":{"Type":"Test::Param","UpdateReplacePolicy":"Retain","Properties":{"Name":"/r2","Value":"v"}} + }}`}) + requireNoError(t, err) + + cs, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{StackName: "s", ChangeSetName: "cs"}) + requireNoError(t, err) + + got := map[string]string{} + for _, c := range cs.Changes { + got[c.LogicalID] = c.PolicyAction + } + + assertEqual(t, got["Dropped"], cfn.PolicyRetain, "Remove policy action") + assertEqual(t, got["Replaced"], cfn.PolicyReplaceAndRetain, "replace policy action") +} diff --git a/providers/aws/cloudformation/exports.go b/providers/aws/cloudformation/exports.go new file mode 100644 index 000000000..d43b12eb9 --- /dev/null +++ b/providers/aws/cloudformation/exports.go @@ -0,0 +1,256 @@ +package cloudformation + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/pagination" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// Error texts of the export guards and the export list operations. +const ( + msgExportTaken = "Export with name %s is already exported by stack %s." + msgExportUpdated = "Export %s cannot be updated as it is in use by %s" + msgExportRemoved = "Export %s cannot be deleted as it is in use by %s" + msgExportDeleted = "Cannot delete export %s as it is in use by %s" + msgNotImported = "Export '%s' is not imported by any stack." + msgExportNameNeed = "ExportName is required" +) + +// exportsPageSize is the number of entries a ListExports or ListImports page +// holds. +const exportsPageSize = 100 + +// exportEntry is one export and the stack that owns it. +type exportEntry struct { + cfn.Export + stackName string +} + +// allExports lists every export in the region, sorted by name. A stack that +// is deleted, or being deleted, exports nothing. +func (m *Mock) allExports() []exportEntry { + var out []exportEntry + + for _, sd := range m.sortedStacks() { + sd.mu.RLock() + + if st := sd.stack.Status; st != cfn.StatusDeleteComplete && st != cfn.StatusDeleteInProgress { + for _, o := range sd.stack.Outputs { + if o.ExportName != "" { + out = append(out, exportEntry{ + Export: cfn.Export{ExportingStackID: sd.stack.ID, Name: o.ExportName, Value: o.Value}, + stackName: sd.stack.Name, + }) + } + } + } + + sd.mu.RUnlock() + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out +} + +// exportValues maps the export names a stack can import to their values. +// A stack cannot import its own exports. +func (m *Mock) exportValues(stackID string) map[string]string { + out := map[string]string{} + + for _, e := range m.allExports() { + if e.ExportingStackID != stackID { + out[e.Name] = e.Value + } + } + + return out +} + +// importers returns, sorted, the names of the stacks other than stackID +// that import the export name. +func (m *Mock) importers(name, stackID string) []string { + var out []string + + for _, sd := range m.sortedStacks() { + sd.mu.RLock() + + if sd.stack.ID != stackID && sd.stack.Status != cfn.StatusDeleteComplete && slices.Contains(sd.imports, name) { + out = append(out, sd.stack.Name) + } + + sd.mu.RUnlock() + } + + return out +} + +// bindImports resolves the exports t imports, checks that each exists and +// records them on the stack together with keep, the imports it already +// holds. It returns the names t imports, or the failure of a missing +// export. It refreshes res.Exports, so the operation reads the values +// current when it starts. +func (m *Mock) bindImports(sd *stackData, t *cfn.Template, res *cfn.Resolver, keep []string) ([]string, *applyFailure) { + m.exportMu.Lock() + defer m.exportMu.Unlock() + + res.Exports = m.exportValues(res.StackID) + + names, err := res.ImportNames(t) + if err != nil { + return nil, &applyFailure{verb: verbCreate, err: err} + } + + for _, name := range names { + if _, ok := res.Exports[name]; !ok { + return nil, &applyFailure{verb: verbCreate, err: cfn.NoExportError(name)} + } + } + + merged := append(slices.Clone(keep), names...) + slices.Sort(merged) + sd.setImports(slices.Compact(merged)) + + return names, nil +} + +func (sd *stackData) setImports(names []string) { + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.imports = names +} + +func (sd *stackData) importList() []string { + sd.mu.RLock() + defer sd.mu.RUnlock() + + return slices.Clone(sd.imports) +} + +// checkExports checks the outputs a stack is about to publish: a new export +// name must be free, and an export another stack imports must keep its +// value. The caller holds exportMu. +func (m *Mock) checkExports(sd *stackData, outputs []cfn.Output) error { + _, stackID := sd.identity() + + owners := map[string]string{} + + for _, e := range m.allExports() { + if e.ExportingStackID != stackID { + owners[e.Name] = e.stackName + } + } + + next := map[string]string{} + + for _, o := range outputs { + if o.ExportName == "" { + continue + } + + if owner, taken := owners[o.ExportName]; taken { + return cerrors.Newf(cerrors.InvalidArgument, msgExportTaken, o.ExportName, owner) + } + + next[o.ExportName] = o.Value + } + + return m.checkExportsInUse(sd, stackID, next) +} + +// checkExportsInUse refuses to drop or change an export another stack +// imports. next maps the export names the stack is about to publish to their +// values. +func (m *Mock) checkExportsInUse(sd *stackData, stackID string, next map[string]string) error { + sd.mu.RLock() + current := slices.Clone(sd.stack.Outputs) + sd.mu.RUnlock() + + for _, o := range current { + if o.ExportName == "" { + continue + } + + users := m.importers(o.ExportName, stackID) + if len(users) == 0 { + continue + } + + value, kept := next[o.ExportName] + + switch { + case !kept: + return cerrors.Newf(cerrors.InvalidArgument, msgExportRemoved, o.ExportName, strings.Join(users, ", ")) + case value != o.Value: + return cerrors.Newf(cerrors.InvalidArgument, msgExportUpdated, o.ExportName, strings.Join(users, ", ")) + } + } + + return nil +} + +// exportInUse returns the reason a stack cannot be deleted because another +// stack imports one of its exports, or "". The caller holds exportMu. +func (m *Mock) exportInUse(sd *stackData) string { + sd.mu.RLock() + outputs := slices.Clone(sd.stack.Outputs) + stackID := sd.stack.ID + sd.mu.RUnlock() + + for _, o := range outputs { + if o.ExportName == "" { + continue + } + + if users := m.importers(o.ExportName, stackID); len(users) > 0 { + return fmt.Sprintf(msgExportDeleted, o.ExportName, strings.Join(users, ", ")) + } + } + + return "" +} + +// ListExports returns one page of the region's exports, sorted by name. +func (m *Mock) ListExports(_ context.Context, nextToken string) (*cfn.ExportList, error) { + entries := m.allExports() + + all := make([]cfn.Export, len(entries)) + for i := range entries { + all[i] = entries[i].Export + } + + page, err := pagination.Paginate(all, nextToken, exportsPageSize) + if err != nil { + return nil, cerrors.New(cerrors.InvalidArgument, msgInvalidNextToken) + } + + return &cfn.ExportList{Exports: page.Items, NextToken: page.NextPageToken}, nil +} + +// ListImports returns one page of the names of the stacks that import an +// export. An export nothing imports, or that does not exist, is a +// ValidationError. +func (m *Mock) ListImports(_ context.Context, in *cfn.ListImportsInput) (*cfn.ImportList, error) { + if in.ExportName == "" { + return nil, cerrors.New(cerrors.InvalidArgument, msgExportNameNeed) + } + + users := m.importers(in.ExportName, "") + if len(users) == 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, msgNotImported, in.ExportName) + } + + page, err := pagination.Paginate(users, in.NextToken, exportsPageSize) + if err != nil { + return nil, cerrors.New(cerrors.InvalidArgument, msgInvalidNextToken) + } + + return &cfn.ImportList{Imports: page.Items, NextToken: page.NextPageToken}, nil +} diff --git a/providers/aws/cloudformation/exports_test.go b/providers/aws/cloudformation/exports_test.go new file mode 100644 index 000000000..97df26f10 --- /dev/null +++ b/providers/aws/cloudformation/exports_test.go @@ -0,0 +1,255 @@ +package cloudformation + +import ( + "context" + "fmt" + "strings" + "testing" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +const exporterTemplate = `{ + "Resources":{"MyBucket":{"Type":"Test::Bucket","Properties":{"Name":"data-bucket"}}}, + "Outputs":{"BucketName":{"Value":{"Ref":"MyBucket"},"Export":{"Name":"shared-bucket"}}} +}` + +const importerTemplate = `{ + "Resources":{"Copy":{"Type":"Test::Bucket","Properties":{ + "Name":{"Fn::Join":["-",[{"Fn::ImportValue":"shared-bucket"},"copy"]]} + }}} +}` + +func createOK(t *testing.T, m *Mock, name, body string) cfn.Stack { + t.Helper() + + _, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{StackName: name, TemplateBody: body}) + requireNoError(t, err) + + return stackStatus(t, m, name) +} + +func deleteOK(t *testing.T, m *Mock, name string) { + t.Helper() + + requireNoError(t, m.DeleteStack(context.Background(), &cfn.DeleteStackInput{StackName: name})) +} + +// lastStatus returns a stack's status by id, which also finds a deleted one. +func lastStatus(t *testing.T, m *Mock, id string) cfn.Stack { + t.Helper() + + return stackStatus(t, m, id) +} + +func TestImportValueResolvesExport(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "exporter", exporterTemplate) + st := createOK(t, m, "importer", importerTemplate) + assertEqual(t, st.Status, cfn.StatusCreateComplete, "importer status") + + if !store.items["data-bucket-copy"] { + t.Fatalf("imported value not resolved: %v", store.items) + } + + exports, err := m.ListExports(ctx, "") + requireNoError(t, err) + assertEqual(t, len(exports.Exports), 1, "exports") + assertEqual(t, exports.Exports[0].Name, "shared-bucket", "export name") + assertEqual(t, exports.Exports[0].Value, "data-bucket", "export value") + assertEqual(t, exports.Exports[0].ExportingStackID, stackStatus(t, m, "exporter").ID, "exporting stack") + + imports, err := m.ListImports(ctx, &cfn.ListImportsInput{ExportName: "shared-bucket"}) + requireNoError(t, err) + assertEqual(t, strings.Join(imports.Imports, ","), "importer", "imports") +} + +func TestImportValueOfMissingExportRollsBack(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + st := createOK(t, m, "importer", importerTemplate) + assertEqual(t, st.Status, cfn.StatusRollbackComplete, "status") + assertEqual(t, st.StatusReason, "No export named shared-bucket found. Rollback requested by user.", "reason") + assertEqual(t, len(store.items), 0, "nothing created") +} + +func TestDuplicateExportNameFailsTheStack(t *testing.T) { + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "exporter", exporterTemplate) + + st := createOK(t, m, "second", strings.ReplaceAll(exporterTemplate, "data-bucket", "other-bucket")) + assertEqual(t, st.Status, cfn.StatusRollbackComplete, "status") + assertEqual(t, st.StatusReason, + "Export with name shared-bucket is already exported by stack exporter. Rollback requested by user.", "reason") + + if store.items["other-bucket"] { + t.Fatal("rolled back resource still exists") + } +} + +func TestDeleteExporterInUseEndsDeleteFailed(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "exporter", exporterTemplate) + createOK(t, m, "importer", importerTemplate) + + deleteOK(t, m, "exporter") + + st := stackStatus(t, m, "exporter") + assertEqual(t, st.Status, cfn.StatusDeleteFailed, "status") + assertEqual(t, st.StatusReason, "Cannot delete export shared-bucket as it is in use by importer", "reason") + + if !store.items["data-bucket"] { + t.Fatal("exporter resource deleted despite the guard") + } + + deleteOK(t, m, "importer") + deleteOK(t, m, "exporter") + assertEqual(t, lastStatus(t, m, st.ID).Status, cfn.StatusDeleteComplete, "retry status") + + exports, err := m.ListExports(ctx, "") + requireNoError(t, err) + assertEqual(t, len(exports.Exports), 0, "exports after delete") +} + +func TestUpdateThatDropsOrChangesAnExportInUseRollsBack(t *testing.T) { + cases := []struct { + name, body, reason string + }{ + { + name: "removed", + body: `{"Resources":{"MyBucket":{"Type":"Test::Bucket","Properties":{"Name":"data-bucket"}}}}`, + reason: "Export shared-bucket cannot be deleted as it is in use by importer", + }, + { + name: "changed", + body: strings.ReplaceAll(exporterTemplate, `{"Ref":"MyBucket"}`, `"literal"`), + reason: "Export shared-bucket cannot be updated as it is in use by importer", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + m := newTestMock(newBacking()) + + createOK(t, m, "exporter", exporterTemplate) + createOK(t, m, "importer", importerTemplate) + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "exporter", TemplateBody: tc.body}) + requireNoError(t, err) + + st := stackStatus(t, m, "exporter") + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "status") + assertEqual(t, st.StatusReason, tc.reason, "reason") + + exports, err := m.ListExports(ctx, "") + requireNoError(t, err) + assertEqual(t, len(exports.Exports), 1, "export kept") + assertEqual(t, exports.Exports[0].Value, "data-bucket", "export value kept") + }) + } +} + +func TestImporterDroppingTheImportReleasesTheExport(t *testing.T) { + ctx := context.Background() + m := newTestMock(newBacking()) + + createOK(t, m, "exporter", exporterTemplate) + createOK(t, m, "importer", importerTemplate) + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{ + StackName: "importer", + TemplateBody: `{"Resources":{"Copy":{"Type":"Test::Bucket","Properties":{"Name":"plain"}}}}`, + }) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "importer").Status, cfn.StatusUpdateComplete, "importer update") + + _, err = m.ListImports(ctx, &cfn.ListImportsInput{ExportName: "shared-bucket"}) + assertErrorContains(t, err, "Export 'shared-bucket' is not imported by any stack.") + + deleteOK(t, m, "exporter") + assertEqual(t, stackStatus(t, m, "importer").Status, cfn.StatusUpdateComplete, "importer untouched") +} + +func TestListImportsOfAnExportNothingImports(t *testing.T) { + m := newTestMock(newBacking()) + createOK(t, m, "exporter", exporterTemplate) + + _, err := m.ListImports(context.Background(), &cfn.ListImportsInput{ExportName: "shared-bucket"}) + assertErrorContains(t, err, "Export 'shared-bucket' is not imported by any stack.") +} + +func TestListExportsPages(t *testing.T) { + ctx := context.Background() + m := newTestMock(newBacking()) + + var outputs []string + for i := range 150 { + outputs = append(outputs, fmt.Sprintf(`"O%d":{"Value":"v%d","Export":{"Name":"e-%03d"}}`, i, i, i)) + } + + body := `{"Resources":{"B":{"Type":"Test::Bucket"}},"Outputs":{` + strings.Join(outputs, ",") + `}}` + assertEqual(t, createOK(t, m, "many", body).Status, cfn.StatusCreateComplete, "status") + + first, err := m.ListExports(ctx, "") + requireNoError(t, err) + assertEqual(t, len(first.Exports), 100, "first page") + + if first.NextToken == "" { + t.Fatal("first page has no NextToken") + } + + second, err := m.ListExports(ctx, first.NextToken) + requireNoError(t, err) + assertEqual(t, len(second.Exports), 50, "second page") + assertEqual(t, second.NextToken, "", "last page token") + assertEqual(t, second.Exports[49].Name, "e-149", "sorted by name") + + _, err = m.ListExports(ctx, "not-a-token") + assertErrorContains(t, err, "Invalid NextToken") +} + +func TestImportValueNameMustNotDependOnResources(t *testing.T) { + m := newTestMock(newBacking()) + + _, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{StackName: "s", TemplateBody: `{"Resources":{ + "A":{"Type":"Test::Bucket"}, + "B":{"Type":"Test::Bucket","Properties":{"Name":{"Fn::ImportValue":{"Ref":"A"}}}} + }}`}) + assertErrorContains(t, err, "Template error: the attribute in Fn::ImportValue must not depend on any resources") +} + +func TestExportGuardsSurviveSnapshotRestore(t *testing.T) { + ctx := context.Background() + store := newBacking() + m := newTestMock(store) + + createOK(t, m, "exporter", exporterTemplate) + createOK(t, m, "importer", importerTemplate) + + snap, err := m.Snapshot(ctx, false) + requireNoError(t, err) + + restored := newTestMock(store) + requireNoError(t, restored.Restore(ctx, snap)) + + deleteOK(t, restored, "exporter") + assertEqual(t, stackStatus(t, restored, "exporter").Status, cfn.StatusDeleteFailed, "restored guard") +} + +func assertErrorContains(t *testing.T, err error, want string) { + t.Helper() + + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want it to contain %q", err, want) + } +} diff --git a/providers/aws/cloudformation/orchestrate.go b/providers/aws/cloudformation/orchestrate.go index a4eb05bae..7ccfd93e2 100644 --- a/providers/aws/cloudformation/orchestrate.go +++ b/providers/aws/cloudformation/orchestrate.go @@ -2,6 +2,7 @@ package cloudformation import ( "context" + "slices" "sort" "strings" "time" @@ -26,6 +27,11 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. return nil, cerrors.New(cerrors.InvalidArgument, "stack name is required") } + onFailure, err := createFailureMode(in) + if err != nil { + return nil, err + } + body, err := m.templateBody(ctx, in.TemplateBody, in.TemplateURL) if err != nil { return nil, err @@ -53,6 +59,10 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. return nil, cerr } + if lerr := m.checkStackLimit(); lerr != nil { + return nil, lerr + } + now := m.clock.Now() sd := &stackData{ resolved: map[string]cfn.ResolvedResource{}, @@ -63,6 +73,9 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. Description: t.Description, Parameters: params, Tags: in.Tags, Capabilities: in.Capabilities, TemplateBody: body, CreationTime: now, LastUpdated: now, NotificationARNs: in.NotificationARNs, + DisableRollback: onFailure == cfn.OnStackFailureDoNothing, + EnableTerminationProtection: in.EnableTerminationProtection, + RetainExceptOnCreate: in.RetainExceptOnCreate, }, } @@ -72,7 +85,7 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. m.emitStackEvent(sd, cfn.StatusCreateInProgress, "User Initiated") - m.provision(ctx, sd, effective, resolver, cfn.OnStackFailureRollback) + m.provision(ctx, sd, effective, resolver, onFailure) out := sd.snapshotStack() @@ -84,7 +97,14 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. // ROLLBACK deletes what was created and leaves ROLLBACK_COMPLETE, // DO_NOTHING keeps it and leaves CREATE_FAILED, and DELETE deletes the stack. func (m *Mock) provision(ctx context.Context, sd *stackData, t *cfn.Template, res *cfn.Resolver, onFailure string) bool { - failures, _ := m.converge(ctx, sd, t, res, convergeOpts{stopOnFailure: true}) + var failures []applyFailure + + if _, f := m.bindImports(sd, t, res, nil); f != nil { + failures = []applyFailure{*f} + } else { + failures, _ = m.converge(ctx, sd, t, res, convergeOpts{stopOnFailure: true}) + } + if len(failures) == 0 { m.emitStackEvent(sd, cfn.StatusCreateComplete, "") return true @@ -97,17 +117,41 @@ func (m *Mock) provision(ctx context.Context, sd *stackData, t *cfn.Template, re m.emitStackEvent(sd, cfn.StatusCreateFailed, reason) case cfn.OnStackFailureDelete: m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reason+" Delete requested by user.") - m.finishDelete(ctx, sd) + m.finishDelete(ctx, sd, teardownOpts{rollbackOfCreate: true}) default: reason += " Rollback requested by user." m.emitStackEvent(sd, cfn.StatusRollbackInProgress, reason) - m.teardown(ctx, sd) + + if tf := m.teardown(ctx, sd, teardownOpts{rollbackOfCreate: true}); len(tf) > 0 { + m.emitStackEvent(sd, cfn.StatusRollbackFailed, failureSummary(tf)) + return false + } + m.emitTerminalEvent(sd, cfn.StatusRollbackComplete, reason) } return false } +// createFailureMode resolves CreateStack's OnFailure and DisableRollback to +// the one failure mode they name. +func createFailureMode(in *cfn.CreateStackInput) (string, error) { + modes := []string{cfn.OnStackFailureDoNothing, cfn.OnStackFailureRollback, cfn.OnStackFailureDelete} + + switch { + case in.OnFailure != "" && in.DisableRollback: + return "", cerrors.New(cerrors.InvalidArgument, msgFailureAndRollback) + case in.OnFailure != "" && !slices.Contains(modes, in.OnFailure): + return "", cerrors.Newf(cerrors.InvalidArgument, msgFieldEnum, in.OnFailure, "onFailure", strings.Join(modes, ", ")) + case in.OnFailure != "": + return in.OnFailure, nil + case in.DisableRollback: + return cfn.OnStackFailureDoNothing, nil + default: + return cfn.OnStackFailureRollback, nil + } +} + // claimStackSlot atomically inserts sd for name, or replaces a prior // DELETE_COMPLETE stack, returning false when an active stack already holds the // name. The atomicity (SetIfAbsent, otherwise a store-locked Update) closes the @@ -307,6 +351,7 @@ func (m *Mock) newResolver(name, id string, paramValues map[string]string, notif StackName: name, StackID: id, NotificationARNs: notificationARNs, + Exports: m.exportValues(id), } } @@ -460,6 +505,8 @@ func (*Mock) forgetAll(sd *stackData) { sd.resolved = map[string]cfn.ResolvedResource{} sd.deleteIDs = map[string]string{} sd.props = map[string]map[string]any{} + sd.policies = nil + sd.imports = nil } func (*Mock) resourceTypes(sd *stackData) map[string]string { diff --git a/providers/aws/cloudformation/snapshot.go b/providers/aws/cloudformation/snapshot.go index 8879529d5..d87ce5d95 100644 --- a/providers/aws/cloudformation/snapshot.go +++ b/providers/aws/cloudformation/snapshot.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "github.com/stackshy/cloudemu/v2/internal/snapshot" cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" @@ -28,6 +29,8 @@ type stackSnapshot struct { RollbackFailed []string `json:"rollbackFailed,omitempty"` ChangeSets []changeSetRecord `json:"changeSets,omitempty"` Retained []retainedResource `json:"retained,omitempty"` + Imports []string `json:"imports,omitempty"` + Policies map[string]resourcePolicy `json:"policies,omitempty"` } // Snapshot captures every stack's state under its own name so a restore @@ -46,6 +49,8 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { RollbackFailed: append([]string(nil), sd.rollbackFailed...), ChangeSets: cloneChangeSets(sd.changeSets), Retained: append([]retainedResource(nil), sd.retained...), + Imports: append([]string(nil), sd.imports...), + Policies: maps.Clone(sd.policies), } sd.mu.RUnlock() } @@ -69,6 +74,8 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { props: ss.Props, rollbackFailed: ss.RollbackFailed, retained: ss.Retained, + imports: ss.Imports, + policies: ss.Policies, } for i := range ss.ChangeSets { diff --git a/providers/aws/cloudformation/stack_id_test.go b/providers/aws/cloudformation/stack_id_test.go index 92b275bba..c5fe31c0a 100644 --- a/providers/aws/cloudformation/stack_id_test.go +++ b/providers/aws/cloudformation/stack_id_test.go @@ -35,7 +35,7 @@ func TestStackNameAcceptsStackID(t *testing.T) { t.Fatalf("an unknown ID must be NotFound, got %v", err) } - requireNoError(t, m.DeleteStack(ctx, id)) + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: id})) got, err = m.DescribeStacks(ctx, id) requireNoError(t, err) diff --git a/providers/aws/cloudformation/update.go b/providers/aws/cloudformation/update.go index df4301d4b..35e817fa8 100644 --- a/providers/aws/cloudformation/update.go +++ b/providers/aws/cloudformation/update.go @@ -93,7 +93,7 @@ func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn. } sd.obsoleteChangeSets() - sd.setDisableRollback(in.DisableRollback) + sd.setRollbackFlags(in.DisableRollback, in.RetainExceptOnCreate) m.runUpdate(ctx, sd, in, plan, onFailure) out := sd.snapshotStack() @@ -114,9 +114,22 @@ func (m *Mock) runUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStack stopOnFailure: true, cleanupStatus: cfn.StatusUpdateCompleteCleanupInProgress, cleanRetained: true, } - failures, replaced := m.converge(ctx, sd, plan.newT, plan.newRes, forward) + var ( + failures []applyFailure + replaced []replacement + ) + + imports, f := m.bindImports(sd, plan.newT, plan.newRes, sd.importList()) + if f != nil { + failures = []applyFailure{*f} + } else { + failures, replaced = m.converge(ctx, sd, plan.newT, plan.newRes, forward) + } + if len(failures) == 0 { + sd.setImports(imports) m.emitStackEvent(sd, cfn.StatusUpdateComplete, "") + return true } @@ -260,11 +273,20 @@ func appliedTemplate(sd *stackData, t *cfn.Template, res *cfn.Resolver) *cfn.Tem return &out } -func (sd *stackData) setDisableRollback(v bool) { +// setRollbackFlags records how the operation that starts handles a failure. +func (sd *stackData) setRollbackFlags(disableRollback, retainExceptOnCreate bool) { sd.mu.Lock() defer sd.mu.Unlock() - sd.stack.DisableRollback = v + sd.stack.DisableRollback = disableRollback + sd.stack.RetainExceptOnCreate = retainExceptOnCreate +} + +func (sd *stackData) retainExceptOnCreate() bool { + sd.mu.RLock() + defer sd.mu.RUnlock() + + return sd.stack.RetainExceptOnCreate } // checkTypesKept rejects a template that gives a live logical ID a new type. @@ -357,7 +379,7 @@ func (*Mock) noChanges(sd *stackData, p *updatePlan, in *cfn.UpdateStackInput) b live, _ := sd.live(id) props, err := resolveProps(p.newRes, rdef.Properties) - if err != nil || !cfn.SameProperties(live.props, props) { + if err != nil || !cfn.SameProperties(live.props, props) || sd.policy(id) != policyOf(&rdef) { return false } } @@ -447,9 +469,13 @@ func (m *Mock) finishRollback( ctx context.Context, sd *stackData, t *cfn.Template, res *cfn.Resolver, skip map[string]bool, reason string, ) { failures, _ := m.converge(ctx, sd, t, res, convergeOpts{ - skip: skip, cleanupStatus: cfn.StatusUpdateRollbackCompleteCleanupInProgress, + skip: skip, cleanupStatus: cfn.StatusUpdateRollbackCompleteCleanupInProgress, rollback: true, }) if len(failures) == 0 { + if imports, err := res.ImportNames(t); err == nil { + sd.setImports(imports) + } + sd.setRollbackFailed(nil) m.emitTerminalEvent(sd, cfn.StatusUpdateRollbackComplete, reason) @@ -584,26 +610,14 @@ func (sd *stackData) rowType(id string) string { return "" } -// DeleteStack tears down the stack's resources in reverse creation order and -// marks it DELETE_COMPLETE. Deleting an absent or already-deleted stack is a -// no-op success, matching CloudFormation's idempotent delete. A stack in -// UPDATE_ROLLBACK_FAILED can be deleted. -func (m *Mock) DeleteStack(ctx context.Context, name string) error { - sd, _, ok := m.findStack(name) - if !ok || sd.status() == cfn.StatusDeleteComplete { - return nil - } - - m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reasonUserInitiated) - m.finishDelete(ctx, sd) - - return nil -} - // finishDelete tears down a stack in DELETE_IN_PROGRESS, drops its change -// sets and marks it DELETE_COMPLETE. -func (m *Mock) finishDelete(ctx context.Context, sd *stackData) { - m.teardown(ctx, sd) +// sets and marks it DELETE_COMPLETE. When a resource fails to delete the +// stack ends DELETE_FAILED with that resource still in it. +func (m *Mock) finishDelete(ctx context.Context, sd *stackData, o teardownOpts) { + if failures := m.teardown(ctx, sd, o); len(failures) > 0 { + m.emitStackEvent(sd, cfn.StatusDeleteFailed, failureSummary(failures)) + return + } sd.mu.Lock() sd.stack.DeletionTime = m.clock.Now() diff --git a/server/aws/cloudformation/changeset.go b/server/aws/cloudformation/changeset.go index 0740966c6..31394c6ca 100644 --- a/server/aws/cloudformation/changeset.go +++ b/server/aws/cloudformation/changeset.go @@ -106,10 +106,15 @@ func (h *Handler) executeChangeSet(w http.ResponseWriter, r *http.Request) { } if r.Form.Has("DisableRollback") { - disable := strings.EqualFold(r.Form.Get("DisableRollback"), "true") + disable := formBool(r.Form, "DisableRollback") in.DisableRollback = &disable } + if r.Form.Has("RetainExceptOnCreate") { + retain := formBool(r.Form, "RetainExceptOnCreate") + in.RetainExceptOnCreate = &retain + } + if err := h.api.ExecuteChangeSet(r.Context(), in); err != nil { writeErr(w, err) return diff --git a/server/aws/cloudformation/exports.go b/server/aws/cloudformation/exports.go new file mode 100644 index 000000000..9e7cd2d75 --- /dev/null +++ b/server/aws/cloudformation/exports.go @@ -0,0 +1,182 @@ +package cloudformation + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// The export, protection and account actions. +const ( + actionListExports = "ListExports" + actionListImports = "ListImports" + actionUpdateTerminationProtection = "UpdateTerminationProtection" + actionDescribeAccountLimits = "DescribeAccountLimits" + actionEstimateTemplateCost = "EstimateTemplateCost" +) + +// serveAccount dispatches the export, protection and account actions. +func (h *Handler) serveAccount(w http.ResponseWriter, r *http.Request) { + switch r.Form.Get("Action") { + case actionListExports: + h.listExports(w, r) + case actionListImports: + h.listImports(w, r) + case actionUpdateTerminationProtection: + h.updateTerminationProtection(w, r) + case actionDescribeAccountLimits: + h.describeAccountLimits(w, r) + case actionEstimateTemplateCost: + h.estimateTemplateCost(w, r) + } +} + +func (h *Handler) listExports(w http.ResponseWriter, r *http.Request) { + list, err := h.api.ListExports(r.Context(), r.Form.Get("NextToken")) + if err != nil { + writeErr(w, err) + return + } + + var resp listExportsResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.NextToken = list.NextToken + + for _, e := range list.Exports { + resp.Result.Exports = append(resp.Result.Exports, exportXML{ + ExportingStackID: e.ExportingStackID, Name: e.Name, Value: e.Value, + }) + } + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) listImports(w http.ResponseWriter, r *http.Request) { + list, err := h.api.ListImports(r.Context(), &cfn.ListImportsInput{ + ExportName: r.Form.Get("ExportName"), + NextToken: r.Form.Get("NextToken"), + }) + if err != nil { + writeErr(w, err) + return + } + + var resp listImportsResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.Imports = list.Imports + resp.Result.NextToken = list.NextToken + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) updateTerminationProtection(w http.ResponseWriter, r *http.Request) { + id, err := h.api.UpdateTerminationProtection(r.Context(), &cfn.UpdateTerminationProtectionInput{ + StackName: r.Form.Get("StackName"), + Enable: formBool(r.Form, "EnableTerminationProtection"), + }) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, updateTerminationProtectionResponse{ + Xmlns: Namespace, Result: stackIDResult{StackID: id}, Meta: meta(), + }) +} + +func (h *Handler) describeAccountLimits(w http.ResponseWriter, r *http.Request) { + limits, err := h.api.DescribeAccountLimits(r.Context(), r.Form.Get("NextToken")) + if err != nil { + writeErr(w, err) + return + } + + var resp describeAccountLimitsResponse + resp.Xmlns = Namespace + resp.Meta = meta() + + for _, l := range limits { + resp.Result.AccountLimits = append(resp.Result.AccountLimits, accountLimitXML{Name: l.Name, Value: l.Value}) + } + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) estimateTemplateCost(w http.ResponseWriter, r *http.Request) { + u, err := h.api.EstimateTemplateCost(r.Context(), &cfn.EstimateTemplateCostInput{ + TemplateBody: r.Form.Get("TemplateBody"), + TemplateURL: r.Form.Get("TemplateURL"), + Parameters: parseParameters(r.Form), + }) + if err != nil { + writeErr(w, err) + return + } + + var resp estimateTemplateCostResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.URL = u + + awsquery.WriteXMLResponse(w, resp) +} + +type exportXML struct { + ExportingStackID string `xml:"ExportingStackId"` + Name string `xml:"Name"` + Value string `xml:"Value"` +} + +type listExportsResponse struct { + XMLName xml.Name `xml:"ListExportsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + Exports []exportXML `xml:"Exports>member"` + NextToken string `xml:"NextToken,omitempty"` + } `xml:"ListExportsResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type listImportsResponse struct { + XMLName xml.Name `xml:"ListImportsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + Imports []string `xml:"Imports>member"` + NextToken string `xml:"NextToken,omitempty"` + } `xml:"ListImportsResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type updateTerminationProtectionResponse struct { + XMLName xml.Name `xml:"UpdateTerminationProtectionResponse"` + Xmlns string `xml:"xmlns,attr"` + Result stackIDResult `xml:"UpdateTerminationProtectionResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type accountLimitXML struct { + Name string `xml:"Name"` + Value int `xml:"Value"` +} + +type describeAccountLimitsResponse struct { + XMLName xml.Name `xml:"DescribeAccountLimitsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + AccountLimits []accountLimitXML `xml:"AccountLimits>member"` + } `xml:"DescribeAccountLimitsResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type estimateTemplateCostResponse struct { + XMLName xml.Name `xml:"EstimateTemplateCostResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + URL string `xml:"Url"` + } `xml:"EstimateTemplateCostResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} diff --git a/server/aws/cloudformation/exports_sdk_test.go b/server/aws/cloudformation/exports_sdk_test.go new file mode 100644 index 000000000..6eaa255d7 --- /dev/null +++ b/server/aws/cloudformation/exports_sdk_test.go @@ -0,0 +1,243 @@ +package cloudformation_test + +import ( + "context" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfn "github.com/aws/aws-sdk-go-v2/service/cloudformation" + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" +) + +const sdkExporter = `Resources: + Queue: + Type: AWS::SQS::Queue + Properties: + QueueName: shared-queue +Outputs: + QueueName: + Value: !GetAtt Queue.QueueName + Export: + Name: shared-queue-name +` + +const sdkImporter = `Resources: + Param: + Type: AWS::SSM::Parameter + Properties: + Name: /imported + Type: String + Value: !ImportValue shared-queue-name +` + +// Exports and imports through the real SDK: ListExports, ListImports, the +// in-use guard on delete, and the exporter delete once the importer is gone. +func TestExportsAndImportsRealSDK(t *testing.T) { + ctx := context.Background() + c, cloud := bootWithProvider(t) + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("exporter"), TemplateBody: aws.String(sdkExporter), + }); err != nil { + t.Fatalf("CreateStack exporter: %v", err) + } + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("importer"), TemplateBody: aws.String(sdkImporter), + }); err != nil { + t.Fatalf("CreateStack importer: %v", err) + } + + if st := describeStack(t, c, "importer"); st.StackStatus != cfntypes.StackStatusCreateComplete { + t.Fatalf("importer = %s (%s)", st.StackStatus, aws.ToString(st.StackStatusReason)) + } + + p, err := cloud.SSM.GetParameter(ctx, "/imported", false) + if err != nil || p.Value != "shared-queue" { + t.Fatalf("imported parameter = %+v, %v", p, err) + } + + exports, err := c.ListExports(ctx, &awscfn.ListExportsInput{}) + if err != nil || len(exports.Exports) != 1 { + t.Fatalf("ListExports = %+v, %v", exports, err) + } + + e := exports.Exports[0] + if aws.ToString(e.Name) != "shared-queue-name" || aws.ToString(e.Value) != "shared-queue" || + aws.ToString(e.ExportingStackId) != aws.ToString(describeStack(t, c, "exporter").StackId) { + t.Fatalf("export = %+v", e) + } + + imports, err := c.ListImports(ctx, &awscfn.ListImportsInput{ExportName: aws.String("shared-queue-name")}) + if err != nil || strings.Join(imports.Imports, ",") != "importer" { + t.Fatalf("ListImports = %+v, %v", imports, err) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("exporter")}); err != nil { + t.Fatalf("DeleteStack exporter: %v", err) + } + + st := describeStack(t, c, "exporter") + if st.StackStatus != cfntypes.StackStatusDeleteFailed || + aws.ToString(st.StackStatusReason) != "Cannot delete export shared-queue-name as it is in use by importer" { + t.Fatalf("exporter = %s (%s)", st.StackStatus, aws.ToString(st.StackStatusReason)) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("importer")}); err != nil { + t.Fatalf("DeleteStack importer: %v", err) + } + + _, err = c.ListImports(ctx, &awscfn.ListImportsInput{ExportName: aws.String("shared-queue-name")}) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || msg != "Export 'shared-queue-name' is not imported by any stack." { + t.Fatalf("ListImports after delete = %s: %s", code, msg) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: st.StackId}); err != nil { + t.Fatalf("DeleteStack retry: %v", err) + } + + if final := describeStack(t, c, aws.ToString(st.StackId)); final.StackStatus != cfntypes.StackStatusDeleteComplete { + t.Fatalf("exporter retry = %s", final.StackStatus) + } +} + +// A DynamoDB table with DeletionPolicy Retain keeps its item after the +// stack is deleted, and a bucket that holds an object fails the delete until +// a retry retains it. +func TestDeletionPolicyAndRetainResourcesRealSDK(t *testing.T) { + ctx := context.Background() + c, cloud := bootWithProvider(t) + + const body = `Resources: + Table: + Type: AWS::DynamoDB::Table + DeletionPolicy: Retain + Properties: + TableName: kept-table + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: [{AttributeName: id, AttributeType: S}] + KeySchema: [{AttributeName: id, KeyType: HASH}] + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: full-bucket +` + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{StackName: aws.String("data"), TemplateBody: aws.String(body)}); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + stackID := aws.ToString(describeStack(t, c, "data").StackId) + + if err := cloud.DynamoDB.PutItem(ctx, "kept-table", map[string]any{"id": "1", "v": "x"}); err != nil { + t.Fatalf("PutItem: %v", err) + } + + if err := cloud.S3.PutObject(ctx, "full-bucket", "k", []byte("v"), "text/plain", nil); err != nil { + t.Fatalf("PutObject: %v", err) + } + + _, err := c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("data"), RetainResources: []string{"Bucket"}}) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || !strings.Contains(msg, "only when the stack is in the DELETE_FAILED state") { + t.Fatalf("RetainResources before DELETE_FAILED = %s: %s", code, msg) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("data")}); err != nil { + t.Fatalf("DeleteStack: %v", err) + } + + st := describeStack(t, c, "data") + if st.StackStatus != cfntypes.StackStatusDeleteFailed || + aws.ToString(st.StackStatusReason) != "The following resource(s) failed to delete: [Bucket]." { + t.Fatalf("after delete = %s (%s)", st.StackStatus, aws.ToString(st.StackStatusReason)) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{ + StackName: aws.String("data"), RetainResources: []string{"Bucket"}, + }); err != nil { + t.Fatalf("DeleteStack retain: %v", err) + } + + if final := describeStack(t, c, stackID); final.StackStatus != cfntypes.StackStatusDeleteComplete { + t.Fatalf("after retain = %s", final.StackStatus) + } + + item, err := cloud.DynamoDB.GetItem(ctx, "kept-table", map[string]any{"id": "1"}) + if err != nil || item["v"] != "x" { + t.Fatalf("retained item = %v, %v", item, err) + } + + if _, err = cloud.S3.GetBucketVersioning(ctx, "full-bucket"); err != nil { + t.Fatalf("retained bucket: %v", err) + } +} + +// Termination protection, CreateStack's failure options and the account +// operations over the real SDK. +func TestTerminationProtectionAndFailureOptionsRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + const queue = `{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"guarded"}}}}` + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("guarded"), TemplateBody: aws.String(queue), EnableTerminationProtection: aws.Bool(true), + }); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + if !aws.ToBool(describeStack(t, c, "guarded").EnableTerminationProtection) { + t.Fatal("EnableTerminationProtection not reported") + } + + _, err := c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("guarded")}) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || + msg != "Stack [guarded] cannot be deleted while TerminationProtection is enabled" { + t.Fatalf("protected delete = %s: %s", code, msg) + } + + out, err := c.UpdateTerminationProtection(ctx, &awscfn.UpdateTerminationProtectionInput{ + StackName: aws.String("guarded"), EnableTerminationProtection: aws.Bool(false), + }) + if err != nil || aws.ToString(out.StackId) != aws.ToString(describeStack(t, c, "guarded").StackId) { + t.Fatalf("UpdateTerminationProtection = %+v, %v", out, err) + } + + if _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("guarded")}); err != nil { + t.Fatalf("DeleteStack: %v", err) + } + + const failing = `{"Resources":{ + "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"kept-on-failure"}}, + "Bad":{"Type":"AWS::Unknown::Thing","DependsOn":"Q"} + }}` + + if _, err = c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("failing"), TemplateBody: aws.String(failing), OnFailure: cfntypes.OnFailureDoNothing, + }); err != nil { + t.Fatalf("CreateStack DO_NOTHING: %v", err) + } + + if st := describeStack(t, c, "failing"); st.StackStatus != cfntypes.StackStatusCreateFailed || !aws.ToBool(st.DisableRollback) { + t.Fatalf("DO_NOTHING = %s, DisableRollback %v", st.StackStatus, aws.ToBool(st.DisableRollback)) + } + + _, err = c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("both"), TemplateBody: aws.String(queue), + OnFailure: cfntypes.OnFailureDelete, DisableRollback: aws.Bool(true), + }) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || msg != "Either DisableRollback or OnFailure can be specified, not both." { + t.Fatalf("both options = %s: %s", code, msg) + } + + limits, err := c.DescribeAccountLimits(ctx, &awscfn.DescribeAccountLimitsInput{}) + if err != nil || len(limits.AccountLimits) != 3 || aws.ToString(limits.AccountLimits[0].Name) != "StackLimit" || + aws.ToInt32(limits.AccountLimits[0].Value) != 2000 { + t.Fatalf("DescribeAccountLimits = %+v, %v", limits, err) + } + + cost, err := c.EstimateTemplateCost(ctx, &awscfn.EstimateTemplateCostInput{TemplateBody: aws.String(queue)}) + if err != nil || !strings.HasPrefix(aws.ToString(cost.Url), "https://calculator.aws/#/estimate?id=") { + t.Fatalf("EstimateTemplateCost = %+v, %v", cost, err) + } +} diff --git a/server/aws/cloudformation/handler.go b/server/aws/cloudformation/handler.go index 47f99f1cc..ad66e396f 100644 --- a/server/aws/cloudformation/handler.go +++ b/server/aws/cloudformation/handler.go @@ -30,6 +30,11 @@ // ListChangeSets API.ListChangeSets // ExecuteChangeSet API.ExecuteChangeSet // DeleteChangeSet API.DeleteChangeSet +// ListExports API.ListExports +// ListImports API.ListImports +// UpdateTerminationProtection API.UpdateTerminationProtection +// DescribeAccountLimits API.DescribeAccountLimits +// EstimateTemplateCost API.EstimateTemplateCost // // Templates may be JSON or YAML, given inline (TemplateBody) or as an S3 // object URL (TemplateURL). @@ -72,6 +77,12 @@ var cfnActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup actionListChangeSets: {}, actionExecuteChangeSet: {}, actionDeleteChangeSet: {}, + + actionListExports: {}, + actionListImports: {}, + actionUpdateTerminationProtection: {}, + actionDescribeAccountLimits: {}, + actionEstimateTemplateCost: {}, } // Handler serves CloudFormation query-protocol requests against a stack API. @@ -139,6 +150,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getTemplateSummary(w, r) case actionCreateChangeSet, actionDescribeChangeSet, actionListChangeSets, actionExecuteChangeSet, actionDeleteChangeSet: h.serveChangeSet(w, r) + case actionListExports, actionListImports, actionUpdateTerminationProtection, actionDescribeAccountLimits, + actionEstimateTemplateCost: + h.serveAccount(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown CloudFormation action: "+r.Form.Get("Action")) diff --git a/server/aws/cloudformation/operations.go b/server/aws/cloudformation/operations.go index 0e4bbf6a4..7e802b4e8 100644 --- a/server/aws/cloudformation/operations.go +++ b/server/aws/cloudformation/operations.go @@ -49,7 +49,13 @@ func (h *Handler) continueUpdateRollback(w http.ResponseWriter, r *http.Request) } func (h *Handler) deleteStack(w http.ResponseWriter, r *http.Request) { - if err := h.api.DeleteStack(r.Context(), r.Form.Get("StackName")); err != nil { + in := &cfn.DeleteStackInput{ + StackName: r.Form.Get("StackName"), + RetainResources: awsquery.ListStrings(r.Form, "RetainResources.member"), + DeletionMode: r.Form.Get("DeletionMode"), + } + + if err := h.api.DeleteStack(r.Context(), in); err != nil { writeErr(w, err) return } diff --git a/server/aws/cloudformation/types.go b/server/aws/cloudformation/types.go index f4529633a..ab747fc20 100644 --- a/server/aws/cloudformation/types.go +++ b/server/aws/cloudformation/types.go @@ -42,9 +42,19 @@ func createInput(form url.Values) cfn.CreateStackInput { Capabilities: awsquery.ListStrings(form, "Capabilities.member"), NotificationARNs: awsquery.ListStrings(form, "NotificationARNs.member"), + + OnFailure: form.Get("OnFailure"), + DisableRollback: formBool(form, "DisableRollback"), + EnableTerminationProtection: formBool(form, "EnableTerminationProtection"), + RetainExceptOnCreate: formBool(form, "RetainExceptOnCreate"), } } +// formBool reads a boolean form field. Absent is false. +func formBool(form url.Values, key string) bool { + return strings.EqualFold(form.Get(key), "true") +} + func updateInput(form url.Values) cfn.UpdateStackInput { return cfn.UpdateStackInput{ StackName: form.Get("StackName"), @@ -54,9 +64,10 @@ func updateInput(form url.Values) cfn.UpdateStackInput { Tags: parseTags(form), Capabilities: awsquery.ListStrings(form, "Capabilities.member"), - UsePreviousTemplate: strings.EqualFold(form.Get("UsePreviousTemplate"), "true"), - DisableRollback: strings.EqualFold(form.Get("DisableRollback"), "true"), - NotificationARNs: updateNotificationARNs(form), + UsePreviousTemplate: formBool(form, "UsePreviousTemplate"), + DisableRollback: formBool(form, "DisableRollback"), + RetainExceptOnCreate: formBool(form, "RetainExceptOnCreate"), + NotificationARNs: updateNotificationARNs(form), } } @@ -183,6 +194,10 @@ type stackXML struct { Capabilities []string `xml:"Capabilities>member,omitempty"` NotificationARNs []string `xml:"NotificationARNs>member,omitempty"` ChangeSetID string `xml:"ChangeSetId,omitempty"` + + EnableTerminationProtection bool `xml:"EnableTerminationProtection"` + RetainExceptOnCreate bool `xml:"RetainExceptOnCreate"` + DeletionMode string `xml:"DeletionMode,omitempty"` } type describeStacksResponse struct { @@ -333,6 +348,8 @@ func toStackXML(s *cfn.Stack) stackXML { CreationTime: isoTime(s.CreationTime), LastUpdatedTime: isoTime(s.LastUpdated), StackStatus: s.Status, StackStatusReason: s.StatusReason, DisableRollback: s.DisableRollback, Capabilities: s.Capabilities, NotificationARNs: s.NotificationARNs, ChangeSetID: s.ChangeSetID, + DeletionTime: isoTime(s.DeletionTime), EnableTerminationProtection: s.EnableTerminationProtection, + RetainExceptOnCreate: s.RetainExceptOnCreate, DeletionMode: s.DeletionMode, } for _, p := range s.Parameters { diff --git a/services/cloudformation/changeplan.go b/services/cloudformation/changeplan.go index aeb902387..8b126ce2c 100644 --- a/services/cloudformation/changeplan.go +++ b/services/cloudformation/changeplan.go @@ -79,7 +79,8 @@ func PlanChanges(in *ChangePlanInput) []ResourceChange { if _, kept := in.New.Resources[id]; !kept { out = append(out, ResourceChange{ Action: ChangeActionRemove, LogicalID: id, PhysicalID: live.PhysicalID, - ResourceType: live.Type, PolicyAction: PolicyDelete, BeforeContext: propertiesContext(live.Props), + ResourceType: live.Type, PolicyAction: in.removePolicy(id, live.Type), + BeforeContext: propertiesContext(live.Props), }) } } @@ -89,6 +90,17 @@ func PlanChanges(in *ChangePlanInput) []ResourceChange { return out } +// removePolicy is the PolicyAction of removing id, from the DeletionPolicy +// it was deployed with. +func (in *ChangePlanInput) removePolicy(id, rtype string) string { + rdef := ResourceDef{Type: rtype} + if in.Old != nil { + rdef.DeletionPolicy = in.Old.Resources[id].DeletionPolicy + } + + return removePolicyAction(&rdef) +} + // modifyChange builds the Modify change of a kept resource, and records how // it affects the resources that read from it. It reports false when the // resource does not change. @@ -134,7 +146,8 @@ func (in *ChangePlanInput) modifyChange(id, rtype string, live *LiveResource, ef } if c.Replacement == ReplacementTrue { - c.PolicyAction = PolicyReplaceAndDelete + rdef := in.New.Resources[id] + c.PolicyAction = replacePolicyAction(&rdef) } return c, true diff --git a/services/cloudformation/changeset.go b/services/cloudformation/changeset.go index 831e65957..04acc0f60 100644 --- a/services/cloudformation/changeset.go +++ b/services/cloudformation/changeset.go @@ -81,8 +81,12 @@ const ( // Policy actions taken on the physical resource of a change. const ( - PolicyDelete = "Delete" - PolicyReplaceAndDelete = "ReplaceAndDelete" + PolicyDelete = "Delete" + PolicyRetain = "Retain" + PolicySnapshot = "Snapshot" + PolicyReplaceAndDelete = "ReplaceAndDelete" + PolicyReplaceAndRetain = "ReplaceAndRetain" + PolicyReplaceAndSnapshot = "ReplaceAndSnapshot" ) // Exception names the change set operations report. @@ -90,6 +94,7 @@ const ( ExceptionAlreadyExists = "AlreadyExistsException" ExceptionChangeSetNotFound = "ChangeSetNotFound" ExceptionInvalidChangeSetStatus = "InvalidChangeSetStatus" + ExceptionLimitExceeded = "LimitExceededException" ) // ChangeTarget is the part of a resource a change detail touches. The value @@ -199,6 +204,9 @@ type ExecuteChangeSetInput struct { ChangeSetName string StackName string DisableRollback *bool + // RetainExceptOnCreate deletes the resources the execution created when + // it rolls back, even those whose DeletionPolicy is Retain. + RetainExceptOnCreate *bool // ClientRequestToken makes a retry of an execution that already started // succeed without running it again. ClientRequestToken string diff --git a/services/cloudformation/intrinsics.go b/services/cloudformation/intrinsics.go index 8ba1649a1..193fcec5d 100644 --- a/services/cloudformation/intrinsics.go +++ b/services/cloudformation/intrinsics.go @@ -28,6 +28,8 @@ type Resolver struct { StackID string // NotificationARNs is what Ref AWS::NotificationARNs returns. NotificationARNs []string + // Exports maps each export name the stack can import to its value. + Exports map[string]string // Prepare fills these from the template. listParams map[string]bool @@ -52,6 +54,8 @@ const ( fnBase64 = "Fn::Base64" fnCidr = "Fn::Cidr" fnGetAZs = "Fn::GetAZs" + + fnImportValue = "Fn::ImportValue" ) // Pseudo parameter names. @@ -109,6 +113,8 @@ func lookupListIntrinsic(fn string) (intrinsicFn, bool) { return (*Resolver).cidr, true case fnGetAZs: return (*Resolver).getAZs, true + case fnImportValue: + return (*Resolver).importValue, true default: return nil, false } diff --git a/services/cloudformation/policies.go b/services/cloudformation/policies.go new file mode 100644 index 000000000..dbc1bec29 --- /dev/null +++ b/services/cloudformation/policies.go @@ -0,0 +1,202 @@ +package cloudformation + +import ( + "slices" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// DeletionPolicy and UpdateReplacePolicy values. +const ( + PolicyValueDelete = "Delete" + PolicyValueRetain = "Retain" + PolicyValueRetainExceptOnCreate = "RetainExceptOnCreate" + PolicyValueSnapshot = "Snapshot" +) + +// MaxOutputs is the number of outputs a template may declare. +const MaxOutputs = 200 + +// snapshotTypes are the resource types CloudFormation can snapshot, per the +// DeletionPolicy reference page. Snapshot on any other type is Delete. +var snapshotTypes = map[string]bool{ //nolint:gochecknoglobals // static lookup table + "AWS::DocDB::DBCluster": true, "AWS::EC2::Volume": true, + "AWS::ElastiCache::CacheCluster": true, "AWS::ElastiCache::ReplicationGroup": true, + "AWS::Neptune::DBCluster": true, "AWS::RDS::DBCluster": true, + "AWS::RDS::DBInstance": true, "AWS::Redshift::Cluster": true, +} + +// deletionPolicies and replacePolicies are the values each attribute takes. +var ( + deletionPolicies = []string{ //nolint:gochecknoglobals // static lookup table + PolicyValueDelete, PolicyValueRetain, PolicyValueRetainExceptOnCreate, PolicyValueSnapshot, + } + replacePolicies = []string{PolicyValueDelete, PolicyValueRetain, PolicyValueSnapshot} //nolint:gochecknoglobals // static lookup table +) + +// effectivePolicy is the policy CloudFormation applies. An unset policy is +// Delete, and so is Snapshot on a type that has no snapshots. +func effectivePolicy(policy, rtype string) string { + if policy == "" || (policy == PolicyValueSnapshot && !snapshotTypes[rtype]) { + return PolicyValueDelete + } + + return policy +} + +// EffectiveDeletionPolicy is the DeletionPolicy CloudFormation applies to r. +func (r *ResourceDef) EffectiveDeletionPolicy() string { + return effectivePolicy(r.DeletionPolicy, r.Type) +} + +// EffectiveReplacePolicy is the UpdateReplacePolicy CloudFormation applies +// to the old resource when r is replaced. +func (r *ResourceDef) EffectiveReplacePolicy() string { + return effectivePolicy(r.UpdateReplacePolicy, r.Type) +} + +// KeepsOnDelete reports whether a resource whose DeletionPolicy is policy +// stays when CloudFormation deletes it. rollbackOfCreate marks the rollback +// of the operation that created the resource. retainExceptOnCreate is the +// stack operation's RetainExceptOnCreate flag, which also deletes a new +// Retain resource on that rollback. +func KeepsOnDelete(policy string, rollbackOfCreate, retainExceptOnCreate bool) bool { + switch policy { + case PolicyValueRetain: + return !rollbackOfCreate || !retainExceptOnCreate + case PolicyValueRetainExceptOnCreate: + return !rollbackOfCreate + default: + return false + } +} + +// removePolicyAction is the PolicyAction of a Remove change. +func removePolicyAction(r *ResourceDef) string { + switch r.EffectiveDeletionPolicy() { + case PolicyValueRetain, PolicyValueRetainExceptOnCreate: + return PolicyRetain + case PolicyValueSnapshot: + return PolicySnapshot + default: + return PolicyDelete + } +} + +// replacePolicyAction is the PolicyAction of a replacing Modify change. +func replacePolicyAction(r *ResourceDef) string { + switch r.EffectiveReplacePolicy() { + case PolicyValueRetain: + return PolicyReplaceAndRetain + case PolicyValueSnapshot: + return PolicyReplaceAndSnapshot + default: + return PolicyReplaceAndDelete + } +} + +// checkPolicy checks a DeletionPolicy or UpdateReplacePolicy value. The +// "Unrecognized" text is the best known form and is not measured. +func checkPolicy(attr, value, id string, allowed []string) error { + if value == "" || slices.Contains(allowed, value) { + return nil + } + + return formatErr("Unrecognized %s %s for resource %s", attr, value, id) +} + +// ImportNames resolves the export names t imports with Fn::ImportValue, +// sorted and without duplicates. Call it after Prepare. +func (r *Resolver) ImportNames(t *Template) ([]string, error) { + seen := map[string]bool{} + + var firstErr error + + for _, node := range valueNodes(t) { + walkIntrinsics(node, func(fn string, arg any) { + if fn != fnImportValue || firstErr != nil { + return + } + + name, err := r.ResolveString(arg) + if err != nil { + firstErr = err + return + } + + seen[name] = true + }) + } + + if firstErr != nil { + return nil, firstErr + } + + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + + sort.Strings(out) + + return out, nil +} + +// importValue resolves Fn::ImportValue from the exports the stack can see. +func (r *Resolver) importValue(arg any) (any, error) { + name, err := r.ResolveString(arg) + if err != nil { + return nil, err + } + + v, ok := r.Exports[name] + if !ok { + return nil, NoExportError(name) + } + + return v, nil +} + +// NoExportError is the error for an Fn::ImportValue of a missing export. +func NoExportError(name string) error { + return cerrors.Newf(cerrors.InvalidArgument, "No export named %s found.", name) +} + +// checkImportValues rejects an Fn::ImportValue whose name depends on a +// resource, another import or Fn::GetAZs, which CloudFormation cannot know +// before it creates anything. +func checkImportValues(t *Template) error { + bad := false + + for _, node := range valueNodes(t) { + walkIntrinsics(node, func(fn string, arg any) { + if fn != fnImportValue || bad { + return + } + + refs, atts := map[string]bool{}, map[string]bool{} + collectNames(arg, refs, atts) + + for name := range refs { + if _, isRes := t.Resources[name]; isRes { + bad = true + } + } + + walkIntrinsics(arg, func(inner string, _ any) { + if inner == fnImportValue || inner == fnGetAZs { + bad = true + } + }) + + bad = bad || len(atts) > 0 + }) + } + + if bad { + return templateErr("the attribute in Fn::ImportValue must not depend on any resources, imported values, or Fn::GetAZs") + } + + return nil +} diff --git a/services/cloudformation/policies_test.go b/services/cloudformation/policies_test.go new file mode 100644 index 000000000..dd577f5da --- /dev/null +++ b/services/cloudformation/policies_test.go @@ -0,0 +1,184 @@ +package cloudformation_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// TestResourcePolicies checks how DeletionPolicy and UpdateReplacePolicy are +// read, and which values they take. +func TestResourcePolicies(t *testing.T) { + t.Parallel() + + cases := []struct { + name, attrs, wantErr string + deletion, replace string + effectiveDelete, effectiveRe string + }{ + {name: "defaults", effectiveDelete: "Delete", effectiveRe: "Delete"}, + { + name: "retain", attrs: `"DeletionPolicy":"Retain","UpdateReplacePolicy":"Retain",`, + deletion: "Retain", replace: "Retain", effectiveDelete: "Retain", effectiveRe: "Retain", + }, + { + name: "snapshot without snapshots", attrs: `"DeletionPolicy":"Snapshot","UpdateReplacePolicy":"Snapshot",`, + deletion: "Snapshot", replace: "Snapshot", effectiveDelete: "Delete", effectiveRe: "Delete", + }, + { + name: "retain except on create", attrs: `"DeletionPolicy":"RetainExceptOnCreate",`, + deletion: "RetainExceptOnCreate", effectiveDelete: "RetainExceptOnCreate", effectiveRe: "Delete", + }, + { + name: "unknown deletion policy", attrs: `"DeletionPolicy":"Keep",`, + wantErr: "Template format error: Unrecognized DeletionPolicy Keep for resource B", + }, + { + name: "RetainExceptOnCreate is not a replace policy", attrs: `"UpdateReplacePolicy":"RetainExceptOnCreate",`, + wantErr: "Unrecognized UpdateReplacePolicy RetainExceptOnCreate for resource B", + }, + { + name: "intrinsic deletion policy", attrs: `"DeletionPolicy":{"Ref":"P"},`, + wantErr: "Template format error: Every DeletionPolicy member must be a string.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tmpl, err := cfn.ParseTemplate(`{"Resources":{"B":{` + tc.attrs + `"Type":"AWS::S3::Bucket"}}}`) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + + b := tmpl.Resources["B"] + assert.Equal(t, tc.deletion, b.DeletionPolicy) + assert.Equal(t, tc.replace, b.UpdateReplacePolicy) + assert.Equal(t, tc.effectiveDelete, b.EffectiveDeletionPolicy()) + assert.Equal(t, tc.effectiveRe, b.EffectiveReplacePolicy()) + }) + } +} + +// TestSnapshotPolicyOnASnapshotType keeps Snapshot for a type that has +// snapshots. +func TestSnapshotPolicyOnASnapshotType(t *testing.T) { + t.Parallel() + + r := cfn.ResourceDef{Type: "AWS::RDS::DBInstance", DeletionPolicy: "Snapshot"} + assert.Equal(t, "Snapshot", r.EffectiveDeletionPolicy()) +} + +// TestKeepsOnDelete is the DeletionPolicy decision matrix. +func TestKeepsOnDelete(t *testing.T) { + t.Parallel() + + cases := []struct { + policy string + rollbackOfCreate, flag bool + keep bool + }{ + {policy: "Delete"}, + {policy: "Retain", keep: true}, + {policy: "Retain", rollbackOfCreate: true, keep: true}, + {policy: "Retain", rollbackOfCreate: true, flag: true}, + {policy: "Retain", flag: true, keep: true}, + {policy: "RetainExceptOnCreate", keep: true}, + {policy: "RetainExceptOnCreate", rollbackOfCreate: true}, + } + + for _, tc := range cases { + got := cfn.KeepsOnDelete(tc.policy, tc.rollbackOfCreate, tc.flag) + assert.Equal(t, tc.keep, got, "%+v", tc) + } +} + +// TestOutputsLimit rejects a template with more than 200 outputs. +func TestOutputsLimit(t *testing.T) { + t.Parallel() + + outputs := make([]string, 0, cfn.MaxOutputs+1) + for i := range cfn.MaxOutputs + 1 { + outputs = append(outputs, fmt.Sprintf(`"O%d":{"Value":"v"}`, i)) + } + + body := `{"Resources":{"B":{"Type":"AWS::S3::Bucket"}},"Outputs":{%s}}` + + _, err := cfn.ParseTemplate(fmt.Sprintf(body, strings.Join(outputs, ","))) + require.Error(t, err) + assert.Contains(t, err.Error(), "Template format error: Outputs count 201 is greater than max allowed 200") + + _, err = cfn.ParseTemplate(fmt.Sprintf(body, strings.Join(outputs[:cfn.MaxOutputs], ","))) + require.NoError(t, err) +} + +// TestImportValue resolves Fn::ImportValue, in long and short form, from the +// exports the resolver sees. +func TestImportValue(t *testing.T) { + t.Parallel() + + body := `Parameters: + Env: {Type: String} +Resources: + B: + Type: AWS::S3::Bucket + Properties: + BucketName: !ImportValue {"Fn::Sub": "${Env}-bucket"} + Other: {"Fn::ImportValue": shared} +` + tmpl, err := cfn.ParseTemplate(body) + require.NoError(t, err) + + r := &cfn.Resolver{ + Params: map[string]string{"Env": "prod"}, + Exports: map[string]string{"prod-bucket": "b-1", "shared": "s-1"}, + } + + prepared, err := r.Prepare(tmpl) + require.NoError(t, err) + + names, err := r.ImportNames(prepared) + require.NoError(t, err) + assert.Equal(t, []string{"prod-bucket", "shared"}, names) + + props, err := r.Resolve(prepared.Resources["B"].Properties) + require.NoError(t, err) + assert.Equal(t, map[string]any{"BucketName": "b-1", "Other": "s-1"}, props) + + r.Exports = nil + _, err = r.Resolve(prepared.Resources["B"].Properties) + require.Error(t, err) + assert.Contains(t, err.Error(), "No export named") +} + +// TestImportValueNameRules rejects an import name that depends on a +// resource, an attribute or another import. +func TestImportValueNameRules(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "ref": `{"Ref":"A"}`, + "getatt": `{"Fn::GetAtt":["A","Arn"]}`, + "nested": `{"Fn::ImportValue":"x"}`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := cfn.ParseTemplate(`{"Resources":{"A":{"Type":"AWS::S3::Bucket"}, + "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":{"Fn::ImportValue":` + value + `}}}}}`) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not depend on any resources, imported values, or Fn::GetAZs") + }) + } +} diff --git a/services/cloudformation/sections.go b/services/cloudformation/sections.go index b43628aec..6cc973505 100644 --- a/services/cloudformation/sections.go +++ b/services/cloudformation/sections.go @@ -291,11 +291,34 @@ func buildResource(m map[string]any, path string) (ResourceDef, error) { return r, formatErr("[/%s/Properties] must be an object", path) } - r.Condition, err = scalarField(m["Condition"], path+"/Condition") + if r.Condition, err = scalarField(m["Condition"], path+"/Condition"); err != nil { + return r, err + } + + id := strings.TrimPrefix(path, "Resources/") + + if r.DeletionPolicy, err = policyField(m, "DeletionPolicy", id, deletionPolicies); err != nil { + return r, err + } + + r.UpdateReplacePolicy, err = policyField(m, "UpdateReplacePolicy", id, replacePolicies) return r, err } +// policyField reads a resource's DeletionPolicy or UpdateReplacePolicy, +// which must be a plain string. +func policyField(m map[string]any, attr, id string, allowed []string) (string, error) { + switch v := m[attr].(type) { + case nil: + return "", nil + case string: + return v, checkPolicy(attr, v, id, allowed) + default: + return "", formatErr("Every %s member must be a string.", attr) + } +} + func buildOutput(m map[string]any, path string) (OutputDef, error) { o := OutputDef{Value: m["Value"]} diff --git a/services/cloudformation/template.go b/services/cloudformation/template.go index 0123b4ddb..9cb2f010c 100644 --- a/services/cloudformation/template.go +++ b/services/cloudformation/template.go @@ -46,6 +46,11 @@ type ResourceDef struct { DependsOn any // Condition names the condition that decides whether the resource exists. Condition string + // DeletionPolicy decides what happens to the resource when it is + // deleted from the stack. UpdateReplacePolicy decides what happens to + // the old resource when it is replaced. "" means Delete. + DeletionPolicy string + UpdateReplacePolicy string } // OutputDef is one output declaration. diff --git a/services/cloudformation/types.go b/services/cloudformation/types.go index 51e8af866..355328f60 100644 --- a/services/cloudformation/types.go +++ b/services/cloudformation/types.go @@ -41,6 +41,13 @@ const ( ResourceDeleteInProgress = "DELETE_IN_PROGRESS" ResourceDeleteComplete = "DELETE_COMPLETE" ResourceDeleteFailed = "DELETE_FAILED" + ResourceDeleteSkipped = "DELETE_SKIPPED" +) + +// DeleteStack DeletionMode values. +const ( + DeletionModeStandard = "STANDARD" + DeletionModeForceDelete = "FORCE_DELETE_STACK" ) // Parameter is a name/value pair supplied to (or resolved for) a stack. @@ -116,6 +123,13 @@ type Stack struct { // DisableRollback records that a failed operation leaves the stack as it // is instead of rolling it back. DisableRollback bool + // EnableTerminationProtection blocks DeleteStack while it is set. + EnableTerminationProtection bool + // RetainExceptOnCreate has the rollback of the last operation delete the + // resources it created, even those whose DeletionPolicy is Retain. + RetainExceptOnCreate bool + // DeletionMode is the mode of the last DeleteStack call. + DeletionMode string } // StackSummary is the condensed stack view ListStacks returns. @@ -141,6 +155,73 @@ type CreateStackInput struct { Capabilities []string NotificationARNs []string + // OnFailure is ROLLBACK (the default), DO_NOTHING or DELETE. It cannot + // be combined with DisableRollback, which means DO_NOTHING. + OnFailure string + DisableRollback bool + // EnableTerminationProtection protects the new stack from DeleteStack. + EnableTerminationProtection bool + // RetainExceptOnCreate deletes the created resources on a rollback, + // even those whose DeletionPolicy is Retain. + RetainExceptOnCreate bool +} + +// DeleteStackInput is the request to delete a stack. +type DeleteStackInput struct { + StackName string + // RetainResources names resources to leave in place. It is valid only + // for a stack in DELETE_FAILED. + RetainResources []string + // DeletionMode is STANDARD (the default) or FORCE_DELETE_STACK, which + // deletes a DELETE_FAILED stack and keeps the resources it cannot + // delete. + DeletionMode string +} + +// UpdateTerminationProtectionInput turns a stack's termination protection +// on or off. +type UpdateTerminationProtectionInput struct { + StackName string + Enable bool +} + +// Export is one exported output value. +type Export struct { + ExportingStackID string + Name string + Value string +} + +// ExportList is one page of ListExports. +type ExportList struct { + Exports []Export + NextToken string +} + +// ListImportsInput names the export whose importing stacks to list. +type ListImportsInput struct { + ExportName string + NextToken string +} + +// ImportList is one page of ListImports: the names of the stacks that +// import the export. +type ImportList struct { + Imports []string + NextToken string +} + +// AccountLimit is one CloudFormation quota of the account. +type AccountLimit struct { + Name string + Value int +} + +// EstimateTemplateCostInput names the template to price. +type EstimateTemplateCostInput struct { + TemplateBody string + TemplateURL string + Parameters []Parameter } // UpdateStackInput is the request to update an existing stack. @@ -156,6 +237,9 @@ type UpdateStackInput struct { // DisableRollback leaves a failed update UPDATE_FAILED instead of // rolling it back. DisableRollback bool + // RetainExceptOnCreate deletes the resources the update created when it + // rolls back, even those whose DeletionPolicy is Retain. + RetainExceptOnCreate bool // NotificationARNs replaces the stack's topics. Nil keeps them. NotificationARNs []string @@ -213,7 +297,7 @@ type API interface { CreateStack(ctx context.Context, in *CreateStackInput) (*Stack, error) UpdateStack(ctx context.Context, in *UpdateStackInput) (*Stack, error) ContinueUpdateRollback(ctx context.Context, in *ContinueUpdateRollbackInput) error - DeleteStack(ctx context.Context, stackName string) error + DeleteStack(ctx context.Context, in *DeleteStackInput) error DescribeStacks(ctx context.Context, stackName string) ([]Stack, error) DescribeStackEvents(ctx context.Context, stackName string) ([]StackEvent, error) ListStacks(ctx context.Context, statusFilter []string) ([]StackSummary, error) @@ -228,4 +312,10 @@ type API interface { ListChangeSets(ctx context.Context, in *ListChangeSetsInput) (*ChangeSetList, error) ExecuteChangeSet(ctx context.Context, in *ExecuteChangeSetInput) error DeleteChangeSet(ctx context.Context, in *DeleteChangeSetInput) error + + ListExports(ctx context.Context, nextToken string) (*ExportList, error) + ListImports(ctx context.Context, in *ListImportsInput) (*ImportList, error) + UpdateTerminationProtection(ctx context.Context, in *UpdateTerminationProtectionInput) (string, error) + DescribeAccountLimits(ctx context.Context, nextToken string) ([]AccountLimit, error) + EstimateTemplateCost(ctx context.Context, in *EstimateTemplateCostInput) (string, error) } diff --git a/services/cloudformation/validate.go b/services/cloudformation/validate.go index 19a7e3b7b..228c58856 100644 --- a/services/cloudformation/validate.go +++ b/services/cloudformation/validate.go @@ -22,6 +22,14 @@ func validate(t *Template) error { return err } + if len(t.Outputs) > MaxOutputs { + return formatErr("Outputs count %d is greater than max allowed %d", len(t.Outputs), MaxOutputs) + } + + if err := checkImportValues(t); err != nil { + return err + } + return checkReferences(t) }