diff --git a/compat/aws/cloudformation_compat_test.go b/compat/aws/cloudformation_compat_test.go index 8d66f4024..55b608b0a 100644 --- a/compat/aws/cloudformation_compat_test.go +++ b/compat/aws/cloudformation_compat_test.go @@ -2,11 +2,13 @@ package aws import ( "context" + "errors" "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" + "github.com/aws/smithy-go" cloudemu "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/internal/compat" @@ -116,6 +118,7 @@ func TestAWSCloudFormationCompat(t *testing.T) { return continueUpdateRollback(ctx, client) }) + stackControlOps(ctx, sess, client, stack) changeSetOps(ctx, sess, client) exportOps(ctx, sess, client) @@ -125,6 +128,88 @@ func TestAWSCloudFormationCompat(t *testing.T) { }) } +// stackControlOps sets and reads a stack policy, checks CancelUpdateStack +// refuses a stack that is not updating, and rolls an UPDATE_FAILED stack +// back. +func stackControlOps(ctx context.Context, sess *compat.AWSSession, client *awscfn.Client, stack string) { + const ( + svc = "cloudformation" + policy = `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}` + ) + + sess.Op(svc, "SetStackPolicy", func() error { + _, err := client.SetStackPolicy(ctx, &awscfn.SetStackPolicyInput{ + StackName: aws.String(stack), StackPolicyBody: aws.String(policy), + }) + + return err + }) + + sess.Op(svc, "GetStackPolicy", func() error { + out, err := client.GetStackPolicy(ctx, &awscfn.GetStackPolicyInput{StackName: aws.String(stack)}) + if err != nil { + return err + } + + if aws.ToString(out.StackPolicyBody) != policy { + return errCompat("policy not returned") + } + + return nil + }) + + sess.Op(svc, "CancelUpdateStack", func() error { + _, err := client.CancelUpdateStack(ctx, &awscfn.CancelUpdateStackInput{StackName: aws.String(stack)}) + + var ae smithy.APIError + if !errors.As(err, &ae) || ae.ErrorCode() != "ValidationError" { + return errCompat("CancelUpdateStack on a finished update must be a ValidationError") + } + + return nil + }) + + sess.Op(svc, "RollbackStack", func() error { + return rollbackStack(ctx, client) + }) +} + +// rollbackStack leaves a stack UPDATE_FAILED with rollback disabled, then +// rolls it back. +func rollbackStack(ctx context.Context, client *awscfn.Client) error { + const name = "compat-rollback-stack" + + if _, err := client.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String(name), TemplateBody: aws.String(changeSetTemplate), + }); err != nil { + return err + } + + if _, err := client.UpdateStack(ctx, &awscfn.UpdateStackInput{ + StackName: aws.String(name), DisableRollback: aws.Bool(true), + TemplateBody: aws.String(`{"Resources":{"Bad":{"Type":"AWS::Unknown::Thing"}}}`), + }); err != nil { + return err + } + + if _, err := client.RollbackStack(ctx, &awscfn.RollbackStackInput{StackName: aws.String(name)}); err != nil { + return err + } + + out, err := client.DescribeStacks(ctx, &awscfn.DescribeStacksInput{StackName: aws.String(name)}) + if err != nil { + return err + } + + if out.Stacks[0].StackStatus != cfntypes.StackStatusUpdateRollbackComplete { + return errCompat("status " + string(out.Stacks[0].StackStatus)) + } + + _, err = client.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String(name)}) + + return err +} + // changeSetOps creates a stack from a CREATE change set, then creates and // deletes an UPDATE change set on it. func changeSetOps(ctx context.Context, sess *compat.AWSSession, client *awscfn.Client) { diff --git a/docs/compat/README.md b/docs/compat/README.md index 900333117..d64d0744e 100644 --- a/docs/compat/README.md +++ b/docs/compat/README.md @@ -37,6 +37,7 @@ Each service's summary breaks the verified counts out per SDK/language. Only lan | Operation | AWS (CloudFormation) | |---|---| +| CancelUpdateStack | ✅ | | ContinueUpdateRollback | ✅ | | CreateChangeSet | ✅ | | CreateStack | ✅ | @@ -49,6 +50,7 @@ Each service's summary breaks the verified counts out per SDK/language. Only lan | DescribeStacks | ✅ | | EstimateTemplateCost | ✅ | | ExecuteChangeSet | ✅ | +| GetStackPolicy | ✅ | | GetTemplate | ✅ | | GetTemplateSummary | ✅ | | ListChangeSets | ✅ | @@ -56,12 +58,14 @@ Each service's summary breaks the verified counts out per SDK/language. Only lan | ListImports | ✅ | | ListStackResources | ✅ | | ListStacks | ✅ | +| RollbackStack | ✅ | +| SetStackPolicy | ✅ | | UpdateStack | ✅ | | UpdateTerminationProtection | ✅ | | ValidateTemplate | ✅ | **cloudformation verified per language:** -- Go: AWS 22/22 +- Go: AWS 26/26 ## compute diff --git a/docs/compat/compat.json b/docs/compat/compat.json index 17dbaec85..945f02de5 100644 --- a/docs/compat/compat.json +++ b/docs/compat/compat.json @@ -316,6 +316,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "CancelUpdateStack", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "ContinueUpdateRollback", @@ -472,6 +485,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "GetStackPolicy", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "GetTemplate", @@ -563,6 +589,32 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "RollbackStack", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, + { + "service": "cloudformation", + "operation": "SetStackPolicy", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "UpdateStack", diff --git a/docs/coverage/README.md b/docs/coverage/README.md index d9b11ff81..654f50b0f 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) | - | - | - | 22 | +| `cloudformation` | [CloudFormation](./aws/cloudformation.md) | - | - | - | 26 | | `cloudfront` | [CloudFront](./aws/cloudfront.md) | - | - | - | 11 | | `cloudids` | - | - | [CloudIDS](./gcp/cloudids.md) | - | 6 | | `cloudrun` | - | - | [CloudRun](./gcp/cloudrun.md) | - | 18 | @@ -89,7 +89,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `filestore` | - | - | [Filestore](./gcp/filestore.md) | - | 5 | | `fis` | [FIS](./aws/fis.md) | - | - | - | 12 | | `frontdoor` | - | [FrontDoor](./azure/frontdoor.md) | - | - | 12 | -| `gke` | - | - | [GKE](./gcp/gke.md) | - | 18 | +| `gke` | - | - | [GKE](./gcp/gke.md) | - | 28 | | `gkebackup` | - | - | [GKEBackup](./gcp/gkebackup.md) | - | 11 | | `gkehub` | - | - | [GKEHub](./gcp/gkehub.md) | - | 16 | | `globalaccelerator` | [GlobalAccelerator](./aws/globalaccelerator.md) | - | - | - | 20 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index ca4a62e78..2fe688a4e 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) | 22 | +| [CloudFormation](./cloudformation.md) | (provider-native) | 26 | | [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 98faaaeb5..2dc3da3cd 100644 --- a/docs/coverage/aws/cloudformation.md +++ b/docs/coverage/aws/cloudformation.md @@ -3,10 +3,11 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver · [AWS index](./README.md) -## Operations (22) +## Operations (26) | Operation | Description | | --- | --- | +| `CancelUpdateStack` | | | `ContinueUpdateRollback` | | | `CreateChangeSet` | | | `CreateStack` | | @@ -19,6 +20,7 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver | `DescribeStacks` | | | `EstimateTemplateCost` | | | `ExecuteChangeSet` | | +| `GetStackPolicy` | | | `GetTemplate` | | | `GetTemplateSummary` | | | `ListChangeSets` | | @@ -26,6 +28,8 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver | `ListImports` | | | `ListStackResources` | | | `ListStacks` | | +| `RollbackStack` | | +| `SetStackPolicy` | | | `UpdateStack` | | | `UpdateTerminationProtection` | | | `ValidateTemplate` | | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 877eb82e7..f38293e41 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -3158,6 +3158,9 @@ "service": "cloudformation", "interface": "provider-native", "operations": [ + { + "name": "CancelUpdateStack" + }, { "name": "ContinueUpdateRollback" }, @@ -3194,6 +3197,9 @@ { "name": "ExecuteChangeSet" }, + { + "name": "GetStackPolicy" + }, { "name": "GetTemplate" }, @@ -3215,6 +3221,12 @@ { "name": "ListStacks" }, + { + "name": "RollbackStack" + }, + { + "name": "SetStackPolicy" + }, { "name": "UpdateStack" }, @@ -6887,6 +6899,36 @@ { "name": "RollbackNodePool" }, + { + "name": "SetClusterLogging" + }, + { + "name": "SetClusterMonitoring" + }, + { + "name": "SetLegacyAbac" + }, + { + "name": "SetMaintenancePolicy" + }, + { + "name": "SetMasterAuth" + }, + { + "name": "SetNetworkPolicy" + }, + { + "name": "SetNodePoolAutoscaling" + }, + { + "name": "SetNodePoolManagement" + }, + { + "name": "SetNodePoolSize" + }, + { + "name": "SetResourceLabels" + }, { "name": "StartIPRotation" }, diff --git a/docs/coverage/gcp/README.md b/docs/coverage/gcp/README.md index cfcdbcbc9..9037b578a 100644 --- a/docs/coverage/gcp/README.md +++ b/docs/coverage/gcp/README.md @@ -37,7 +37,7 @@ Services cloudemu emulates for GCP, by native name. Back to the [cross-provider | [Firestore](./firestore.md) | `database` | 24 | | [GCE](./gce.md) | `compute` | 37 | | [GCS](./gcs.md) | `storage` | 35 | -| [GKE](./gke.md) | (provider-native) | 18 | +| [GKE](./gke.md) | (provider-native) | 28 | | [GKEBackup](./gkebackup.md) | `gkebackup` | 11 | | [GKEHub](./gkehub.md) | `gkehub` | 16 | | [IAM](./iam.md) | `iam` | 40 | diff --git a/docs/coverage/gcp/gke.md b/docs/coverage/gcp/gke.md index 559101ca9..9dc71c29d 100644 --- a/docs/coverage/gcp/gke.md +++ b/docs/coverage/gcp/gke.md @@ -3,7 +3,7 @@ provider-native `gke` wire service (GCP-only) · no portable driver · [GCP index](./README.md) -## Operations (18) +## Operations (28) | Operation | Description | | --- | --- | @@ -22,6 +22,16 @@ provider-native `gke` wire service (GCP-only) · no portable driver · [GCP inde | `ListNodePools` | | | `ListOperations` | | | `RollbackNodePool` | | +| `SetClusterLogging` | | +| `SetClusterMonitoring` | | +| `SetLegacyAbac` | | +| `SetMaintenancePolicy` | | +| `SetMasterAuth` | | +| `SetNetworkPolicy` | | +| `SetNodePoolAutoscaling` | | +| `SetNodePoolManagement` | | +| `SetNodePoolSize` | | +| `SetResourceLabels` | | | `StartIPRotation` | | | `UpdateCluster` | | | `UpdateNodePool` | | diff --git a/internal/coveragegen/native.go b/internal/coveragegen/native.go index 2a84e9779..b39646cbc 100644 --- a/internal/coveragegen/native.go +++ b/internal/coveragegen/native.go @@ -303,11 +303,12 @@ func nativeOperations(mockDir string, services map[string]*Service) []Operation } methods := mockMethods(mockDir) + apiSetters := contextSetters(mockDir) ops := make([]Operation, 0, len(methods)) for name := range methods { - if isNativeOperation(name) { + if isNativeOperation(name, apiSetters[name]) { ops = append(ops, Operation{Name: name}) } } @@ -317,17 +318,59 @@ func nativeOperations(mockDir string, services map[string]*Service) []Operation return ops } -func isNativeOperation(name string) bool { +// isNativeOperation reports whether a mock method is an operation. Snapshot, +// Restore and Tick are persistence and time plumbing. A Set* method is a +// wiring setter unless it takes a context, as an API call such as +// SetStackPolicy does. +func isNativeOperation(name string, takesContext bool) bool { if !ast.IsExported(name) { return false } switch name { - case "Snapshot", "Restore": + case "Snapshot", "Restore", "Tick": return false } - return !strings.HasPrefix(name, "Set") + return takesContext || !strings.HasPrefix(name, "Set") +} + +// contextSetters returns the Set* methods in mockDir whose first parameter +// is a context.Context. +func contextSetters(mockDir string) map[string]bool { + fset := token.NewFileSet() + + //nolint:staticcheck // ParseDir is adequate here; build-tag precision is unneeded for docs generation. + pkgs, err := parser.ParseDir(fset, mockDir, notTest, 0) + if err != nil { + return nil + } + + out := map[string]bool{} + + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil && strings.HasPrefix(fn.Name.Name, "Set") && + firstParamIsContext(fn) { + out[fn.Name.Name] = true + } + } + } + } + + return out +} + +func firstParamIsContext(fn *ast.FuncDecl) bool { + params := fn.Type.Params.List + if len(params) == 0 { + return false + } + + sel, ok := params[0].Type.(*ast.SelectorExpr) + + return ok && sel.Sel.Name == "Context" } // displayName is the fallback native display for a handler package with no diff --git a/internal/settle/settle.go b/internal/settle/settle.go index d2c95f671..ae567735d 100644 --- a/internal/settle/settle.go +++ b/internal/settle/settle.go @@ -50,6 +50,8 @@ const ( DefaultTargetHealthSettle = 2 * time.Second // ELBv2 target initial->healthy DefaultTargetDrainSettle = 2 * time.Second // ELBv2 target draining->removed + + DefaultStackSettle = 5 * time.Second // CloudFormation stack *_IN_PROGRESS->*_COMPLETE ) // Window is a read-time overlay describing a resource still settling into its diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 6f4568be2..eb7d9a848 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -600,7 +600,7 @@ func awsDrivers(p *Provider) *resourcediscovery.Drivers { // background ticker calls each one on its interval. New time-driven services // register here. func (p *Provider) Tickables() []config.Tickable { - return []config.Tickable{p.CloudWatch, p.SSM} + return []config.Tickable{p.CloudWatch, p.SSM, p.CloudFormation} } // Close tears down any real engines wired into the provider via diff --git a/providers/aws/cloudformation/async.go b/providers/aws/cloudformation/async.go new file mode 100644 index 000000000..a3346537f --- /dev/null +++ b/providers/aws/cloudformation/async.go @@ -0,0 +1,382 @@ +package cloudformation + +import ( + "context" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// stackEventStep is the spacing of the events of an operation that runs +// under AsyncSettle, so a client polling DescribeStackEvents sees them +// arrive one after another. +const stackEventStep = 250 * time.Millisecond + +// Kinds of the last phase of a stack operation. +const ( + opCreate = "create" + opUpdate = "update" + opRollback = "rollback" + opRollbackCreate = "rollbackCreate" + opContinue = "continueRollback" + opDelete = "delete" +) + +// Reasons and error texts of CancelUpdateStack. The reasons keep the AWS +// spelling. +const ( + reasonUpdateCancelled = "Stack update cancelled" //nolint:misspell // the AWS status reason + reasonResourceCancelled = "Resource update cancelled" //nolint:misspell // the AWS status reason + reasonCreateCancelled = "Resource creation cancelled" //nolint:misspell // the AWS status reason + msgCancelBadStatus = "CancelUpdateStack cannot be called from current stack status" + msgDeleteInProgress = "Stack [%s] cannot be deleted while in status %s" +) + +// pendingOp is the last phase of a stack operation. Without AsyncSettle it +// runs as soon as the operation has done its work. Under AsyncSettle it +// waits until ReadyAt, and the stack stays in its *_IN_PROGRESS status +// until then. It is stored with the stack, so a snapshot taken while an +// operation runs can finish it after a restore. +type pendingOp struct { + Kind string `json:"kind"` + ReadyAt time.Time `json:"readyAt"` + // OnFailure is ROLLBACK, DO_NOTHING or DELETE. + OnFailure string `json:"onFailure,omitempty"` + // Failure is the status reason of a failed operation, "" when it + // succeeded. A rollback keeps it as the stack's reason. + Failure string `json:"failure,omitempty"` + ChangeSetID string `json:"changeSetId,omitempty"` + // Prior is the stack before the update, which a rollback restores. + Prior *storedPrior `json:"prior,omitempty"` + // Replaced are the old resources of the update's replacements. + Replaced []retainedResource `json:"replaced,omitempty"` + // Imports are the exports the updated template imports. + Imports []string `json:"imports,omitempty"` + // HeldCleanup marks an update whose cleanup phase has not run. + HeldCleanup bool `json:"heldCleanup,omitempty"` + // Skip names the resources ContinueUpdateRollback leaves as they are. + Skip []string `json:"skip,omitempty"` + // Retain and Force are DeleteStack's RetainResources and whether a + // resource that fails to delete is kept instead. + Retain []string `json:"retain,omitempty"` + Force bool `json:"force,omitempty"` + + // plan is the update's plan. It is lost in a snapshot, and a rollback + // then prepares the previous template again from Prior. + plan *updatePlan +} + +// storedPrior is priorState in a form a snapshot can hold. +type storedPrior struct { + TemplateBody string `json:"templateBody"` + Params []cfn.Parameter `json:"params,omitempty"` + Description string `json:"description,omitempty"` + Outputs []cfn.Output `json:"outputs,omitempty"` + NotificationARNs []string `json:"notificationArns,omitempty"` +} + +func storePrior(p *priorState) *storedPrior { + return &storedPrior{ + TemplateBody: p.templateBody, Params: p.params, Description: p.description, + Outputs: p.outputs, NotificationARNs: p.notificationARNs, + } +} + +func (s *storedPrior) state() priorState { + return priorState{ + templateBody: s.TemplateBody, params: s.Params, description: s.Description, + outputs: s.Outputs, notificationARNs: s.NotificationARNs, + } +} + +// toRetained stores replacements in their snapshot form. +func toRetained(replaced []replacement) []retainedResource { + out := make([]retainedResource, 0, len(replaced)) + + for i := range replaced { + r := &replaced[i] + out = append(out, retainedResource{ + LogicalID: r.id, Type: r.old.typ, Resolved: r.old.resolved, Props: r.old.props, + DeleteID: r.old.deleteID, ReplacePolicy: r.policy, Reclaimed: r.reclaimed, + }) + } + + return out +} + +func replacements(stored []retainedResource) []replacement { + out := make([]replacement, 0, len(stored)) + for i := range stored { + out = append(out, stored[i].replacement()) + } + + return out +} + +// finish runs the last phase of an operation, at once or, under +// AsyncSettle, once the settle window and the operation's events have +// passed. +func (m *Mock) finish(ctx context.Context, sd *stackData, op *pendingOp) { + if m.settleWindow <= 0 { + m.complete(ctx, sd, op) + return + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + ready := m.clock.Now().Add(m.settleWindow) + if sd.cursor.After(ready) { + ready = sd.cursor + } + + op.ReadyAt = ready + sd.pending = op + sd.cursor = time.Time{} +} + +func (m *Mock) complete(ctx context.Context, sd *stackData, op *pendingOp) { + switch op.Kind { + case opCreate: + m.completeCreate(ctx, sd, op) + case opUpdate: + m.completeUpdate(ctx, sd, op) + case opRollback: + m.doRollback(ctx, sd, op) + case opRollbackCreate: + m.completeRollbackCreate(ctx, sd) + case opContinue: + m.completeContinue(ctx, sd, op) + case opDelete: + m.finishDelete(ctx, sd, teardownOpts{retain: nameSet(op.Retain), force: op.Force}) + } +} + +func nameSet(names []string) map[string]bool { + out := make(map[string]bool, len(names)) + for _, n := range names { + out[n] = true + } + + return out +} + +// settle finishes the operations whose settle window has passed. Every API +// call settles first, so reads never need the serve ticker. +func (m *Mock) settle(ctx context.Context) { + m.settleAt(ctx, m.clock.Now()) +} + +// Tick finishes the stack operations due at now. The serve background +// ticker calls it, so an operation's last phase, such as deleting the +// resources of a deleted stack, runs even when no one reads the stack. It +// reports whether any operation finished. +func (m *Mock) Tick(now time.Time) bool { + return m.settleAt(context.Background(), now) +} + +func (m *Mock) settleAt(ctx context.Context, now time.Time) bool { + changed := false + + for _, sd := range m.sortedStacks() { + if op := sd.takeDue(now); op != nil { + m.complete(ctx, sd, op) + + changed = true + } + } + + return changed +} + +// takeDue removes and returns the stack's pending phase once it is due. +func (sd *stackData) takeDue(now time.Time) *pendingOp { + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.pending == nil || now.Before(sd.pending.ReadyAt) { + return nil + } + + op := sd.pending + sd.pending = nil + + return op +} + +// startCursor makes the events of the operation that starts arrive one +// after another under AsyncSettle. The caller holds sd.mu. +func (m *Mock) startCursor(sd *stackData) { + if m.settleWindow > 0 { + sd.cursor = m.clock.Now() + } +} + +// eventTime is the time the next event of the stack is stamped with. The +// caller holds sd.mu. +func (m *Mock) eventTime(sd *stackData) time.Time { + now := m.clock.Now() + if sd.cursor.IsZero() { + return now + } + + t := sd.cursor + if now.After(t) { + t = now + } + + sd.cursor = t.Add(stackEventStep) + + return t +} + +// visibleResources returns the stack's resources as the events seen by now +// report them. A resource whose events have not all arrived shows the +// status of its newest arrived event, and one with no arrived event is not +// listed yet. +func (sd *stackData) visibleResources(now time.Time) []cfn.StackResource { + sd.mu.RLock() + defer sd.mu.RUnlock() + + latest := map[string]*cfn.StackEvent{} + waiting := map[string]bool{} + + for i := range sd.stack.Events { + e := &sd.stack.Events[i] + if e.ResourceType == stackResourceType && e.LogicalID == sd.stack.Name { + continue + } + + if e.Timestamp.After(now) { + waiting[e.LogicalID] = true + } else { + latest[e.LogicalID] = e + } + } + + out := make([]cfn.StackResource, 0, len(sd.stack.Resources)) + + for _, r := range sd.stack.Resources { + if waiting[r.LogicalID] { + e := latest[r.LogicalID] + if e == nil { + continue + } + + r.Status, r.StatusReason, r.Timestamp = e.Status, e.StatusReason, e.Timestamp + if e.PhysicalID != "" { + r.PhysicalID = e.PhysicalID + } + } + + out = append(out, r) + } + + return out +} + +// CancelUpdateStack stops an update that is still UPDATE_IN_PROGRESS and +// rolls it back, ending UPDATE_ROLLBACK_COMPLETE. Only an update running +// under AsyncSettle can be caught in progress. Otherwise the update has +// already finished, and the call is a ValidationError as in AWS. +func (m *Mock) CancelUpdateStack(ctx context.Context, in *cfn.CancelUpdateStackInput) error { + m.settle(ctx) + + sd, err := m.activeStack(in.StackName) + if err != nil { + return err + } + + retry, err := sd.checkToken(in.ClientRequestToken, actionCancelUpdateStack) + if err != nil || retry { + return err + } + + op := m.takeCancellable(sd, in.ClientRequestToken) + if op == nil { + return cerrors.New(cerrors.InvalidArgument, msgCancelBadStatus) + } + + m.markCancelled(sd) + sd.finishChangeSet(op.ChangeSetID, false) + m.startRollback(ctx, sd, op, reasonUpdateCancelled) + + return nil +} + +// takeCancellable takes the pending phase of an update in progress. The +// update's events that have not arrived yet are dropped: the cancel stops +// the update before them, and the rollback undoes what it did. +func (m *Mock) takeCancellable(sd *stackData, token string) *pendingOp { + sd.mu.Lock() + defer sd.mu.Unlock() + + op := sd.pending + if op == nil || op.Kind != opUpdate || sd.stack.Status != cfn.StatusUpdateInProgress { + return nil + } + + sd.pending = nil + sd.recordToken(token, actionCancelUpdateStack) + + now := m.clock.Now() + kept := sd.stack.Events[:0] + + for i := range sd.stack.Events { + if !sd.stack.Events[i].Timestamp.After(now) { + kept = append(kept, sd.stack.Events[i]) + } + } + + sd.stack.Events = kept + + return op +} + +// markCancelled fails the resources a cancel caught in progress. +func (m *Mock) markCancelled(sd *stackData) { + sd.mu.RLock() + + var order []string + + last := map[string]cfn.StackEvent{} + + for i := range sd.stack.Events { + e := &sd.stack.Events[i] + if e.ResourceType == stackResourceType && e.LogicalID == sd.stack.Name { + continue + } + + if _, seen := last[e.LogicalID]; !seen { + order = append(order, e.LogicalID) + } + + last[e.LogicalID] = *e + } + sd.mu.RUnlock() + + for _, id := range order { + e := last[id] + + switch e.Status { + case cfn.ResourceCreateInProgress: + m.emitResourceEvent(sd, id, e.PhysicalID, e.ResourceType, cfn.ResourceCreateFailed, reasonCreateCancelled) + case cfn.ResourceUpdateInProgress: + m.emitResourceEvent(sd, id, e.PhysicalID, e.ResourceType, cfn.ResourceUpdateFailed, reasonResourceCancelled) + } + } +} + +// checkNotBusy refuses to delete a stack while one of its operations is +// still running under AsyncSettle. +func checkNotBusy(sd *stackData) error { + sd.mu.RLock() + defer sd.mu.RUnlock() + + if sd.pending != nil { + return cerrors.Newf(cerrors.InvalidArgument, msgDeleteInProgress, sd.stack.Name, sd.stack.Status) + } + + return nil +} diff --git a/providers/aws/cloudformation/async_stack_test.go b/providers/aws/cloudformation/async_stack_test.go new file mode 100644 index 000000000..da343dc57 --- /dev/null +++ b/providers/aws/cloudformation/async_stack_test.go @@ -0,0 +1,262 @@ +package cloudformation + +import ( + "context" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// settled is a clock step past any stack settle window. +const settled = time.Minute + +// newAsyncParamMock is newParamMock with AsyncSettle on, and its clock. +func newAsyncParamMock(p paramProv) (*Mock, *config.FakeClock) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + m := New(config.NewOptions(config.WithClock(fc), config.WithRegion("us-east-1"), + config.WithAccountID("123456789012"), config.WithAsyncSettle())) + m.SetRegistry(cfn.Registry{"Test::Param": p, "Test::Boom": failProv{}}) + + return m, fc +} + +func visibleEvents(t *testing.T, m *Mock, stack string) int { + t.Helper() + + events, err := m.DescribeStackEvents(context.Background(), stack) + requireNoError(t, err) + + return len(events) +} + +func TestAsyncCreateStaysInProgressUntilSettled(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + st, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusCreateInProgress, "returned status") + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusCreateInProgress, "described status") + assertEqual(t, visibleEvents(t, m, "s"), 1, "only the stack event is visible at once") + + summaries, err := m.ListStacks(ctx, nil) + requireNoError(t, err) + assertEqual(t, summaries[0].Status, cfn.StatusCreateInProgress, "listed status") + + fc.Advance(stackEventStep) + assertEqual(t, visibleEvents(t, m, "s"), 2, "events arrive one by one") + + fc.Advance(settled) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusCreateComplete, "settled status") + assertEqual(t, eventStatuses(t, m, "s", "Old")[1], cfn.ResourceCreateComplete, "resource events") + assertEqual(t, p.values["/p"], "v1", "resource created") +} + +func TestAsyncStackRowsFollowVisibleEvents(t *testing.T) { + m, fc := newAsyncParamMock(newParamProv()) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + + rows, err := m.DescribeStackResources(ctx, "s") + requireNoError(t, err) + assertEqual(t, len(rows), 0, "no resource is visible before its first event") + + fc.Advance(stackEventStep) + + rows, err = m.DescribeStackResources(ctx, "s") + requireNoError(t, err) + assertEqual(t, len(rows), 1, "one row") + assertEqual(t, rows[0].Status, cfn.ResourceCreateInProgress, "row shows the visible status") + + fc.Advance(settled) + + rows, err = m.DescribeStackResources(ctx, "s") + requireNoError(t, err) + assertEqual(t, rows[0].Status, cfn.ResourceCreateComplete, "settled row") +} + +func TestAsyncUpdateBlocksAnotherUpdate(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + fc.Advance(settled) + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateInProgress, "update in progress") + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramV1}) + assertValidation(t, err, "") + + fc.Advance(settled) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "settled") + assertEqual(t, p.values["/p"], "v2", "updated") +} + +func TestCancelUpdateStackRollsBackAReplacement(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + fc.Advance(settled) + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + + requireNoError(t, m.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "s", ClientRequestToken: "cancel-1"})) + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusUpdateRollbackInProgress, "rolling back") + assertEqual(t, st.StatusReason, reasonUpdateCancelled, "reason") + + events, err := m.DescribeStackEvents(ctx, "s") + requireNoError(t, err) + assertEqual(t, events[0].ClientRequestToken, "cancel-1", "cancel events carry the token") + + fc.Advance(settled) + + st = stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "rolled back") + assertEqual(t, p.values["/p"], "v1", "the old resource is intact") + + if _, ok := p.values["/p2"]; ok { + t.Fatal("the replacement must be deleted by the rollback") + } + + body, err := m.GetTemplate(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, paramV1, "template reverted") +} + +func TestCancelUpdateStackOutsideAnUpdate(t *testing.T) { + ctx := context.Background() + + sync := newParamMock(newParamProv()) + _, err := sync.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + _, err = sync.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertValidation(t, sync.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "s"}), msgCancelBadStatus) + assertValidation(t, sync.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "nope"}), "") + + async, fc := newAsyncParamMock(newParamProv()) + _, err = async.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertValidation(t, async.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "s"}), msgCancelBadStatus) + + fc.Advance(settled) + _, err = async.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + fc.Advance(settled) + assertValidation(t, async.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "s"}), msgCancelBadStatus) +} + +func TestAsyncDeniedUpdateRollsBackAfterSettle(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1, StackPolicyBody: denyReplaceOld}) + requireNoError(t, err) + fc.Advance(settled) + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateInProgress, "in progress") + + fc.Advance(settled) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackInProgress, "rolling back") + + fc.Advance(settled) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackComplete, "rolled back") + assertEqual(t, p.values["/p"], "v1", "intact") +} + +func TestAsyncDeleteSettles(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + fc.Advance(settled) + + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s"})) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusDeleteInProgress, "deleting") + + // The serve ticker completes the delete without any read. + if !m.Tick(fc.Now().Add(settled)) { + t.Fatal("Tick should settle the delete") + } + + fc.Advance(settled) + + if _, err = m.DescribeStacks(ctx, "s"); err == nil { + t.Fatal("a deleted stack is not described by name") + } + + if _, ok := p.values["/p"]; ok { + t.Fatal("resource should be deleted") + } + + if m.Tick(fc.Now()) { + t.Fatal("nothing left to settle") + } +} + +func TestAsyncPendingUpdateSurvivesSnapshot(t *testing.T) { + p := newParamProv() + m, fc := newAsyncParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + fc.Advance(settled) + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + + data, err := m.Snapshot(ctx, false) + requireNoError(t, err) + + restored, rfc := newAsyncParamMock(p) + rfc.Set(fc.Now()) + requireNoError(t, restored.Restore(ctx, data)) + assertEqual(t, stackStatus(t, restored, "s").Status, cfn.StatusUpdateInProgress, "still in progress") + + requireNoError(t, restored.CancelUpdateStack(ctx, &cfn.CancelUpdateStackInput{StackName: "s"})) + rfc.Advance(settled) + assertEqual(t, stackStatus(t, restored, "s").Status, cfn.StatusUpdateRollbackComplete, "rolled back after restore") + assertEqual(t, p.values["/p"], "v1", "old resource intact") + + if _, ok := p.values["/p2"]; ok { + t.Fatal("replacement deleted") + } +} + +func TestSyncStacksStaySynchronous(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + st, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusCreateComplete, "create") + + st, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "update") + + if m.Tick(time.Now()) { + t.Fatal("a synchronous mock has nothing to settle") + } +} diff --git a/providers/aws/cloudformation/changeset.go b/providers/aws/cloudformation/changeset.go index 39e59041d..1e84ee9be 100644 --- a/providers/aws/cloudformation/changeset.go +++ b/providers/aws/cloudformation/changeset.go @@ -71,6 +71,8 @@ type changeSetRecord struct { // change nothing. A template or parameter error is returned and nothing is // stored. func (m *Mock) CreateChangeSet(ctx context.Context, in *cfn.CreateChangeSetInput) (*cfn.ChangeSet, error) { + m.settle(ctx) + if err := validateChangeSetInput(in); err != nil { return nil, err } @@ -493,7 +495,9 @@ func (sd *stackData) view(rec *changeSetRecord) cfn.ChangeSet { // DescribeChangeSet returns a change set and one page of its changes. // Property values are included only when IncludePropertyValues is set. -func (m *Mock) DescribeChangeSet(_ context.Context, in *cfn.DescribeChangeSetInput) (*cfn.ChangeSet, error) { +func (m *Mock) DescribeChangeSet(ctx context.Context, in *cfn.DescribeChangeSetInput) (*cfn.ChangeSet, error) { + m.settle(ctx) + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) if err != nil { return nil, err @@ -557,7 +561,9 @@ func pageChanges(changes []cfn.ResourceChange, token string) ([]cfn.ResourceChan // ListChangeSets returns one page of a stack's change sets in creation // order. -func (m *Mock) ListChangeSets(_ context.Context, in *cfn.ListChangeSetsInput) (*cfn.ChangeSetList, error) { +func (m *Mock) ListChangeSets(ctx context.Context, in *cfn.ListChangeSetsInput) (*cfn.ChangeSetList, error) { + m.settle(ctx) + sd, err := m.activeStack(in.StackName) if err != nil { return nil, err @@ -585,11 +591,17 @@ func (m *Mock) ListChangeSets(_ context.Context, in *cfn.ListChangeSetsInput) (* // sets are deleted as it starts. OnStackFailure, or DisableRollback when the // change set has none, decides what a failure does. func (m *Mock) ExecuteChangeSet(ctx context.Context, in *cfn.ExecuteChangeSetInput) error { + m.settle(ctx) + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) if err != nil { return err } + if _, terr := sd.checkToken(in.ClientRequestToken, actionExecuteChangeSet); terr != nil { + return terr + } + sd.mu.RLock() retried := in.ClientRequestToken != "" && rec.ExecuteToken == in.ClientRequestToken err = executable(rec) @@ -692,7 +704,7 @@ func (m *Mock) executeCreate(ctx context.Context, sd *stackData, run *execution) return err } - sd.finishExecute(rec, m.provision(ctx, sd, effective, res, run.onFailure)) + m.provision(ctx, sd, effective, res, &pendingOp{OnFailure: run.onFailure, ChangeSetID: rec.ChangeSet.ID}) return nil } @@ -715,7 +727,7 @@ func (m *Mock) executeUpdate(ctx context.Context, sd *stackData, run *execution) return berr } - sd.finishExecute(rec, m.runUpdate(ctx, sd, in, plan, run.onFailure)) + m.runUpdate(ctx, sd, in, plan, &pendingOp{OnFailure: run.onFailure, ChangeSetID: rec.ChangeSet.ID}) return nil } @@ -735,10 +747,13 @@ func (m *Mock) beginExecute( return err } - if !allowed(sd.stack.Status) { + if !allowed(sd.stack.Status) || sd.pending != nil { return cerrors.Newf(cerrors.InvalidArgument, msgStackCannotUpdate, sd.stack.ID, sd.stack.Status) } + sd.recordToken(run.token, actionExecuteChangeSet) + m.startCursor(sd) + rec.ChangeSet.ExecutionStatus = cfn.ExecutionInProgress rec.ExecuteToken = run.token sd.changeSets = []*changeSetRecord{rec} @@ -758,14 +773,25 @@ func (m *Mock) beginExecute( return nil } -// finishExecute records how an execution ended. -func (sd *stackData) finishExecute(rec *changeSetRecord, ok bool) { +// finishChangeSet records how the execution of change set id ended. An +// operation that no change set started passes "". +func (sd *stackData) finishChangeSet(id string, ok bool) { + if id == "" { + return + } + sd.mu.Lock() defer sd.mu.Unlock() - rec.ChangeSet.ExecutionStatus = cfn.ExecutionComplete - if !ok { - rec.ChangeSet.ExecutionStatus = cfn.ExecutionFailed + for _, rec := range sd.changeSets { + if rec.ChangeSet.ID != id { + continue + } + + rec.ChangeSet.ExecutionStatus = cfn.ExecutionComplete + if !ok { + rec.ChangeSet.ExecutionStatus = cfn.ExecutionFailed + } } } @@ -785,7 +811,9 @@ func (sd *stackData) obsoleteChangeSets() { // REVIEW_IN_PROGRESS stays when its last change set is deleted, as in AWS. // Named within a stack that exists, a change set that is already gone is a // successful no-op. -func (m *Mock) DeleteChangeSet(_ context.Context, in *cfn.DeleteChangeSetInput) error { +func (m *Mock) DeleteChangeSet(ctx context.Context, in *cfn.DeleteChangeSetInput) error { + m.settle(ctx) + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) var named *cfn.ExceptionError diff --git a/providers/aws/cloudformation/cloudformation.go b/providers/aws/cloudformation/cloudformation.go index c585a3dc9..b19128952 100644 --- a/providers/aws/cloudformation/cloudformation.go +++ b/providers/aws/cloudformation/cloudformation.go @@ -11,11 +11,13 @@ import ( "sort" "strings" "sync" + "time" "github.com/stackshy/cloudemu/v2/config" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/internal/settle" cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" ) @@ -34,6 +36,9 @@ type Mock struct { // stacks cannot claim one export name and an export cannot lose its // last guard while a stack starts importing it. exportMu sync.Mutex + // settleWindow is how long a stack operation stays *_IN_PROGRESS under + // AsyncSettle. Zero runs every operation to its end at once. + settleWindow time.Duration } // stackData is the stored state of one stack, guarded by its own mutex. @@ -68,6 +73,22 @@ type stackData struct { // policies maps a logical ID to the DeletionPolicy and // UpdateReplacePolicy it was last applied with. policies map[string]resourcePolicy + // stackPolicy is the stack policy body, "" when none was set. + stackPolicy string + // pending is the last phase of an operation that runs under + // AsyncSettle. It completes once the settle window has passed. + pending *pendingOp + // cursor is when the next event of an asynchronous operation is + // stamped, so its events arrive one after another. Zero stamps events + // with the current time. + cursor time.Time + // tokens maps each ClientRequestToken used on the stack to the action + // that used it, and opToken is the token of the running operation. + tokens map[string]string + opToken string + // stable is the stack's state before an update that failed without a + // rollback, which RollbackStack goes back to. + stable *storedPrior } // resourcePolicy is the effective DeletionPolicy and UpdateReplacePolicy of @@ -117,10 +138,13 @@ type retainedResource struct { DeleteID string `json:"deleteId"` // ReplacePolicy is the UpdateReplacePolicy the cleanup applies. ReplacePolicy string `json:"replacePolicy,omitempty"` + // Reclaimed marks a replacement whose new resource was taken back from + // the retained old resources. + Reclaimed bool `json:"reclaimed,omitempty"` } func (r *retainedResource) replacement() replacement { - return replacement{id: r.LogicalID, policy: r.ReplacePolicy, old: liveResource{ + return replacement{id: r.LogicalID, policy: r.ReplacePolicy, reclaimed: r.Reclaimed, old: liveResource{ typ: r.Type, resolved: r.Resolved, props: r.Props, deleteID: r.DeleteID, }} } @@ -197,6 +221,8 @@ func New(opts *config.Options) *Mock { clock: opts.Clock, accountID: opts.AccountID, region: opts.Region, + + settleWindow: opts.SettleDuration(settle.DefaultStackSettle), } } @@ -258,7 +284,9 @@ func (sd *stackData) status() string { // DescribeStacks returns the named stack, or every active stack when name is "". // A stack ID also finds a deleted stack, as in real CloudFormation. -func (m *Mock) DescribeStacks(_ context.Context, name string) ([]cfn.Stack, error) { +func (m *Mock) DescribeStacks(ctx context.Context, name string) ([]cfn.Stack, error) { + m.settle(ctx) + if name != "" { sd, byID, ok := m.findStack(name) if !ok || (!byID && sd.status() == cfn.StatusDeleteComplete) { @@ -282,27 +310,35 @@ func (m *Mock) DescribeStacks(_ context.Context, name string) ([]cfn.Stack, erro } // DescribeStackEvents returns the named stack's events, newest first. -func (m *Mock) DescribeStackEvents(_ context.Context, name string) ([]cfn.StackEvent, error) { +// Under AsyncSettle an event is listed once its time has come. +func (m *Mock) DescribeStackEvents(ctx context.Context, name string) ([]cfn.StackEvent, error) { + m.settle(ctx) + sd, err := m.stackAnyState(name) if err != nil { return nil, err } + now := m.clock.Now() + sd.mu.RLock() defer sd.mu.RUnlock() - n := len(sd.stack.Events) - out := make([]cfn.StackEvent, n) + out := make([]cfn.StackEvent, 0, len(sd.stack.Events)) - for i := range sd.stack.Events { - out[n-1-i] = sd.stack.Events[i] + for i := len(sd.stack.Events) - 1; i >= 0; i-- { + if e := sd.stack.Events[i]; !e.Timestamp.After(now) { + out = append(out, e) + } } return out, nil } // ListStacks returns a summary of every stack, optionally filtered by status. -func (m *Mock) ListStacks(_ context.Context, statusFilter []string) ([]cfn.StackSummary, error) { +func (m *Mock) ListStacks(ctx context.Context, statusFilter []string) ([]cfn.StackSummary, error) { + m.settle(ctx) + want := map[string]bool{} for _, s := range statusFilter { want[s] = true @@ -329,20 +365,18 @@ func (m *Mock) ListStacks(_ context.Context, statusFilter []string) ([]cfn.Stack return out, nil } -// DescribeStackResources returns the resources of an active stack. -func (m *Mock) DescribeStackResources(_ context.Context, name string) ([]cfn.StackResource, error) { +// DescribeStackResources returns the resources of an active stack. While +// an asynchronous operation runs, each resource shows what its events so +// far report. +func (m *Mock) DescribeStackResources(ctx context.Context, name string) ([]cfn.StackResource, error) { + m.settle(ctx) + sd, err := m.activeStack(name) if err != nil { return nil, err } - sd.mu.RLock() - defer sd.mu.RUnlock() - - out := make([]cfn.StackResource, len(sd.stack.Resources)) - copy(out, sd.stack.Resources) - - return out, nil + return sd.visibleResources(m.clock.Now()), nil } // ListStackResources is DescribeStackResources' summary form; it returns the @@ -352,7 +386,9 @@ func (m *Mock) ListStackResources(ctx context.Context, name string) ([]cfn.Stack } // GetTemplate returns the template body an active stack was deployed with. -func (m *Mock) GetTemplate(_ context.Context, name string) (string, error) { +func (m *Mock) GetTemplate(ctx context.Context, name string) (string, error) { + m.settle(ctx) + sd, err := m.stackAnyState(name) if err != nil { return "", err diff --git a/providers/aws/cloudformation/converge.go b/providers/aws/cloudformation/converge.go index 48db7ab08..481458192 100644 --- a/providers/aws/cloudformation/converge.go +++ b/providers/aws/cloudformation/converge.go @@ -32,6 +32,11 @@ type convergeOpts struct { // 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 + // policy is the stack policy an update is held to, or nil for none. + policy *cfn.StackPolicy + // holdCleanup ends a successful pass before its cleanup phase and + // returns the replacements, so the cleanup can run later. + holdCleanup bool } // applyFailure is one resource a converge pass could not bring to its target. @@ -88,13 +93,17 @@ func (m *Mock) converge( } failures, replaced := m.applyAll(ctx, sd, t, res, order, o) + if len(failures) == 0 { + failures = m.checkDrops(sd, t, &o) + } + if len(failures) > 0 && o.stopOnFailure { return failures, replaced } if len(failures) == 0 { failures = m.setOutputs(sd, res, t, !o.rollback) - if len(failures) > 0 && o.stopOnFailure { + if len(failures) > 0 && o.stopOnFailure || len(failures) == 0 && o.holdCleanup { return failures, replaced } } @@ -130,7 +139,7 @@ func (m *Mock) applyAll( rdef := t.Resources[id] - if f := m.applyOne(ctx, sd, res, id, rdef, &replaced); f != nil { + if f := m.applyOne(ctx, sd, res, id, &rdef, o.policy, &replaced); f != nil { failures = append(failures, *f) if o.stopOnFailure { @@ -184,12 +193,14 @@ func seedResolver(sd *stackData, t *cfn.Template, res *cfn.Resolver) { } // applyOne brings one resource to its definition in the target template. +// A change the stack policy does not allow fails the resource untouched. func (m *Mock) applyOne( - ctx context.Context, sd *stackData, res *cfn.Resolver, id string, rdef cfn.ResourceDef, replaced *[]replacement, + ctx context.Context, sd *stackData, res *cfn.Resolver, id string, rdef *cfn.ResourceDef, + policy *cfn.StackPolicy, replaced *[]replacement, ) *applyFailure { live, exists := sd.live(id) if !exists { - return m.createOne(ctx, sd, res, id, rdef, createEvents()) + return m.createOne(ctx, sd, res, id, *rdef, createEvents()) } props, err := resolveProps(res, rdef.Properties) @@ -199,20 +210,32 @@ func (m *Mock) applyOne( prov, ok := m.registry[rdef.Type] if !ok || live.typ != rdef.Type { - return m.replaceOne(ctx, sd, res, id, rdef, &live, replaced) + if f := m.policyDenies(sd, policy, cfn.StackPolicyReplace, id, &live); f != nil { + return f + } + + return m.replaceOne(ctx, sd, res, id, *rdef, &live, replaced) } switch cfn.PlanResourceUpdate(prov, live.props, props) { case cfn.UpdateNone: return nil case cfn.UpdateInPlace: - return m.updateOne(ctx, sd, res, id, rdef, &live, props) + if f := m.policyDenies(sd, policy, cfn.StackPolicyModify, id, &live); f != nil { + return f + } + + return m.updateOne(ctx, sd, res, id, *rdef, &live, props) case cfn.UpdateReplace: + if f := m.policyDenies(sd, policy, cfn.StackPolicyReplace, id, &live); f != nil { + return f + } + if name, kept := customNameKept(prov, live.props, props); kept { return m.updateFailed(sd, id, &live, cerrors.Newf(cerrors.InvalidArgument, msgCustomNameFmt, name)) } - return m.replaceOne(ctx, sd, res, id, rdef, &live, replaced) + return m.replaceOne(ctx, sd, res, id, *rdef, &live, replaced) } return nil diff --git a/providers/aws/cloudformation/deletion.go b/providers/aws/cloudformation/deletion.go index 1d6e0ea4a..aa4ba6ca1 100644 --- a/providers/aws/cloudformation/deletion.go +++ b/providers/aws/cloudformation/deletion.go @@ -50,11 +50,21 @@ func (m *Mock) DeleteStack(ctx context.Context, in *cfn.DeleteStackInput) error return cerrors.Newf(cerrors.InvalidArgument, msgFieldEnum, in.DeletionMode, "deletionMode", strings.Join(modes, ", ")) } + m.settle(ctx) + sd, _, ok := m.findStack(in.StackName) if !ok || sd.status() == cfn.StatusDeleteComplete { return nil } + if retry, err := sd.checkToken(in.ClientRequestToken, actionDeleteStack); err != nil || retry { + return err + } + + if err := checkNotBusy(sd); err != nil { + return err + } + wasFailed, err := checkDeletable(sd, in) if err != nil { return err @@ -62,7 +72,7 @@ func (m *Mock) DeleteStack(ctx context.Context, in *cfn.DeleteStackInput) error m.exportMu.Lock() reason := m.exportInUse(sd) - m.startDelete(sd, in.DeletionMode) + m.startDelete(sd, in.DeletionMode, in.ClientRequestToken) m.exportMu.Unlock() if reason != "" { @@ -70,12 +80,9 @@ func (m *Mock) DeleteStack(ctx context.Context, in *cfn.DeleteStackInput) error 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}) + m.finish(ctx, sd, &pendingOp{ + Kind: opDelete, Retain: in.RetainResources, Force: wasFailed && in.DeletionMode == cfn.DeletionModeForceDelete, + }) return nil } @@ -100,13 +107,15 @@ func checkDeletable(sd *stackData, in *cfn.DeleteStackInput) (bool, error) { // 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) { +func (m *Mock) startDelete(sd *stackData, mode, token string) { if mode == "" { mode = cfn.DeletionModeStandard } sd.mu.Lock() sd.stack.DeletionMode = mode + sd.recordToken(token, actionDeleteStack) + m.startCursor(sd) sd.mu.Unlock() m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reasonUserInitiated) @@ -114,7 +123,9 @@ func (m *Mock) startDelete(sd *stackData, mode string) { // 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) { +func (m *Mock) UpdateTerminationProtection(ctx context.Context, in *cfn.UpdateTerminationProtectionInput) (string, error) { + m.settle(ctx) + sd, err := m.activeStack(in.StackName) if err != nil { return "", err diff --git a/providers/aws/cloudformation/exports.go b/providers/aws/cloudformation/exports.go index d43b12eb9..bf243abab 100644 --- a/providers/aws/cloudformation/exports.go +++ b/providers/aws/cloudformation/exports.go @@ -218,7 +218,9 @@ func (m *Mock) exportInUse(sd *stackData) string { } // ListExports returns one page of the region's exports, sorted by name. -func (m *Mock) ListExports(_ context.Context, nextToken string) (*cfn.ExportList, error) { +func (m *Mock) ListExports(ctx context.Context, nextToken string) (*cfn.ExportList, error) { + m.settle(ctx) + entries := m.allExports() all := make([]cfn.Export, len(entries)) @@ -237,7 +239,9 @@ func (m *Mock) ListExports(_ context.Context, nextToken string) (*cfn.ExportList // 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) { +func (m *Mock) ListImports(ctx context.Context, in *cfn.ListImportsInput) (*cfn.ImportList, error) { + m.settle(ctx) + if in.ExportName == "" { return nil, cerrors.New(cerrors.InvalidArgument, msgExportNameNeed) } diff --git a/providers/aws/cloudformation/orchestrate.go b/providers/aws/cloudformation/orchestrate.go index 7ccfd93e2..8f299679d 100644 --- a/providers/aws/cloudformation/orchestrate.go +++ b/providers/aws/cloudformation/orchestrate.go @@ -5,7 +5,6 @@ import ( "slices" "sort" "strings" - "time" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" @@ -22,11 +21,21 @@ const stackResourceType = "AWS::CloudFormation::Stack" // ROLLBACK_COMPLETE, reported through the stack status and events, not as an // API error, mirroring CloudFormation's asynchronous create. A template that // needs an unacknowledged capability is refused before anything is created. +// +// Under AsyncSettle the stack stays CREATE_IN_PROGRESS for the settle window +// and its events arrive one after another. A retry with the +// ClientRequestToken of the create returns the stack it made. func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn.Stack, error) { + m.settle(ctx) + if in.StackName == "" { return nil, cerrors.New(cerrors.InvalidArgument, "stack name is required") } + if prior, err := m.retriedCreate(in); prior != nil || err != nil { + return prior, err + } + onFailure, err := createFailureMode(in) if err != nil { return nil, err @@ -63,11 +72,17 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. return nil, lerr } + policy, err := m.policyBody(ctx, in.StackPolicyBody, in.StackPolicyURL, msgBothPolicies) + if err != nil { + return nil, err + } + now := m.clock.Now() sd := &stackData{ - resolved: map[string]cfn.ResolvedResource{}, - deleteIDs: map[string]string{}, - props: map[string]map[string]any{}, + resolved: map[string]cfn.ResolvedResource{}, + deleteIDs: map[string]string{}, + props: map[string]map[string]any{}, + stackPolicy: policy, stack: cfn.Stack{ ID: stackID, Name: in.StackName, Status: cfn.StatusCreateInProgress, Description: t.Description, Parameters: params, Tags: in.Tags, @@ -79,24 +94,48 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. }, } + sd.recordToken(in.ClientRequestToken, actionCreateStack) + m.startCursor(sd) + if !m.claimStackSlot(in.StackName, sd) { return nil, cerrors.Newf(cerrors.AlreadyExists, "Stack [%s] already exists", in.StackName) } m.emitStackEvent(sd, cfn.StatusCreateInProgress, "User Initiated") - m.provision(ctx, sd, effective, resolver, onFailure) + m.provision(ctx, sd, effective, resolver, &pendingOp{OnFailure: onFailure}) out := sd.snapshotStack() return &out, nil } -// provision creates the resources of a new stack. It reports whether every -// resource was created. On a failure onFailure decides what happens next: -// 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 { +// retriedCreate returns the stack a CreateStack with the same +// ClientRequestToken already made, or the TokenAlreadyExistsException for a +// token another operation of that stack used. +func (m *Mock) retriedCreate(in *cfn.CreateStackInput) (*cfn.Stack, error) { + if in.ClientRequestToken == "" { + return nil, nil + } + + sd, _, ok := m.findStack(in.StackName) + if !ok || sd.status() == cfn.StatusDeleteComplete { + return nil, nil + } + + retry, err := sd.checkToken(in.ClientRequestToken, actionCreateStack) + if err != nil || !retry { + return nil, err + } + + out := sd.snapshotStack() + + return &out, nil +} + +// provision creates the resources of a new stack, then ends the create in +// op's last phase. +func (m *Mock) provision(ctx context.Context, sd *stackData, t *cfn.Template, res *cfn.Resolver, op *pendingOp) { var failures []applyFailure if _, f := m.bindImports(sd, t, res, nil); f != nil { @@ -105,14 +144,28 @@ func (m *Mock) provision(ctx context.Context, sd *stackData, t *cfn.Template, re failures, _ = m.converge(ctx, sd, t, res, convergeOpts{stopOnFailure: true}) } - if len(failures) == 0 { + op.Kind = opCreate + if len(failures) > 0 { + op.Failure = failureSummary(failures) + } + + m.finish(ctx, sd, op) +} + +// completeCreate ends a create. On a failure OnFailure decides what happens +// next: 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) completeCreate(ctx context.Context, sd *stackData, op *pendingOp) { + sd.finishChangeSet(op.ChangeSetID, op.Failure == "") + + if op.Failure == "" { m.emitStackEvent(sd, cfn.StatusCreateComplete, "") - return true + return } - reason := failureSummary(failures) + reason := op.Failure - switch onFailure { + switch op.OnFailure { case cfn.OnStackFailureDoNothing: m.emitStackEvent(sd, cfn.StatusCreateFailed, reason) case cfn.OnStackFailureDelete: @@ -124,13 +177,11 @@ func (m *Mock) provision(ctx context.Context, sd *stackData, t *cfn.Template, re if tf := m.teardown(ctx, sd, teardownOpts{rollbackOfCreate: true}); len(tf) > 0 { m.emitStackEvent(sd, cfn.StatusRollbackFailed, failureSummary(tf)) - return false + return } m.emitTerminalEvent(sd, cfn.StatusRollbackComplete, reason) } - - return false } // createFailureMode resolves CreateStack's OnFailure and DisableRollback to @@ -384,22 +435,19 @@ func (m *Mock) emitResourceEvent(sd *stackData, logicalID, physicalID, rtype, st sd.stack.Events = append(sd.stack.Events, m.event(sd, logicalID, physicalID, rtype, status, reason)) } -// event builds a StackEvent stamped with a fresh id and the current clock time. +// event builds a StackEvent stamped with a fresh id, the time eventTime +// gives and the running operation's ClientRequestToken. The caller holds +// sd.mu. Equal timestamps, as under a FakeClock, stay ordered by append +// position, which DescribeStackEvents preserves. func (m *Mock) event(sd *stackData, logicalID, physicalID, rtype, status, reason string) cfn.StackEvent { return cfn.StackEvent{ EventID: idgen.UUID(), StackID: sd.stack.ID, StackName: sd.stack.Name, LogicalID: logicalID, PhysicalID: physicalID, ResourceType: rtype, - Status: status, StatusReason: reason, Timestamp: m.tick(), + Status: status, StatusReason: reason, Timestamp: m.eventTime(sd), + ClientRequestToken: sd.opToken, } } -// tick returns the clock time; a monotonic real clock keeps events ordered even -// under a FakeClock that returns a fixed instant (equal timestamps are still -// ordered by append position, which DescribeStackEvents preserves). -func (m *Mock) tick() time.Time { - return m.clock.Now() -} - func (m *Mock) applyStackMeta(sd *stackData, in *cfn.UpdateStackInput, p *updatePlan) { sd.mu.Lock() defer sd.mu.Unlock() @@ -417,6 +465,10 @@ func (m *Mock) applyStackMeta(sd *stackData, in *cfn.UpdateStackInput, p *update if in.Capabilities != nil { sd.stack.Capabilities = in.Capabilities } + + if p.newPolicy != "" { + sd.stackPolicy = p.newPolicy + } } // priorState snapshots the metadata an update is about to overwrite, so a diff --git a/providers/aws/cloudformation/rollback_stack.go b/providers/aws/cloudformation/rollback_stack.go new file mode 100644 index 000000000..a1384e679 --- /dev/null +++ b/providers/aws/cloudformation/rollback_stack.go @@ -0,0 +1,120 @@ +package cloudformation + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +const msgRollbackBadStatus = "Stack:%s is in %s state and can not be rolled back." + +// RollbackStack rolls a stack whose last operation failed without a +// rollback back to its last stable state. A CREATE_FAILED stack has none, +// so its resources are deleted and it ends ROLLBACK_COMPLETE. An +// UPDATE_FAILED stack goes back to the template, parameters and resources +// it had before the failed update, and ends UPDATE_ROLLBACK_COMPLETE. +func (m *Mock) RollbackStack(ctx context.Context, in *cfn.RollbackStackInput) (string, error) { + m.settle(ctx) + + sd, err := m.activeStack(in.StackName) + if err != nil { + return "", err + } + + _, id := sd.identity() + + retry, err := sd.checkToken(in.ClientRequestToken, actionRollbackStack) + if err != nil || retry { + return id, err + } + + op := &pendingOp{Kind: opRollbackCreate} + next := cfn.StatusRollbackInProgress + + st := sd.status() + + switch st { + case cfn.StatusCreateFailed: + case cfn.StatusUpdateFailed: + op = &pendingOp{Kind: opRollback, Prior: sd.stableState()} + next = cfn.StatusUpdateRollbackInProgress + default: + return "", cerrors.Newf(cerrors.InvalidArgument, msgRollbackBadStatus, id, st) + } + + unchanged := func(s string) bool { return s == st } + + if berr := m.beginOperation(sd, unchanged, next, in.ClientRequestToken, actionRollbackStack); berr != nil { + return "", berr + } + + sd.mu.Lock() + sd.stack.RetainExceptOnCreate = in.RetainExceptOnCreate + sd.mu.Unlock() + + if op.Kind == opRollback { + op.Replaced = sd.takeAllRetained() + } + + m.finish(ctx, sd, op) + + return id, nil +} + +// completeRollbackCreate deletes what a failed create made. +func (m *Mock) completeRollbackCreate(ctx context.Context, sd *stackData) { + if tf := m.teardown(ctx, sd, teardownOpts{rollbackOfCreate: true}); len(tf) > 0 { + m.emitStackEvent(sd, cfn.StatusRollbackFailed, failureSummary(tf)) + return + } + + m.emitStackEvent(sd, cfn.StatusRollbackComplete, "") +} + +// stableState returns the stack's last stable state, or its current one +// when none was recorded, such as after a restore of an older snapshot. +func (sd *stackData) stableState() *storedPrior { + sd.mu.RLock() + stable := sd.stable + sd.mu.RUnlock() + + if stable != nil { + return stable + } + + cur := sd.priorState() + + return storePrior(&cur) +} + +// keepStable records the state before the first of a run of failed updates +// that were not rolled back. +func (sd *stackData) keepStable(p *storedPrior) { + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.stable == nil { + sd.stable = p + } +} + +// clearStable forgets the stable state once the stack reaches a new one. +func (sd *stackData) clearStable() { + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.stable = nil +} + +// takeAllRetained removes and returns every retained old resource, which a +// rollback puts back in place. +func (sd *stackData) takeAllRetained() []retainedResource { + sd.mu.Lock() + defer sd.mu.Unlock() + + out := sd.retained + sd.retained = nil + + return out +} diff --git a/providers/aws/cloudformation/rollback_stack_test.go b/providers/aws/cloudformation/rollback_stack_test.go new file mode 100644 index 000000000..7562fbd52 --- /dev/null +++ b/providers/aws/cloudformation/rollback_stack_test.go @@ -0,0 +1,129 @@ +package cloudformation + +import ( + "context" + "testing" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +func TestRollbackStackFromUpdateFailed(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramV2, DisableRollback: true}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateFailed, "update failed") + assertEqual(t, p.values["/p"], "v2", "Old was updated") + + id, err := m.RollbackStack(ctx, &cfn.RollbackStackInput{StackName: "s", ClientRequestToken: "rb-1"}) + requireNoError(t, err) + assertEqual(t, id, st.ID, "stack id") + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackComplete, "rolled back") + assertEqual(t, p.values["/p"], "v1", "Old restored") + + if _, ok := p.values["/n"]; ok { + t.Fatal("the resource the update created must be deleted") + } + + body, err := m.GetTemplate(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, paramV1, "template reverted") + + // The stack updates normally afterwards. + st, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "later update") +} + +func TestRollbackStackRestoresReplacedResources(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + _, err := m.RollbackStack(context.Background(), &cfn.RollbackStackInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackComplete, "rolled back") + + if _, ok := p.values["/a2"]; ok { + t.Fatalf("the replacement of A must be deleted, values = %v", p.values) + } + + assertEqual(t, p.values["/a"], "data", "the old A is back") + assertEqual(t, len(m.mustData(t, "s").retained), 0, "nothing retained") +} + +func TestRollbackStackFromCreateFailed(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + st, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV2, DisableRollback: true}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusCreateFailed, "create failed") + + _, err = m.RollbackStack(ctx, &cfn.RollbackStackInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusRollbackComplete, "rolled back") + assertEqual(t, len(p.values), 0, "created resources deleted") +} + +func TestRollbackStackRejectsOtherStates(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + + _, err = m.RollbackStack(ctx, &cfn.RollbackStackInput{StackName: "s"}) + assertValidation(t, err, "") + + _, err = m.RollbackStack(ctx, &cfn.RollbackStackInput{StackName: "nope"}) + assertValidation(t, err, "") +} + +func TestClientRequestTokens(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + first, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1, ClientRequestToken: "tok-1"}) + requireNoError(t, err) + + // A retry of the same create returns the stack instead of AlreadyExists. + again, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1, ClientRequestToken: "tok-1"}) + requireNoError(t, err) + assertEqual(t, again.ID, first.ID, "same stack") + + events, err := m.DescribeStackEvents(ctx, "s") + requireNoError(t, err) + + for _, e := range events { + assertEqual(t, e.ClientRequestToken, "tok-1", "create events carry the token") + } + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified, ClientRequestToken: "tok-1"}) + assertException(t, err, cfn.ExceptionTokenAlreadyExists, "") + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified, ClientRequestToken: "tok-2"}) + requireNoError(t, err) + + // Retrying the update does not run it again or fail with "No updates". + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified, ClientRequestToken: "tok-2"}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "retried update") + assertEqual(t, *p.updates, 1, "one real update") + + err = m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", ClientRequestToken: "tok-2"}) + assertException(t, err, cfn.ExceptionTokenAlreadyExists, "") + + err = m.ContinueUpdateRollback(ctx, &cfn.ContinueUpdateRollbackInput{StackName: "s", ClientRequestToken: "tok-1"}) + assertException(t, err, cfn.ExceptionTokenAlreadyExists, "") + + requireNoError(t, m.DeleteStack(ctx, &cfn.DeleteStackInput{StackName: "s", ClientRequestToken: "tok-3"})) +} diff --git a/providers/aws/cloudformation/snapshot.go b/providers/aws/cloudformation/snapshot.go index d87ce5d95..0033f8eb4 100644 --- a/providers/aws/cloudformation/snapshot.go +++ b/providers/aws/cloudformation/snapshot.go @@ -31,6 +31,11 @@ type stackSnapshot struct { Retained []retainedResource `json:"retained,omitempty"` Imports []string `json:"imports,omitempty"` Policies map[string]resourcePolicy `json:"policies,omitempty"` + StackPolicy string `json:"stackPolicy,omitempty"` + Pending *pendingOp `json:"pending,omitempty"` + Tokens map[string]string `json:"tokens,omitempty"` + OpToken string `json:"opToken,omitempty"` + Stable *storedPrior `json:"stable,omitempty"` } // Snapshot captures every stack's state under its own name so a restore @@ -51,6 +56,11 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { Retained: append([]retainedResource(nil), sd.retained...), Imports: append([]string(nil), sd.imports...), Policies: maps.Clone(sd.policies), + StackPolicy: sd.stackPolicy, + Pending: clonePending(sd.pending), + Tokens: maps.Clone(sd.tokens), + OpToken: sd.opToken, + Stable: sd.stable, } sd.mu.RUnlock() } @@ -76,6 +86,11 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { retained: ss.Retained, imports: ss.Imports, policies: ss.Policies, + stackPolicy: ss.StackPolicy, + pending: ss.Pending, + tokens: ss.Tokens, + opToken: ss.OpToken, + stable: ss.Stable, } for i := range ss.ChangeSets { @@ -100,6 +115,18 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { return nil } +// clonePending copies the pending phase for a snapshot. Its slices are +// never mutated in place, so sharing them is safe. +func clonePending(op *pendingOp) *pendingOp { + if op == nil { + return nil + } + + out := *op + + return &out +} + // cloneChangeSets copies the stored change sets. Their slices and maps are // never mutated in place, so sharing them is safe. func cloneChangeSets(in []*changeSetRecord) []changeSetRecord { diff --git a/providers/aws/cloudformation/stack_policy.go b/providers/aws/cloudformation/stack_policy.go new file mode 100644 index 000000000..e19d5a82e --- /dev/null +++ b/providers/aws/cloudformation/stack_policy.go @@ -0,0 +1,172 @@ +package cloudformation + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// Error texts of the stack policy inputs. +const ( + msgBothPolicies = "You cannot specify both StackPolicyURL and StackPolicyBody" + msgBothDuringPolicies = "You cannot specify both StackPolicyDuringUpdateURL and StackPolicyDuringUpdateBody" + msgPolicyRequired = "Either StackPolicyBody or StackPolicyURL must be specified. " + + "A stack policy cannot be removed, only replaced." + msgPolicyNotS3URL = "StackPolicyURL must be an Amazon S3 URL." + msgPolicyAccess = "StackPolicyURL must reference a valid S3 object to which you have access." +) + +// SetStackPolicy replaces a stack's policy. There is no way to remove one: +// a body or URL is required, and a policy that allows everything is how a +// stack is unprotected again. +func (m *Mock) SetStackPolicy(ctx context.Context, in *cfn.SetStackPolicyInput) error { + m.settle(ctx) + + sd, err := m.activeStack(in.StackName) + if err != nil { + return err + } + + body, err := m.policyBody(ctx, in.StackPolicyBody, in.StackPolicyURL, msgBothPolicies) + if err != nil { + return err + } + + if body == "" { + return cerrors.New(cerrors.InvalidArgument, msgPolicyRequired) + } + + sd.setStackPolicy(body) + + return nil +} + +// GetStackPolicy returns a stack's policy body, or "" when it has none. +func (m *Mock) GetStackPolicy(ctx context.Context, name string) (string, error) { + m.settle(ctx) + + sd, err := m.activeStack(name) + if err != nil { + return "", err + } + + sd.mu.RLock() + defer sd.mu.RUnlock() + + return sd.stackPolicy, nil +} + +// policyBody returns the policy a request names by body or S3 URL, checked +// against the stack policy grammar, or "" when it names none. +func (m *Mock) policyBody(ctx context.Context, body, policyURL, bothMsg string) (string, error) { + switch { + case body != "" && policyURL != "": + return "", cerrors.New(cerrors.InvalidArgument, bothMsg) + case body == "" && policyURL == "": + return "", nil + case policyURL != "": + fetched, err := m.fetchPolicy(ctx, policyURL) + if err != nil { + return "", err + } + + body = fetched + } + + if _, err := cfn.ParseStackPolicy(body); err != nil { + return "", err + } + + return body, nil +} + +func (m *Mock) fetchPolicy(ctx context.Context, policyURL string) (string, error) { + obj, ok := parseS3URL(policyURL) + if !ok { + return "", cerrors.New(cerrors.InvalidArgument, msgPolicyNotS3URL) + } + + if m.fetchTemplate == nil { + return "", cerrors.New(cerrors.InvalidArgument, msgPolicyAccess) + } + + data, err := m.fetchTemplate(ctx, obj.bucket, obj.key, obj.versionID) + if err != nil { + return "", cerrors.New(cerrors.InvalidArgument, msgPolicyAccess) + } + + return string(data), nil +} + +// updatePolicy works out the policy an update is held to: the one given for +// the update only, else the stack's own. It is nil when there is neither. +// The stored body was checked when it was set. +func updatePolicy(sd *stackData, during string) (*cfn.StackPolicy, error) { + body := during + if body == "" { + sd.mu.RLock() + body = sd.stackPolicy + sd.mu.RUnlock() + } + + if body == "" { + return nil, nil + } + + return cfn.ParseStackPolicy(body) +} + +func (sd *stackData) setStackPolicy(body string) { + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.stackPolicy = body +} + +// policyDenies fails a resource whose update the stack policy does not +// allow. The resource is left as it is. +func (m *Mock) policyDenies(sd *stackData, policy *cfn.StackPolicy, action, id string, live *liveResource) *applyFailure { + if policy == nil { + return nil + } + + reason, ok := policy.Allows(action, id, live.typ) + if ok { + return nil + } + + m.emitResourceEvent(sd, id, live.resolved.RefValue, live.typ, cfn.ResourceUpdateFailed, reason) + + return &applyFailure{logicalID: id, verb: verbUpdate, err: cerrors.New(cerrors.InvalidArgument, reason)} +} + +// checkDrops checks the resources an update drops from the template against +// the stack policy's Update:Delete, before the cleanup deletes any of them. +func (m *Mock) checkDrops(sd *stackData, t *cfn.Template, o *convergeOpts) []applyFailure { + if o.policy == nil || o.rollback { + return nil + } + + sd.mu.RLock() + order := append([]string(nil), sd.provisionOrder...) + sd.mu.RUnlock() + + for i := len(order) - 1; i >= 0; i-- { + id := order[i] + if _, kept := t.Resources[id]; kept || o.skip[id] { + continue + } + + live, ok := sd.live(id) + if !ok { + continue + } + + if f := m.policyDenies(sd, o.policy, cfn.StackPolicyDelete, id, &live); f != nil { + return []applyFailure{*f} + } + } + + return nil +} diff --git a/providers/aws/cloudformation/stack_policy_test.go b/providers/aws/cloudformation/stack_policy_test.go new file mode 100644 index 000000000..6a03a18ec --- /dev/null +++ b/providers/aws/cloudformation/stack_policy_test.go @@ -0,0 +1,272 @@ +package cloudformation + +import ( + "context" + "testing" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// denyReplaceOld protects Old from replacement and allows everything else. +const denyReplaceOld = `{"Statement":[ + {"Effect":"Deny","Action":"Update:Replace","Principal":"*","Resource":"LogicalResourceId/Old"}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"} +]}` + +const allowAll = `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}` + +// paramRenamed renames Old, which replaces it. +const paramRenamed = `{"Resources":{ + "Old":{"Type":"Test::Param","Properties":{"Name":"/p2","Value":"v1"}} +}}` + +// paramModified changes Old's value in place. +const paramModified = `{"Resources":{ + "Old":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v2"}} +}}` + +const deniedReplaceReason = "Action denied by stack policy: Statement [#1] does not allow [Update:Replace] " + + "for resource [LogicalResourceId/Old]" + +func createWithPolicy(t *testing.T, m *Mock, policy string) { + t.Helper() + + st, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{ + StackName: "s", TemplateBody: paramV1, StackPolicyBody: policy, + }) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusCreateComplete, "create status") +} + +// lastEventReason returns the reason of the newest event of id with status. +func lastEventReason(t *testing.T, m *Mock, stack, id, status string) string { + t.Helper() + + events, err := m.DescribeStackEvents(context.Background(), stack) + requireNoError(t, err) + + for _, e := range events { + if e.LogicalID == id && e.Status == status { + return e.StatusReason + } + } + + t.Fatalf("no %s event for %s", status, id) + + return "" +} + +func TestStackPolicyDeniesReplacementAndRollsBack(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + createWithPolicy(t, m, denyReplaceOld) + + st, err := m.UpdateStack(context.Background(), &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "status") + assertEqual(t, lastEventReason(t, m, "s", "Old", cfn.ResourceUpdateFailed), deniedReplaceReason, "reason") + + if _, ok := p.values["/p2"]; ok { + t.Fatal("the denied replacement must not create the new resource") + } + + assertEqual(t, p.values["/p"], "v1", "old resource intact") + assertEqual(t, stackStatus(t, m, "s").StatusReason, "The following resource(s) failed to update: [Old].", "stack reason") +} + +func TestStackPolicyAllowsWhatItDoesNotDeny(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + createWithPolicy(t, m, denyReplaceOld) + + st, err := m.UpdateStack(context.Background(), &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "status") + assertEqual(t, p.values["/p"], "v2", "modified in place") +} + +func TestStackPolicyDefaultDeny(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + createWithPolicy(t, m, `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*",`+ + `"Resource":"LogicalResourceId/Other"}]}`) + + st, err := m.UpdateStack(context.Background(), &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramModified}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "status") + assertEqual(t, lastEventReason(t, m, "s", "Old", cfn.ResourceUpdateFailed), + "Action denied by stack policy: No statement allows [Update:Modify] for resource [LogicalResourceId/Old]", "reason") + assertEqual(t, p.values["/p"], "v1", "value kept") +} + +func TestStackPolicyDeniesDelete(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + two := `{"Resources":{ + "Old":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v1"}}, + "Keep":{"Type":"Test::Param","Properties":{"Name":"/k","Value":"k"}} + }}` + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "s", TemplateBody: two, + StackPolicyBody: `{"Statement":[{"Effect":"Allow","NotAction":"Update:Delete","Principal":"*","Resource":"*"}]}`, + }) + requireNoError(t, err) + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "status") + assertEqual(t, lastEventReason(t, m, "s", "Keep", cfn.ResourceUpdateFailed), + "Action denied by stack policy: No statement allows [Update:Delete] for resource [LogicalResourceId/Keep]", "reason") + assertEqual(t, p.values["/k"], "k", "the protected resource is kept") +} + +func TestStackPolicyDuringUpdateOverridesOnce(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + createWithPolicy(t, m, denyReplaceOld) + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: paramRenamed, StackPolicyDuringUpdateBody: allowAll, + }) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "override update") + assertEqual(t, p.values["/p2"], "v1", "replaced") + + body, err := m.GetStackPolicy(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, denyReplaceOld, "the stored policy is unchanged") + + // The next update is governed by the stored policy again. + st, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "denied again") +} + +func TestSetStackPolicyReplacesButNeverRemoves(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + + body, err := m.GetStackPolicy(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, "", "no policy yet") + + assertValidation(t, m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{StackName: "s"}), msgPolicyRequired) + assertValidation(t, m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{StackName: "s", StackPolicyBody: "{"}), "") + assertValidation(t, m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{ + StackName: "s", StackPolicyBody: allowAll, StackPolicyURL: "https://b.s3.amazonaws.com/p.json", + }), msgBothPolicies) + assertValidation(t, m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{StackName: "missing", StackPolicyBody: allowAll}), "") + + requireNoError(t, m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{StackName: "s", StackPolicyBody: denyReplaceOld})) + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "denied by the set policy") + + // UpdateStack's StackPolicyBody replaces the policy for later updates. + st, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: paramModified, StackPolicyBody: allowAll, + }) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "modify allowed") + + body, err = m.GetStackPolicy(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, allowAll, "replaced by UpdateStack") +} + +func TestStackPolicyFromURL(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + m.SetTemplateFetcher(func(_ context.Context, bucket, key, _ string) ([]byte, error) { + if bucket == "b" && key == "policy.json" { + return []byte(denyReplaceOld), nil + } + + return nil, errBoom + }) + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "s", TemplateBody: paramV1, StackPolicyURL: "https://b.s3.amazonaws.com/policy.json", + }) + requireNoError(t, err) + + body, err := m.GetStackPolicy(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, denyReplaceOld, "fetched from S3") + + err = m.SetStackPolicy(ctx, &cfn.SetStackPolicyInput{StackName: "s", StackPolicyURL: "https://b.s3.amazonaws.com/nope.json"}) + assertValidation(t, err, "") +} + +func TestExecuteChangeSetHonoursStackPolicy(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + createWithPolicy(t, m, denyReplaceOld) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "c", TemplateBody: paramRenamed}) + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID})) + + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateRollbackComplete, "status") + assertEqual(t, lastEventReason(t, m, "s", "Old", cfn.ResourceUpdateFailed), deniedReplaceReason, "reason") + assertEqual(t, describeCS(t, m, "s", cs.ID).ExecutionStatus, cfn.ExecutionFailed, "execution status") +} + +func TestStackPolicySurvivesSnapshot(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + createWithPolicy(t, m, denyReplaceOld) + + data, err := m.Snapshot(ctx, false) + requireNoError(t, err) + + restored := newParamMock(p) + requireNoError(t, restored.Restore(ctx, data)) + + body, err := restored.GetStackPolicy(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, denyReplaceOld, "policy restored") + + st, err := restored.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramRenamed}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "still enforced") +} + +func TestCreateStackRejectsBadPolicy(t *testing.T) { + m := newParamMock(newParamProv()) + + _, err := m.CreateStack(context.Background(), &cfn.CreateStackInput{ + StackName: "s", TemplateBody: paramV1, StackPolicyBody: `{"Statement":[{"Effect":"Allow"}]}`, + }) + assertValidation(t, err, "") + + if _, _, ok := m.findStack("s"); ok { + t.Fatal("a stack with a bad policy must not be created") + } + + createWithPolicy(t, m, allowAll) + + _, err = m.UpdateStack(context.Background(), &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: paramModified, StackPolicyDuringUpdateBody: allowAll, + StackPolicyDuringUpdateURL: "https://b.s3.amazonaws.com/p.json", + }) + assertValidation(t, err, msgBothDuringPolicies) + + _, err = m.UpdateStack(context.Background(), &cfn.UpdateStackInput{ + StackName: "s", TemplateBody: paramModified, StackPolicyDuringUpdateBody: `{"Statement":[]`, + }) + assertValidation(t, err, "") + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusCreateComplete, "nothing started") +} diff --git a/providers/aws/cloudformation/template_source.go b/providers/aws/cloudformation/template_source.go index 62dde9bda..bc704aa24 100644 --- a/providers/aws/cloudformation/template_source.go +++ b/providers/aws/cloudformation/template_source.go @@ -81,6 +81,8 @@ func (m *Mock) ValidateTemplate(ctx context.Context, in *cfn.ValidateTemplateInp // as a body or URL, the way ValidateTemplate does, plus its resource types // and format version. func (m *Mock) GetTemplateSummary(ctx context.Context, in *cfn.GetTemplateSummaryInput) (*cfn.TemplateSummary, error) { + m.settle(ctx) + if in.StackName == "" { return m.ValidateTemplate(ctx, &cfn.ValidateTemplateInput{TemplateBody: in.TemplateBody, TemplateURL: in.TemplateURL}) } diff --git a/providers/aws/cloudformation/tokens.go b/providers/aws/cloudformation/tokens.go new file mode 100644 index 000000000..7336ac55c --- /dev/null +++ b/providers/aws/cloudformation/tokens.go @@ -0,0 +1,60 @@ +package cloudformation + +import ( + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// Actions a ClientRequestToken is recorded under. +const ( + actionCreateStack = "CreateStack" + actionUpdateStack = "UpdateStack" + actionDeleteStack = "DeleteStack" + actionCancelUpdateStack = "CancelUpdateStack" + actionContinueUpdateRollback = "ContinueUpdateRollback" + actionRollbackStack = "RollbackStack" + actionExecuteChangeSet = "ExecuteChangeSet" +) + +const msgTokenExists = "A client request token [%s] already exists for a different request on stack [%s]." + +// checkToken looks a request's ClientRequestToken up on the stack. retry is +// true when the same action already ran with it, so the request is a retry +// that must not run again. A token another action used is a +// TokenAlreadyExistsException. +func (sd *stackData) checkToken(token, action string) (retry bool, err error) { + if token == "" { + return false, nil + } + + sd.mu.RLock() + defer sd.mu.RUnlock() + + used, ok := sd.tokens[token] + if !ok { + return false, nil + } + + if used != action { + return false, cfn.NewException(cfn.ExceptionTokenAlreadyExists, + cerrors.Newf(cerrors.FailedPrecondition, msgTokenExists, token, sd.stack.Name)) + } + + return true, nil +} + +// recordToken records the token of an operation that starts. The events it +// records carry the token. The caller holds sd.mu. +func (sd *stackData) recordToken(token, action string) { + sd.opToken = token + + if token == "" { + return + } + + if sd.tokens == nil { + sd.tokens = map[string]string{} + } + + sd.tokens[token] = action +} diff --git a/providers/aws/cloudformation/update.go b/providers/aws/cloudformation/update.go index 35e817fa8..a3b2bf0a6 100644 --- a/providers/aws/cloudformation/update.go +++ b/providers/aws/cloudformation/update.go @@ -54,6 +54,10 @@ type updatePlan struct { newT, oldT *cfn.Template newRes, oldRes *cfn.Resolver prior priorState + // policy is the stack policy the update is held to, and newPolicy the + // policy the request sets for later updates, "" to keep the current one. + policy *cfn.StackPolicy + newPolicy string } // UpdateStack brings the stack to a new template. Each resource is created, @@ -64,12 +68,29 @@ type updatePlan struct { // are to be performed.". A failure rolls the stack back to its previous // template. If the rollback also fails the stack ends UPDATE_ROLLBACK_FAILED // until ContinueUpdateRollback. +// +// A stack policy, or StackPolicyDuringUpdateBody for this update only, can +// refuse a resource's change, which fails the update. A retry with the +// ClientRequestToken of an update that already ran returns the stack as it +// is. func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn.Stack, error) { + m.settle(ctx) + sd, err := m.activeStack(in.StackName) if err != nil { return nil, err } + if retry, terr := sd.checkToken(in.ClientRequestToken, actionUpdateStack); terr != nil || retry { + if terr != nil { + return nil, terr + } + + out := sd.snapshotStack() + + return &out, nil + } + if uerr := checkUpdatable(sd); uerr != nil { return nil, uerr } @@ -83,7 +104,8 @@ func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn. return nil, cerrors.New(cerrors.InvalidArgument, msgNoUpdates) } - if berr := m.beginOperation(sd, updatableStatus, cfn.StatusUpdateInProgress); berr != nil { + if berr := m.beginOperation(sd, updatableStatus, cfn.StatusUpdateInProgress, in.ClientRequestToken, + actionUpdateStack); berr != nil { return nil, berr } @@ -94,7 +116,7 @@ func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn. sd.obsoleteChangeSets() sd.setRollbackFlags(in.DisableRollback, in.RetainExceptOnCreate) - m.runUpdate(ctx, sd, in, plan, onFailure) + m.runUpdate(ctx, sd, in, plan, &pendingOp{OnFailure: onFailure}) out := sd.snapshotStack() @@ -102,16 +124,15 @@ func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn. } // runUpdate applies a planned update to a stack already in -// UPDATE_IN_PROGRESS. It reports whether the update succeeded. A failure -// rolls the stack back, or with DO_NOTHING leaves it UPDATE_FAILED as it is. -// Old resources of replacements are deleted only in the cleanup phase of a -// successful update. A failure that is not rolled back keeps them, and the -// next successful update cleans them up. -func (m *Mock) runUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStackInput, plan *updatePlan, onFailure string) bool { +// UPDATE_IN_PROGRESS. The update ends in op's last phase. Under +// AsyncSettle the cleanup phase waits for it too, so a cancel can still +// put the old resources of replacements back. +func (m *Mock) runUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStackInput, plan *updatePlan, op *pendingOp) { m.applyStackMeta(sd, in, plan) forward := convergeOpts{ stopOnFailure: true, cleanupStatus: cfn.StatusUpdateCompleteCleanupInProgress, cleanRetained: true, + policy: plan.policy, holdCleanup: m.settleWindow > 0, } var ( @@ -126,22 +147,77 @@ func (m *Mock) runUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStack failures, replaced = m.converge(ctx, sd, plan.newT, plan.newRes, forward) } - if len(failures) == 0 { - sd.setImports(imports) + op.Kind = opUpdate + op.Prior = storePrior(&plan.prior) + op.plan = plan + op.Replaced = toRetained(replaced) + op.Imports = imports + op.HeldCleanup = forward.holdCleanup && len(failures) == 0 + + if len(failures) > 0 { + op.Failure = failureSummary(failures) + } + + m.finish(ctx, sd, op) +} + +// completeUpdate ends an update. A failure rolls the stack back, or with +// DO_NOTHING leaves it UPDATE_FAILED as it is. Old resources of +// replacements are deleted only in the cleanup phase of a successful +// update. A failure that is not rolled back keeps them, and the next +// successful update cleans them up. +func (m *Mock) completeUpdate(ctx context.Context, sd *stackData, op *pendingOp) { + if op.Failure == "" { + if op.HeldCleanup { + m.cleanupUpdate(ctx, sd, op) + } + + sd.setImports(op.Imports) + sd.clearStable() m.emitStackEvent(sd, cfn.StatusUpdateComplete, "") + sd.finishChangeSet(op.ChangeSetID, true) - return true + return } - if onFailure != cfn.OnStackFailureDoNothing { - m.rollbackUpdate(ctx, sd, plan, failureSummary(failures), replaced) - return false + sd.finishChangeSet(op.ChangeSetID, false) + + if op.OnFailure != cfn.OnStackFailureDoNothing { + m.startRollback(ctx, sd, op, op.Failure) + return } - sd.retain(replaced) - m.emitStackEvent(sd, cfn.StatusUpdateFailed, failureSummary(failures)) + sd.retain(replacements(op.Replaced)) + sd.keepStable(op.Prior) + m.emitStackEvent(sd, cfn.StatusUpdateFailed, op.Failure) +} - return false +// cleanupUpdate runs the cleanup phase an asynchronous update held back: +// it deletes the old resources of replacements, then the resources the +// template dropped. +func (m *Mock) cleanupUpdate(ctx context.Context, sd *stackData, op *pendingOp) { + var t *cfn.Template + + if op.plan != nil { + t = op.plan.newT + } else { + cur := sd.priorState() + if prepared, _, err := m.preparedTemplate(sd, &cur); err == nil { + t = prepared + } + } + + if t == nil { + // Without the template no resource can be told to be dropped, so + // only the replaced ones are cleaned up. + t = &cfn.Template{Resources: map[string]cfn.ResourceDef{}} + for id, typ := range m.resourceTypes(sd) { + t.Resources[id] = cfn.ResourceDef{Type: typ} + } + } + + m.emitStackEvent(sd, cfn.StatusUpdateCompleteCleanupInProgress, "") + m.cleanup(ctx, sd, t, &convergeOpts{}, append(sd.drainRetained(), replacements(op.Replaced)...)) } // checkUpdatable rejects an update of a stack in a state that does not allow @@ -160,14 +236,17 @@ func checkUpdatable(sd *stackData) error { // beginOperation moves the stack to status, recording a "User Initiated" // event, if its current status passes allowed. The check and the move are // one step, so two concurrent operations cannot both start. -func (m *Mock) beginOperation(sd *stackData, allowed func(string) bool, status string) error { +func (m *Mock) beginOperation(sd *stackData, allowed func(string) bool, status, token, action string) error { sd.mu.Lock() defer sd.mu.Unlock() - if !allowed(sd.stack.Status) { + if !allowed(sd.stack.Status) || sd.pending != nil { return cerrors.Newf(cerrors.InvalidArgument, msgStackCannotUpdate, sd.stack.ID, sd.stack.Status) } + sd.recordToken(token, action) + m.startCursor(sd) + sd.stack.Status = status sd.stack.StatusReason = reasonUserInitiated sd.stack.Events = append(sd.stack.Events, @@ -203,6 +282,10 @@ func (m *Mock) planUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStac p := &updatePlan{body: body, params: params, description: newT.Description, prior: prior} + if p.policy, p.newPolicy, err = m.updatePolicies(ctx, sd, in); err != nil { + return nil, err + } + p.notificationARNs = in.NotificationARNs if p.notificationARNs == nil { p.notificationARNs = p.prior.notificationARNs @@ -449,16 +532,53 @@ func previousValues(in, stored []cfn.Parameter) ([]cfn.Parameter, error) { return out, nil } -// rollbackUpdate brings the stack back to its previous template after a -// failed update, restoring what the update changed or deleted and deleting -// what it created. -func (m *Mock) rollbackUpdate( - ctx context.Context, sd *stackData, p *updatePlan, reason string, replaced []replacement, -) { +// updatePolicies checks an update's stack policy inputs. It returns the +// policy the update is held to and the body StackPolicyBody sets. +func (m *Mock) updatePolicies(ctx context.Context, sd *stackData, in *cfn.UpdateStackInput) (*cfn.StackPolicy, string, error) { + during, err := m.policyBody(ctx, in.StackPolicyDuringUpdateBody, in.StackPolicyDuringUpdateURL, msgBothDuringPolicies) + if err != nil { + return nil, "", err + } + + next, err := m.policyBody(ctx, in.StackPolicyBody, in.StackPolicyURL, msgBothPolicies) + if err != nil { + return nil, "", err + } + + policy, err := updatePolicy(sd, during) + + return policy, next, err +} + +// startRollback begins rolling a failed or stopped update back. The +// rollback itself is the next phase. +func (m *Mock) startRollback(ctx context.Context, sd *stackData, op *pendingOp, reason string) { m.emitStackEvent(sd, cfn.StatusUpdateRollbackInProgress, reason) - m.restoreReplaced(ctx, sd, replaced) - m.revertStackMeta(sd, &p.prior) - m.finishRollback(ctx, sd, p.oldT, p.oldRes, nil, reason) + m.finish(ctx, sd, &pendingOp{ + Kind: opRollback, Failure: reason, Prior: op.Prior, Replaced: op.Replaced, plan: op.plan, + }) +} + +// doRollback brings the stack back to its previous template, restoring +// what the update changed or deleted and deleting what it created. +func (m *Mock) doRollback(ctx context.Context, sd *stackData, op *pendingOp) { + m.restoreReplaced(ctx, sd, replacements(op.Replaced)) + + prior := op.Prior.state() + m.revertStackMeta(sd, &prior) + + if op.plan != nil { + m.finishRollback(ctx, sd, op.plan.oldT, op.plan.oldRes, nil, op.Failure) + return + } + + t, res, err := m.preparedTemplate(sd, &prior) + if err != nil { + m.emitStackEvent(sd, cfn.StatusUpdateRollbackFailed, cerrors.Message(err)) + return + } + + m.finishRollback(ctx, sd, t, res, nil, op.Failure) } // finishRollback converges to the previous template t. If a resource cannot @@ -477,6 +597,7 @@ func (m *Mock) finishRollback( } sd.setRollbackFailed(nil) + sd.clearStable() m.emitTerminalEvent(sd, cfn.StatusUpdateRollbackComplete, reason) return @@ -506,25 +627,31 @@ func (sd *stackData) setRollbackFailed(ids []string) { // UPDATE_ROLLBACK_FAILED. Resources named in ResourcesToSkip must be among // those that failed. They are marked UPDATE_COMPLETE and left as they are. func (m *Mock) ContinueUpdateRollback(ctx context.Context, in *cfn.ContinueUpdateRollbackInput) error { + m.settle(ctx) + sd, err := m.activeStack(in.StackName) if err != nil { return err } - skip, err := sd.skipSet(in.ResourcesToSkip) - if err != nil { + if retry, terr := sd.checkToken(in.ClientRequestToken, actionContinueUpdateRollback); terr != nil || retry { + return terr + } + + if _, err = sd.skipSet(in.ResourcesToSkip); err != nil { return err } st := sd.priorState() - t, res, err := m.preparedTemplate(sd, &st) + t, _, err := m.preparedTemplate(sd, &st) if err != nil { return err } isFailed := func(s string) bool { return s == cfn.StatusUpdateRollbackFailed } - if err = m.beginOperation(sd, isFailed, cfn.StatusUpdateRollbackInProgress); err != nil { + if err = m.beginOperation(sd, isFailed, cfn.StatusUpdateRollbackInProgress, in.ClientRequestToken, + actionContinueUpdateRollback); err != nil { return cerrors.New(cerrors.InvalidArgument, msgContinueBadStatus) } @@ -532,11 +659,25 @@ func (m *Mock) ContinueUpdateRollback(ctx context.Context, in *cfn.ContinueUpdat m.markSkipped(sd, t, id) } - m.finishRollback(ctx, sd, t, res, skip, "") + m.finish(ctx, sd, &pendingOp{Kind: opContinue, Skip: in.ResourcesToSkip}) return nil } +// completeContinue retries the rollback, leaving the skipped resources as +// they are. +func (m *Mock) completeContinue(ctx context.Context, sd *stackData, op *pendingOp) { + st := sd.priorState() + + t, res, err := m.preparedTemplate(sd, &st) + if err != nil { + m.emitStackEvent(sd, cfn.StatusUpdateRollbackFailed, cerrors.Message(err)) + return + } + + m.finishRollback(ctx, sd, t, res, nameSet(op.Skip), "") +} + // skipSet checks ResourcesToSkip against the resources whose rollback failed. func (sd *stackData) skipSet(ids []string) (map[string]bool, error) { sd.mu.RLock() diff --git a/providers/aws/tickables_test.go b/providers/aws/tickables_test.go index ccd2ee6fe..15d0d66df 100644 --- a/providers/aws/tickables_test.go +++ b/providers/aws/tickables_test.go @@ -8,13 +8,14 @@ import ( ) // TestTickablesRegistersTimeDrivenServices checks the serve ticker reaches the -// region's own CloudWatch and SSM, so due alarms and parameter policies run -// without a read. +// region's own CloudWatch, SSM and CloudFormation, so due alarms, parameter policies and +// settling stack operations run without a read. func TestTickablesRegistersTimeDrivenServices(t *testing.T) { p := aws.New() got := p.Tickables() - if len(got) != 2 || got[0] != config.Tickable(p.CloudWatch) || got[1] != config.Tickable(p.SSM) { - t.Fatalf("Tickables() = %v, want this provider's CloudWatch and SSM", got) + if len(got) != 3 || got[0] != config.Tickable(p.CloudWatch) || got[1] != config.Tickable(p.SSM) || + got[2] != config.Tickable(p.CloudFormation) { + t.Fatalf("Tickables() = %v, want this provider's CloudWatch, SSM and CloudFormation", got) } } diff --git a/server/aws/cloudformation/handler.go b/server/aws/cloudformation/handler.go index ad66e396f..26dda99f9 100644 --- a/server/aws/cloudformation/handler.go +++ b/server/aws/cloudformation/handler.go @@ -35,6 +35,10 @@ // UpdateTerminationProtection API.UpdateTerminationProtection // DescribeAccountLimits API.DescribeAccountLimits // EstimateTemplateCost API.EstimateTemplateCost +// SetStackPolicy API.SetStackPolicy +// GetStackPolicy API.GetStackPolicy +// CancelUpdateStack API.CancelUpdateStack +// RollbackStack API.RollbackStack // // Templates may be JSON or YAML, given inline (TemplateBody) or as an S3 // object URL (TemplateURL). @@ -83,6 +87,11 @@ var cfnActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup actionUpdateTerminationProtection: {}, actionDescribeAccountLimits: {}, actionEstimateTemplateCost: {}, + + actionSetStackPolicy: {}, + actionGetStackPolicy: {}, + actionCancelUpdateStack: {}, + actionRollbackStack: {}, } // Handler serves CloudFormation query-protocol requests against a stack API. @@ -153,6 +162,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case actionListExports, actionListImports, actionUpdateTerminationProtection, actionDescribeAccountLimits, actionEstimateTemplateCost: h.serveAccount(w, r) + case actionSetStackPolicy, actionGetStackPolicy, actionCancelUpdateStack, actionRollbackStack: + h.serveStackControl(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 7e802b4e8..03965bb6e 100644 --- a/server/aws/cloudformation/operations.go +++ b/server/aws/cloudformation/operations.go @@ -37,8 +37,9 @@ func (h *Handler) updateStack(w http.ResponseWriter, r *http.Request) { func (h *Handler) continueUpdateRollback(w http.ResponseWriter, r *http.Request) { err := h.api.ContinueUpdateRollback(r.Context(), &cfn.ContinueUpdateRollbackInput{ - StackName: r.Form.Get("StackName"), - ResourcesToSkip: awsquery.ListStrings(r.Form, "ResourcesToSkip.member"), + StackName: r.Form.Get("StackName"), + ResourcesToSkip: awsquery.ListStrings(r.Form, "ResourcesToSkip.member"), + ClientRequestToken: r.Form.Get("ClientRequestToken"), }) if err != nil { writeErr(w, err) @@ -50,9 +51,10 @@ func (h *Handler) continueUpdateRollback(w http.ResponseWriter, r *http.Request) func (h *Handler) deleteStack(w http.ResponseWriter, r *http.Request) { in := &cfn.DeleteStackInput{ - StackName: r.Form.Get("StackName"), - RetainResources: awsquery.ListStrings(r.Form, "RetainResources.member"), - DeletionMode: r.Form.Get("DeletionMode"), + StackName: r.Form.Get("StackName"), + RetainResources: awsquery.ListStrings(r.Form, "RetainResources.member"), + DeletionMode: r.Form.Get("DeletionMode"), + ClientRequestToken: r.Form.Get("ClientRequestToken"), } if err := h.api.DeleteStack(r.Context(), in); err != nil { @@ -99,6 +101,7 @@ func (h *Handler) describeStackEvents(w http.ResponseWriter, r *http.Request) { LogicalResourceID: e.LogicalID, PhysicalResourceID: e.PhysicalID, ResourceType: e.ResourceType, Timestamp: isoTime(e.Timestamp), ResourceStatus: e.Status, ResourceStatusReason: e.StatusReason, + ClientRequestToken: e.ClientRequestToken, }) } diff --git a/server/aws/cloudformation/stack_policy.go b/server/aws/cloudformation/stack_policy.go new file mode 100644 index 000000000..bb5876ac2 --- /dev/null +++ b/server/aws/cloudformation/stack_policy.go @@ -0,0 +1,116 @@ +package cloudformation + +import ( + "encoding/xml" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// The stack policy, cancel and rollback actions. +const ( + actionSetStackPolicy = "SetStackPolicy" + actionGetStackPolicy = "GetStackPolicy" + actionCancelUpdateStack = "CancelUpdateStack" + actionRollbackStack = "RollbackStack" +) + +// serveStackControl dispatches the stack policy, cancel and rollback +// actions. +func (h *Handler) serveStackControl(w http.ResponseWriter, r *http.Request) { + switch r.Form.Get("Action") { + case actionSetStackPolicy: + h.setStackPolicy(w, r) + case actionGetStackPolicy: + h.getStackPolicy(w, r) + case actionCancelUpdateStack: + h.cancelUpdateStack(w, r) + case actionRollbackStack: + h.rollbackStack(w, r) + } +} + +func (h *Handler) setStackPolicy(w http.ResponseWriter, r *http.Request) { + err := h.api.SetStackPolicy(r.Context(), &cfn.SetStackPolicyInput{ + StackName: r.Form.Get("StackName"), + StackPolicyBody: r.Form.Get("StackPolicyBody"), + StackPolicyURL: r.Form.Get("StackPolicyURL"), + }) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, setStackPolicyResponse{Xmlns: Namespace, Meta: meta()}) +} + +func (h *Handler) getStackPolicy(w http.ResponseWriter, r *http.Request) { + body, err := h.api.GetStackPolicy(r.Context(), r.Form.Get("StackName")) + if err != nil { + writeErr(w, err) + return + } + + resp := getStackPolicyResponse{Xmlns: Namespace, Meta: meta()} + resp.Result.StackPolicyBody = body + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) cancelUpdateStack(w http.ResponseWriter, r *http.Request) { + err := h.api.CancelUpdateStack(r.Context(), &cfn.CancelUpdateStackInput{ + StackName: r.Form.Get("StackName"), + ClientRequestToken: r.Form.Get("ClientRequestToken"), + }) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, cancelUpdateStackResponse{Xmlns: Namespace, Meta: meta()}) +} + +func (h *Handler) rollbackStack(w http.ResponseWriter, r *http.Request) { + id, err := h.api.RollbackStack(r.Context(), &cfn.RollbackStackInput{ + StackName: r.Form.Get("StackName"), + ClientRequestToken: r.Form.Get("ClientRequestToken"), + RetainExceptOnCreate: formBool(r.Form, "RetainExceptOnCreate"), + }) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, rollbackStackResponse{ + Xmlns: Namespace, Result: stackIDResult{StackID: id}, Meta: meta(), + }) +} + +type setStackPolicyResponse struct { + XMLName xml.Name `xml:"SetStackPolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type getStackPolicyResponse struct { + XMLName xml.Name `xml:"GetStackPolicyResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + StackPolicyBody string `xml:"StackPolicyBody,omitempty"` + } `xml:"GetStackPolicyResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type cancelUpdateStackResponse struct { + XMLName xml.Name `xml:"CancelUpdateStackResponse"` + Xmlns string `xml:"xmlns,attr"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type rollbackStackResponse struct { + XMLName xml.Name `xml:"RollbackStackResponse"` + Xmlns string `xml:"xmlns,attr"` + Result stackIDResult `xml:"RollbackStackResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} diff --git a/server/aws/cloudformation/stack_policy_sdk_test.go b/server/aws/cloudformation/stack_policy_sdk_test.go new file mode 100644 index 000000000..1854fee7b --- /dev/null +++ b/server/aws/cloudformation/stack_policy_sdk_test.go @@ -0,0 +1,221 @@ +package cloudformation_test + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awscfn "github.com/aws/aws-sdk-go-v2/service/cloudformation" + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/config" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +func tableTemplate(name string) string { + return `{"Resources":{"Table":{"Type":"AWS::DynamoDB::Table","Properties":{ + "TableName":"` + name + `","BillingMode":"PAY_PER_REQUEST", + "AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}], + "KeySchema":[{"AttributeName":"id","KeyType":"HASH"}]}}}}` +} + +const denyTableReplace = `{"Statement":[ + {"Effect":"Deny","Action":"Update:Replace","Principal":"*","Resource":"LogicalResourceId/Table"}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}` + +const allowEverything = `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}` + +// fixedStart and settleWait drive the async test clock: settleWait is past +// any stack settle window. +var fixedStart = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) //nolint:gochecknoglobals // test clock start + +const settleWait = time.Minute + +// A stack policy that denies replacing the table fails the update, rolls +// it back and keeps the table and its item. An override for one update +// lets the replacement through without changing the stored policy. +func TestStackPolicyRealSDK(t *testing.T) { + ctx := context.Background() + c, cloud := bootWithProvider(t) + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("p"), TemplateBody: aws.String(tableTemplate("t1")), + StackPolicyBody: aws.String(denyTableReplace), ClientRequestToken: aws.String("create-1"), + }); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + if err := cloud.DynamoDB.PutItem(ctx, "t1", map[string]any{"id": "keep"}); err != nil { + t.Fatalf("PutItem: %v", err) + } + + got, err := c.GetStackPolicy(ctx, &awscfn.GetStackPolicyInput{StackName: aws.String("p")}) + if err != nil || aws.ToString(got.StackPolicyBody) != denyTableReplace { + t.Fatalf("GetStackPolicy = %v, %v", got, err) + } + + if _, err = c.UpdateStack(ctx, &awscfn.UpdateStackInput{ + StackName: aws.String("p"), TemplateBody: aws.String(tableTemplate("t2")), + }); err != nil { + t.Fatalf("UpdateStack: %v", err) + } + + if st := describeStack(t, c, "p"); st.StackStatus != cfntypes.StackStatusUpdateRollbackComplete { + t.Fatalf("status = %s", st.StackStatus) + } + + assertDeniedEvent(ctx, t, c) + + if item, ierr := cloud.DynamoDB.GetItem(ctx, "t1", map[string]any{"id": "keep"}); ierr != nil || item == nil { + t.Fatalf("the item must survive: %v %v", item, ierr) + } + + if _, derr := cloud.DynamoDB.DescribeTable(ctx, "t2"); derr == nil { + t.Fatal("the denied replacement must not create t2") + } + + if _, err = c.UpdateStack(ctx, &awscfn.UpdateStackInput{ + StackName: aws.String("p"), TemplateBody: aws.String(tableTemplate("t2")), + StackPolicyDuringUpdateBody: aws.String(allowEverything), + }); err != nil { + t.Fatalf("override UpdateStack: %v", err) + } + + if st := describeStack(t, c, "p"); st.StackStatus != cfntypes.StackStatusUpdateComplete { + t.Fatalf("override status = %s", st.StackStatus) + } + + got, _ = c.GetStackPolicy(ctx, &awscfn.GetStackPolicyInput{StackName: aws.String("p")}) + if aws.ToString(got.StackPolicyBody) != denyTableReplace { + t.Fatal("the override must not change the stored policy") + } +} + +func assertDeniedEvent(ctx context.Context, t *testing.T, c *awscfn.Client) { + t.Helper() + + out, err := c.DescribeStackEvents(ctx, &awscfn.DescribeStackEventsInput{StackName: aws.String("p")}) + if err != nil { + t.Fatalf("DescribeStackEvents: %v", err) + } + + want := "Action denied by stack policy: Statement [#1] does not allow [Update:Replace] for resource [LogicalResourceId/Table]" + + for _, e := range out.StackEvents { + if aws.ToString(e.LogicalResourceId) == "Table" && e.ResourceStatus == cfntypes.ResourceStatusUpdateFailed { + if aws.ToString(e.ResourceStatusReason) != want { + t.Fatalf("reason = %q", aws.ToString(e.ResourceStatusReason)) + } + + return + } + } + + t.Fatal("no UPDATE_FAILED event for Table") +} + +func TestStackPolicyErrorsRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("e"), TemplateBody: aws.String(tableTemplate("e1")), ClientRequestToken: aws.String("tok"), + }); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + _, err := c.SetStackPolicy(ctx, &awscfn.SetStackPolicyInput{StackName: aws.String("e"), StackPolicyBody: aws.String("{")}) + if code, _ := apiErrorCode(t, err); code != "ValidationError" { + t.Fatalf("bad policy: %s", code) + } + + _, err = c.CancelUpdateStack(ctx, &awscfn.CancelUpdateStackInput{StackName: aws.String("e")}) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || + msg != "CancelUpdateStack cannot be called from current stack status" { + t.Fatalf("cancel: %s %s", code, msg) + } + + _, err = c.RollbackStack(ctx, &awscfn.RollbackStackInput{StackName: aws.String("e")}) + if code, _ := apiErrorCode(t, err); code != "ValidationError" { + t.Fatalf("rollback: %s", code) + } + + _, err = c.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String("e"), ClientRequestToken: aws.String("tok")}) + + var exists *cfntypes.TokenAlreadyExistsException + if !errors.As(err, &exists) { + t.Fatalf("reused token: %v", err) + } + + events, err := c.DescribeStackEvents(ctx, &awscfn.DescribeStackEventsInput{StackName: aws.String("e")}) + if err != nil || aws.ToString(events.StackEvents[0].ClientRequestToken) != "tok" { + t.Fatalf("events carry the token: %v", err) + } +} + +// Under AsyncSettle an update stays UPDATE_IN_PROGRESS, so CancelUpdateStack +// catches it and rolls it back. +func TestCancelUpdateStackRealSDK(t *testing.T) { + ctx := context.Background() + fc := config.NewFakeClock(fixedStart) + cloud := cloudemu.NewAWS(config.WithAsyncSettle(), config.WithClock(fc)) + ts := httptest.NewServer(awsserver.NewFromProvider(cloud)) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", ""))) + if err != nil { + t.Fatalf("config: %v", err) + } + + c := awscfn.NewFromConfig(cfg, func(o *awscfn.Options) { o.BaseEndpoint = aws.String(ts.URL) }) + + if _, err = c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("a"), TemplateBody: aws.String(tableTemplate("a1")), + }); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + if st := describeStack(t, c, "a"); st.StackStatus != cfntypes.StackStatusCreateInProgress { + t.Fatalf("create status = %s", st.StackStatus) + } + + fc.Advance(settleWait) + + if _, err = c.UpdateStack(ctx, &awscfn.UpdateStackInput{ + StackName: aws.String("a"), TemplateBody: aws.String(tableTemplate("a2")), + }); err != nil { + t.Fatalf("UpdateStack: %v", err) + } + + if _, err = c.CancelUpdateStack(ctx, &awscfn.CancelUpdateStackInput{StackName: aws.String("a")}); err != nil { + t.Fatalf("CancelUpdateStack: %v", err) + } + + st := describeStack(t, c, "a") + if st.StackStatus != cfntypes.StackStatusUpdateRollbackInProgress || + !strings.Contains(aws.ToString(st.StackStatusReason), "cancelled") { + t.Fatalf("after cancel = %s %q", st.StackStatus, aws.ToString(st.StackStatusReason)) + } + + fc.Advance(settleWait) + + if st = describeStack(t, c, "a"); st.StackStatus != cfntypes.StackStatusUpdateRollbackComplete { + t.Fatalf("settled = %s", st.StackStatus) + } + + if _, err = cloud.DynamoDB.DescribeTable(ctx, "a1"); err != nil { + t.Fatalf("the old table must remain: %v", err) + } + + if _, err = cloud.DynamoDB.DescribeTable(ctx, "a2"); err == nil { + t.Fatal("the replacement table must be deleted") + } +} diff --git a/server/aws/cloudformation/types.go b/server/aws/cloudformation/types.go index ab747fc20..0e06b0b62 100644 --- a/server/aws/cloudformation/types.go +++ b/server/aws/cloudformation/types.go @@ -47,6 +47,10 @@ func createInput(form url.Values) cfn.CreateStackInput { DisableRollback: formBool(form, "DisableRollback"), EnableTerminationProtection: formBool(form, "EnableTerminationProtection"), RetainExceptOnCreate: formBool(form, "RetainExceptOnCreate"), + + StackPolicyBody: form.Get("StackPolicyBody"), + StackPolicyURL: form.Get("StackPolicyURL"), + ClientRequestToken: form.Get("ClientRequestToken"), } } @@ -68,6 +72,12 @@ func updateInput(form url.Values) cfn.UpdateStackInput { DisableRollback: formBool(form, "DisableRollback"), RetainExceptOnCreate: formBool(form, "RetainExceptOnCreate"), NotificationARNs: updateNotificationARNs(form), + + StackPolicyBody: form.Get("StackPolicyBody"), + StackPolicyURL: form.Get("StackPolicyURL"), + StackPolicyDuringUpdateBody: form.Get("StackPolicyDuringUpdateBody"), + StackPolicyDuringUpdateURL: form.Get("StackPolicyDuringUpdateURL"), + ClientRequestToken: form.Get("ClientRequestToken"), } } @@ -219,6 +229,7 @@ type eventXML struct { Timestamp string `xml:"Timestamp"` ResourceStatus string `xml:"ResourceStatus"` ResourceStatusReason string `xml:"ResourceStatusReason,omitempty"` + ClientRequestToken string `xml:"ClientRequestToken,omitempty"` } type describeStackEventsResponse struct { diff --git a/services/cloudformation/changeset.go b/services/cloudformation/changeset.go index 04acc0f60..90e4daa93 100644 --- a/services/cloudformation/changeset.go +++ b/services/cloudformation/changeset.go @@ -95,6 +95,7 @@ const ( ExceptionChangeSetNotFound = "ChangeSetNotFound" ExceptionInvalidChangeSetStatus = "InvalidChangeSetStatus" ExceptionLimitExceeded = "LimitExceededException" + ExceptionTokenAlreadyExists = "TokenAlreadyExistsException" ) // ChangeTarget is the part of a resource a change detail touches. The value diff --git a/services/cloudformation/stack_policy.go b/services/cloudformation/stack_policy.go new file mode 100644 index 000000000..19b2be258 --- /dev/null +++ b/services/cloudformation/stack_policy.go @@ -0,0 +1,329 @@ +package cloudformation + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "path" + "slices" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// Stack policy update actions. An update that changes a resource in place is +// Update:Modify, one that recreates it is Update:Replace, and one that drops +// it from the template is Update:Delete. +const ( + StackPolicyModify = "Update:Modify" + StackPolicyReplace = "Update:Replace" + StackPolicyDelete = "Update:Delete" + stackPolicyAll = "Update:*" +) + +// MaxStackPolicyLength is the size limit of a stack policy body. +const MaxStackPolicyLength = 16384 + +const ( + policyEffectAllow = "Allow" + policyEffectDeny = "Deny" + policyResourceAll = "*" + policyResourcePfx = "LogicalResourceId/" + condStringEquals = "StringEquals" + condStringLike = "StringLike" + condResourceType = "ResourceType" +) + +// Reasons a resource update fails with when the stack policy refuses it. +const ( + msgPolicyDenied = "Action denied by stack policy: Statement [#%d] does not allow [%s] for resource [LogicalResourceId/%s]" + msgPolicyNoAllow = "Action denied by stack policy: No statement allows [%s] for resource [LogicalResourceId/%s]" + msgPolicyInvalid = "Error validating stack policy: %s" + msgPolicyTooLong = "1 validation error detected: Value at 'stackPolicyBody' failed to satisfy constraint: " + + "Member must have length less than or equal to 16384" + policyActionValues = "Update:Modify, Update:Replace, Update:Delete, Update:*" +) + +// StackPolicy is a parsed stack policy. Once a stack has one, an update of a +// resource is allowed only when a statement allows it and none denies it. +type StackPolicy struct { + statements []policyStatement +} + +// policyStatement is one statement of a stack policy. +type policyStatement struct { + allow bool + actions []string + notActions []string + resources []string + notResources []string + // typeEquals and typeLike are the ResourceType condition values. + typeEquals []string + typeLike []string +} + +// rawStatement is a statement as JSON gives it. A string or a list is +// accepted wherever the grammar allows one or more values. +type rawStatement struct { + Sid string `json:"Sid"` + Effect string `json:"Effect"` + Action policyValues `json:"Action"` + NotAction policyValues `json:"NotAction"` + Principal any `json:"Principal"` + Resource policyValues `json:"Resource"` + NotResource policyValues `json:"NotResource"` + Condition map[string]map[string]policyValues `json:"Condition"` +} + +// errPolicyValues reports a policy element that is neither a string nor a +// list of strings. +var errPolicyValues = errors.New("expected a string or a list of strings") + +// policyValues is a JSON string or list of strings. +type policyValues []string + +// UnmarshalJSON accepts "x" or ["x", ...]. +func (s *policyValues) UnmarshalJSON(b []byte) error { + var one string + if err := json.Unmarshal(b, &one); err == nil { + *s = policyValues{one} + return nil + } + + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return errPolicyValues + } + + *s = many + + return nil +} + +// ParseStackPolicy checks a stack policy body and parses it. A body that is +// not valid JSON or breaks the grammar is a ValidationError. +func ParseStackPolicy(body string) (*StackPolicy, error) { + if len(body) > MaxStackPolicyLength { + return nil, cerrors.New(cerrors.InvalidArgument, msgPolicyTooLong) + } + + var doc struct { + Statement json.RawMessage `json:"Statement"` + } + + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return nil, policyError("the policy is not valid JSON") + } + + raws, err := rawStatements(doc.Statement) + if err != nil { + return nil, err + } + + p := &StackPolicy{statements: make([]policyStatement, 0, len(raws))} + + for i := range raws { + st, serr := checkStatement(&raws[i]) + if serr != nil { + return nil, serr + } + + p.statements = append(p.statements, st) + } + + return p, nil +} + +func policyError(detail string) error { + return cerrors.Newf(cerrors.InvalidArgument, msgPolicyInvalid, detail) +} + +// rawStatements decodes Statement, a single statement or a list of them. +func rawStatements(raw json.RawMessage) ([]rawStatement, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return nil, policyError("the policy must contain a Statement") + } + + var out []rawStatement + + if raw[0] == '{' { + var one rawStatement + if err := json.Unmarshal(raw, &one); err != nil { + return nil, policyError("invalid Statement: " + err.Error()) + } + + return []rawStatement{one}, nil + } + + if err := json.Unmarshal(raw, &out); err != nil { + return nil, policyError("invalid Statement: " + err.Error()) + } + + return out, nil +} + +// checkStatement validates one statement against the stack policy grammar. +func checkStatement(r *rawStatement) (policyStatement, error) { + st := policyStatement{allow: r.Effect == policyEffectAllow, actions: r.Action, notActions: r.NotAction, + resources: r.Resource, notResources: r.NotResource} + + if r.Effect != policyEffectAllow && r.Effect != policyEffectDeny { + return st, policyError(fmt.Sprintf("Effect must be Allow or Deny, not [%s]", r.Effect)) + } + + if p, ok := r.Principal.(string); !ok || p != "*" { + return st, policyError("Principal is required and must be \"*\"") + } + + if err := checkActions(r.Action, r.NotAction); err != nil { + return st, err + } + + if err := checkResources(r.Resource, r.NotResource); err != nil { + return st, err + } + + if err := st.addConditions(r.Condition); err != nil { + return st, err + } + + return st, nil +} + +// addConditions checks a statement's Condition and records its ResourceType +// values. +func (st *policyStatement) addConditions(cond map[string]map[string]policyValues) error { + for op, keys := range cond { + if op != condStringEquals && op != condStringLike { + return policyError(fmt.Sprintf("unsupported condition [%s]; use StringEquals or StringLike", op)) + } + + for key, values := range keys { + if key != condResourceType { + return policyError(fmt.Sprintf("unsupported condition key [%s]; only ResourceType is allowed", key)) + } + + if op == condStringEquals { + st.typeEquals = append(st.typeEquals, values...) + } else { + st.typeLike = append(st.typeLike, values...) + } + } + } + + return nil +} + +func checkActions(actions, notActions []string) error { + if (len(actions) == 0) == (len(notActions) == 0) { + return policyError("each statement must have exactly one of Action and NotAction") + } + + valid := []string{StackPolicyModify, StackPolicyReplace, StackPolicyDelete, stackPolicyAll} + + for _, a := range slices.Concat(actions, notActions) { + if !slices.Contains(valid, a) { + return policyError(fmt.Sprintf("invalid action [%s]; valid actions are %s", a, policyActionValues)) + } + } + + return nil +} + +func checkResources(resources, notResources []string) error { + if (len(resources) == 0) == (len(notResources) == 0) { + return policyError("each statement must have exactly one of Resource and NotResource") + } + + for _, r := range slices.Concat(resources, notResources) { + if r != policyResourceAll && !strings.HasPrefix(r, policyResourcePfx) { + return policyError(fmt.Sprintf("invalid resource [%s]; use \"*\" or LogicalResourceId/", r)) + } + } + + return nil +} + +// Allows reports whether the policy allows action on a resource. When it does +// not, reason is the status reason CloudFormation fails the resource with. An +// explicit Deny wins over any Allow, and a resource no statement allows is +// denied. +func (p *StackPolicy) Allows(action, logicalID, resourceType string) (reason string, allowed bool) { + for i := range p.statements { + st := &p.statements[i] + if !st.allow && st.matches(action, logicalID, resourceType) { + return fmt.Sprintf(msgPolicyDenied, i+1, action, logicalID), false + } + } + + for i := range p.statements { + st := &p.statements[i] + if st.allow && st.matches(action, logicalID, resourceType) { + return "", true + } + } + + return fmt.Sprintf(msgPolicyNoAllow, action, logicalID), false +} + +func (st *policyStatement) matches(action, logicalID, resourceType string) bool { + return st.matchesAction(action) && st.matchesResource(logicalID) && st.matchesType(resourceType) +} + +func (st *policyStatement) matchesAction(action string) bool { + hit := func(list []string) bool { + return slices.ContainsFunc(list, func(a string) bool { return a == stackPolicyAll || a == action }) + } + + if len(st.actions) > 0 { + return hit(st.actions) + } + + return !hit(st.notActions) +} + +// matchesResource matches the logical id against Resource or NotResource. +// CloudFormation evaluates an Allow against the logical id and the resource +// type separately, and denies by default only when both deny. An Allow whose +// NotResource excludes a resource but that has no type condition therefore +// still allows the resource by its type. +func (st *policyStatement) matchesResource(logicalID string) bool { + hit := func(list []string) bool { + return slices.ContainsFunc(list, func(r string) bool { + if r == policyResourceAll { + return true + } + + ok, err := path.Match(strings.TrimPrefix(r, policyResourcePfx), logicalID) + + return err == nil && ok + }) + } + + if len(st.resources) > 0 { + return hit(st.resources) + } + + if st.allow && len(st.typeEquals) == 0 && len(st.typeLike) == 0 { + return true + } + + return !hit(st.notResources) +} + +func (st *policyStatement) matchesType(resourceType string) bool { + if len(st.typeEquals) == 0 && len(st.typeLike) == 0 { + return true + } + + if slices.Contains(st.typeEquals, resourceType) { + return true + } + + return slices.ContainsFunc(st.typeLike, func(pattern string) bool { + ok, err := path.Match(pattern, resourceType) + return err == nil && ok + }) +} diff --git a/services/cloudformation/stack_policy_test.go b/services/cloudformation/stack_policy_test.go new file mode 100644 index 000000000..b1550f68a --- /dev/null +++ b/services/cloudformation/stack_policy_test.go @@ -0,0 +1,104 @@ +package cloudformation_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +func TestParseStackPolicyRejectsBadDocuments(t *testing.T) { + tests := []struct { + name, body, want string + }{ + {"not json", `{"Statement":`, "not valid JSON"}, + {"no statement", `{}`, "Statement"}, + {"bad effect", `{"Statement":[{"Effect":"Maybe","Action":"Update:*","Principal":"*","Resource":"*"}]}`, "Effect"}, + {"bad action", `{"Statement":[{"Effect":"Allow","Action":"Update:Rename","Principal":"*","Resource":"*"}]}`, "Update:Rename"}, + {"no action", `{"Statement":[{"Effect":"Allow","Principal":"*","Resource":"*"}]}`, "Action"}, + {"both actions", `{"Statement":[{"Effect":"Allow","Action":"Update:*","NotAction":"Update:Delete",` + + `"Principal":"*","Resource":"*"}]}`, "Action"}, + {"principal not star", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"me","Resource":"*"}]}`, "Principal"}, + {"no principal", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Resource":"*"}]}`, "Principal"}, + {"bad resource", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"Table"}]}`, "Table"}, + {"no resource", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*"}]}`, "Resource"}, + {"bad condition", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*",` + + `"Condition":{"StringNotEquals":{"ResourceType":["AWS::S3::Bucket"]}}}]}`, "StringNotEquals"}, + {"bad condition key", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*",` + + `"Condition":{"StringEquals":{"LogicalId":["A"]}}}]}`, "LogicalId"}, + {"too long", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*",` + + `"Sid":"` + strings.Repeat("x", 16384) + `"}]}`, "16384"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := cfn.ParseStackPolicy(tc.body) + require.Error(t, err) + assert.True(t, cerrors.IsInvalidArgument(err), "a ValidationError") + assert.Contains(t, cerrors.Message(err), tc.want) + }) + } +} + +const tableDenyReplace = `{"Statement":[ + {"Effect":"Deny","Action":"Update:Replace","Principal":"*","Resource":"LogicalResourceId/Table"}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"} +]}` + +func TestStackPolicyEvaluation(t *testing.T) { + tests := []struct { + name, policy, action, id, rtype string + allowed bool + reason string + }{ + {"explicit deny", tableDenyReplace, cfn.StackPolicyReplace, "Table", "AWS::DynamoDB::Table", false, + "Action denied by stack policy: Statement [#1] does not allow [Update:Replace] for resource [LogicalResourceId/Table]"}, + {"deny does not cover modify", tableDenyReplace, cfn.StackPolicyModify, "Table", "AWS::DynamoDB::Table", true, ""}, + {"other resource allowed", tableDenyReplace, cfn.StackPolicyReplace, "Bucket", "AWS::S3::Bucket", true, ""}, + {"implicit deny", `{"Statement":{"Effect":"Allow","Action":"Update:Modify","Principal":"*",` + + `"Resource":"LogicalResourceId/Bucket"}}`, cfn.StackPolicyModify, "Table", "AWS::DynamoDB::Table", false, + "Action denied by stack policy: No statement allows [Update:Modify] for resource [LogicalResourceId/Table]"}, + {"wildcard logical id", `{"Statement":[{"Effect":"Allow","Action":["Update:Modify","Update:Replace"],` + + `"Principal":"*","Resource":["LogicalResourceId/Crit*"]}]}`, cfn.StackPolicyReplace, "CriticalDB", "T", true, ""}, + {"not action", `{"Statement":[{"Effect":"Allow","NotAction":"Update:Delete","Principal":"*","Resource":"*"}]}`, + cfn.StackPolicyDelete, "Table", "T", false, + "Action denied by stack policy: No statement allows [Update:Delete] for resource [LogicalResourceId/Table]"}, + {"not action allows others", `{"Statement":[{"Effect":"Allow","NotAction":"Update:Delete","Principal":"*",` + + `"Resource":"*"}]}`, cfn.StackPolicyModify, "Table", "T", true, ""}, + {"deny by type", `{"Statement":[ + {"Effect":"Deny","Action":"Update:*","Principal":"*","Resource":"*", + "Condition":{"StringEquals":{"ResourceType":["AWS::DynamoDB::Table"]}}}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}`, + cfn.StackPolicyModify, "Table", "AWS::DynamoDB::Table", false, + "Action denied by stack policy: Statement [#1] does not allow [Update:Modify] for resource [LogicalResourceId/Table]"}, + {"type like", `{"Statement":[ + {"Effect":"Deny","Action":"Update:*","Principal":"*","Resource":"*", + "Condition":{"StringLike":{"ResourceType":"AWS::EC2::*"}}}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}`, + cfn.StackPolicyModify, "Table", "AWS::DynamoDB::Table", true, ""}, + {"deny not resource", `{"Statement":[ + {"Effect":"Deny","Action":"Update:*","Principal":"*","NotResource":"LogicalResourceId/Bucket"}, + {"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}`, + cfn.StackPolicyModify, "Table", "T", false, + "Action denied by stack policy: Statement [#1] does not allow [Update:Modify] for resource [LogicalResourceId/Table]"}, + // An Allow with NotResource does not protect the excluded resource: + // its type is still allowed, as the AWS guide warns. + {"allow not resource does not protect", `{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*",` + + `"NotResource":"LogicalResourceId/Table"}]}`, cfn.StackPolicyModify, "Table", "T", true, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p, err := cfn.ParseStackPolicy(tc.policy) + require.NoError(t, err) + + reason, ok := p.Allows(tc.action, tc.id, tc.rtype) + assert.Equal(t, tc.allowed, ok) + assert.Equal(t, tc.reason, reason) + }) + } +} diff --git a/services/cloudformation/types.go b/services/cloudformation/types.go index 355328f60..1eb61b2d2 100644 --- a/services/cloudformation/types.go +++ b/services/cloudformation/types.go @@ -6,7 +6,7 @@ import ( ) // Stack status values, a subset of the real CloudFormation set covering the -// lifecycle the synchronous emulator models. +// lifecycle the emulator models. const ( StatusCreateInProgress = "CREATE_IN_PROGRESS" StatusCreateComplete = "CREATE_COMPLETE" @@ -95,6 +95,9 @@ type StackEvent struct { Status string StatusReason string Timestamp time.Time + // ClientRequestToken is the token of the operation that recorded the + // event. + ClientRequestToken string `json:",omitempty"` } // Stack is the full state of a deployed stack. @@ -164,6 +167,12 @@ type CreateStackInput struct { // RetainExceptOnCreate deletes the created resources on a rollback, // even those whose DeletionPolicy is Retain. RetainExceptOnCreate bool + // StackPolicyBody or StackPolicyURL sets the stack policy. + StackPolicyBody string + StackPolicyURL string + // ClientRequestToken identifies the request, so a retry is not taken + // for a second create. + ClientRequestToken string } // DeleteStackInput is the request to delete a stack. @@ -175,7 +184,8 @@ type DeleteStackInput struct { // DeletionMode is STANDARD (the default) or FORCE_DELETE_STACK, which // deletes a DELETE_FAILED stack and keeps the resources it cannot // delete. - DeletionMode string + DeletionMode string + ClientRequestToken string } // UpdateTerminationProtectionInput turns a stack's termination protection @@ -243,6 +253,39 @@ type UpdateStackInput struct { // NotificationARNs replaces the stack's topics. Nil keeps them. NotificationARNs []string + + // StackPolicyBody or StackPolicyURL replaces the stack policy. Neither + // keeps it. + StackPolicyBody string + StackPolicyURL string + // StackPolicyDuringUpdateBody or StackPolicyDuringUpdateURL overrides + // the stack policy for this update only. + StackPolicyDuringUpdateBody string + StackPolicyDuringUpdateURL string + ClientRequestToken string +} + +// SetStackPolicyInput sets a stack's policy from a body or an S3 URL. +type SetStackPolicyInput struct { + StackName string + StackPolicyBody string + StackPolicyURL string +} + +// CancelUpdateStackInput cancels a stack update that is in progress. +type CancelUpdateStackInput struct { + StackName string + ClientRequestToken string +} + +// RollbackStackInput rolls a CREATE_FAILED or UPDATE_FAILED stack back to its +// last stable state. +type RollbackStackInput struct { + StackName string + ClientRequestToken string + // RetainExceptOnCreate deletes the resources the failed operation + // created, even those whose DeletionPolicy is Retain. + RetainExceptOnCreate bool } // ContinueUpdateRollbackInput is the request to retry the rollback of a stack @@ -250,7 +293,8 @@ type UpdateStackInput struct { type ContinueUpdateRollbackInput struct { StackName string // ResourcesToSkip names failed resources the rollback leaves as they are. - ResourcesToSkip []string + ResourcesToSkip []string + ClientRequestToken string } // ValidateTemplateInput is the request to validate a template. TemplateBody @@ -318,4 +362,9 @@ type API interface { UpdateTerminationProtection(ctx context.Context, in *UpdateTerminationProtectionInput) (string, error) DescribeAccountLimits(ctx context.Context, nextToken string) ([]AccountLimit, error) EstimateTemplateCost(ctx context.Context, in *EstimateTemplateCostInput) (string, error) + + SetStackPolicy(ctx context.Context, in *SetStackPolicyInput) error + GetStackPolicy(ctx context.Context, stackName string) (string, error) + CancelUpdateStack(ctx context.Context, in *CancelUpdateStackInput) error + RollbackStack(ctx context.Context, in *RollbackStackInput) (string, error) }