diff --git a/compat/aws/cloudformation_compat_test.go b/compat/aws/cloudformation_compat_test.go index cf4ca2353..35531af60 100644 --- a/compat/aws/cloudformation_compat_test.go +++ b/compat/aws/cloudformation_compat_test.go @@ -6,6 +6,7 @@ import ( "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" cloudemu "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/internal/compat" @@ -89,6 +90,19 @@ func TestAWSCloudFormationCompat(t *testing.T) { return err }) + sess.Op(svc, "GetTemplateSummary", func() error { + out, err := client.GetTemplateSummary(ctx, &awscfn.GetTemplateSummaryInput{StackName: aws.String(stack)}) + if err != nil { + return err + } + + if len(out.ResourceTypes) != 2 { + return errCompat("expected 2 resource types") + } + + return nil + }) + sess.Op(svc, "UpdateStack", func() error { _, err := client.UpdateStack(ctx, &awscfn.UpdateStackInput{ StackName: aws.String(stack), @@ -102,12 +116,98 @@ func TestAWSCloudFormationCompat(t *testing.T) { return continueUpdateRollback(ctx, client) }) + changeSetOps(ctx, sess, client) + sess.Op(svc, "DeleteStack", func() error { _, err := client.DeleteStack(ctx, &awscfn.DeleteStackInput{StackName: aws.String(stack)}) 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) { + const ( + svc = "cloudformation" + stack = "compat-cs" + ) + + var id string + + sess.Op(svc, "CreateChangeSet", func() error { + out, err := client.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String(stack), ChangeSetName: aws.String("create"), + ChangeSetType: cfntypes.ChangeSetTypeCreate, TemplateBody: aws.String(changeSetTemplate), + }) + if err != nil { + return err + } + + id = aws.ToString(out.Id) + + return nil + }) + + sess.Op(svc, "DescribeChangeSet", func() error { + out, err := client.DescribeChangeSet(ctx, &awscfn.DescribeChangeSetInput{ChangeSetName: aws.String(id)}) + if err != nil { + return err + } + + if out.Status != cfntypes.ChangeSetStatusCreateComplete || len(out.Changes) != 1 { + return errCompat("change set " + string(out.Status)) + } + + return nil + }) + + sess.Op(svc, "ListChangeSets", func() error { + out, err := client.ListChangeSets(ctx, &awscfn.ListChangeSetsInput{StackName: aws.String(stack)}) + if err != nil { + return err + } + + if len(out.Summaries) != 1 { + return errCompat("expected 1 change set") + } + + return nil + }) + + sess.Op(svc, "ExecuteChangeSet", func() error { + if _, err := client.ExecuteChangeSet(ctx, &awscfn.ExecuteChangeSetInput{ChangeSetName: aws.String(id)}); err != nil { + return err + } + + out, err := client.DescribeStacks(ctx, &awscfn.DescribeStacksInput{StackName: aws.String(stack)}) + if err != nil { + return err + } + + if out.Stacks[0].StackStatus != cfntypes.StackStatusCreateComplete { + return errCompat("status " + string(out.Stacks[0].StackStatus)) + } + + return nil + }) + + sess.Op(svc, "DeleteChangeSet", func() error { + if _, err := client.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String(stack), ChangeSetName: aws.String("update"), UsePreviousTemplate: aws.Bool(true), + }); err != nil { + return err + } + + _, err := client.DeleteChangeSet(ctx, &awscfn.DeleteChangeSetInput{ + StackName: aws.String(stack), ChangeSetName: aws.String("update"), + }) + + return err + }) +} + +const changeSetTemplate = `{"Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"compat-cs-bucket"}}}}` + // continueUpdateRollback drives a stack into UPDATE_ROLLBACK_FAILED, where // Parameter Store refuses to move a parameter back from the Advanced tier, // then continues the rollback skipping it. diff --git a/docs/compat/README.md b/docs/compat/README.md index 2b9d1ac5b..48d7bbcd3 100644 --- a/docs/compat/README.md +++ b/docs/compat/README.md @@ -38,19 +38,25 @@ Each service's summary breaks the verified counts out per SDK/language. Only lan | Operation | AWS (CloudFormation) | |---|---| | ContinueUpdateRollback | ✅ | +| CreateChangeSet | ✅ | | CreateStack | ✅ | +| DeleteChangeSet | ✅ | | DeleteStack | ✅ | +| DescribeChangeSet | ✅ | | DescribeStackEvents | ✅ | | DescribeStackResources | ✅ | | DescribeStacks | ✅ | +| ExecuteChangeSet | ✅ | | GetTemplate | ✅ | +| GetTemplateSummary | ✅ | +| ListChangeSets | ✅ | | ListStackResources | ✅ | | ListStacks | ✅ | | UpdateStack | ✅ | | ValidateTemplate | ✅ | **cloudformation verified per language:** -- Go: AWS 11/11 +- Go: AWS 17/17 ## compute diff --git a/docs/compat/compat.json b/docs/compat/compat.json index 9cc0f2263..716f5f55f 100644 --- a/docs/compat/compat.json +++ b/docs/compat/compat.json @@ -329,6 +329,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "CreateChangeSet", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "CreateStack", @@ -342,6 +355,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "DeleteChangeSet", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "DeleteStack", @@ -355,6 +381,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "DescribeChangeSet", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "DescribeStackEvents", @@ -394,6 +433,19 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "ExecuteChangeSet", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "GetTemplate", @@ -407,6 +459,32 @@ }, "status": "green" }, + { + "service": "cloudformation", + "operation": "GetTemplateSummary", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, + { + "service": "cloudformation", + "operation": "ListChangeSets", + "providers": { + "aws": { + "native": "CloudFormation", + "clients": { + "sdkGo": "pass" + } + } + }, + "status": "green" + }, { "service": "cloudformation", "operation": "ListStackResources", diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 96c4e64b3..379b02027 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) | - | - | - | 11 | +| `cloudformation` | [CloudFormation](./aws/cloudformation.md) | - | - | - | 17 | | `cloudfront` | [CloudFront](./aws/cloudfront.md) | - | - | - | 11 | | `cloudids` | - | - | [CloudIDS](./gcp/cloudids.md) | - | 6 | | `cloudrun` | - | - | [CloudRun](./gcp/cloudrun.md) | - | 18 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index 61790088a..5227fdf33 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) | 11 | +| [CloudFormation](./cloudformation.md) | (provider-native) | 17 | | [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 85257673b..d507b2fbd 100644 --- a/docs/coverage/aws/cloudformation.md +++ b/docs/coverage/aws/cloudformation.md @@ -3,17 +3,23 @@ provider-native `cloudformation` wire service (AWS-only) · no portable driver · [AWS index](./README.md) -## Operations (11) +## Operations (17) | Operation | Description | | --- | --- | | `ContinueUpdateRollback` | | +| `CreateChangeSet` | | | `CreateStack` | | +| `DeleteChangeSet` | | | `DeleteStack` | | +| `DescribeChangeSet` | | | `DescribeStackEvents` | | | `DescribeStackResources` | | | `DescribeStacks` | | +| `ExecuteChangeSet` | | | `GetTemplate` | | +| `GetTemplateSummary` | | +| `ListChangeSets` | | | `ListStackResources` | | | `ListStacks` | | | `UpdateStack` | | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index c89c15b00..d5720306b 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -3137,12 +3137,21 @@ { "name": "ContinueUpdateRollback" }, + { + "name": "CreateChangeSet" + }, { "name": "CreateStack" }, + { + "name": "DeleteChangeSet" + }, { "name": "DeleteStack" }, + { + "name": "DescribeChangeSet" + }, { "name": "DescribeStackEvents" }, @@ -3152,9 +3161,18 @@ { "name": "DescribeStacks" }, + { + "name": "ExecuteChangeSet" + }, { "name": "GetTemplate" }, + { + "name": "GetTemplateSummary" + }, + { + "name": "ListChangeSets" + }, { "name": "ListStackResources" }, diff --git a/providers/aws/cloudformation/changeset.go b/providers/aws/cloudformation/changeset.go new file mode 100644 index 000000000..58678f07a --- /dev/null +++ b/providers/aws/cloudformation/changeset.go @@ -0,0 +1,807 @@ +package cloudformation + +import ( + "context" + "encoding/json" + "errors" + "maps" + "slices" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/pagination" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// Error and status texts of the change set operations. +const ( + msgNoChanges = "The submitted information didn't contain changes. " + + "Submit different information to create a change set." + msgStackMissing = "Stack [%s] does not exist" + msgStackExists = "Stack [%s] already exists and cannot be created again with the changeSet [%s]." + msgChangeSetExists = "ChangeSet [%s] already exists" + msgChangeSetNotFound = "ChangeSet [%s] does not exist" + msgNeedStackName = "StackName must be specified if ChangeSetName is not specified as an ARN." + msgCannotExecuteStatus = "ChangeSet [%s] cannot be executed in its current status of [%s]" + msgCannotExecuteExec = "ChangeSet [%s] cannot be executed in its current execution status of [%s]" + msgCannotDelete = "ChangeSet [%s] cannot be deleted in its current execution status of [%s]" + msgOnFailureDeleteUpdate = "OnStackFailure DELETE is only valid when ChangeSetType is CREATE." + msgBothFailureOptions = "You cannot specify both OnStackFailure and DisableRollback." + msgImportUnsupported = "ChangeSetType IMPORT is not supported." + msgInvalidNextToken = "Invalid NextToken" + msgFieldPattern = "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: " + + "Member must satisfy regular expression pattern: %s" + msgFieldLength = "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: " + + "Member must have length less than or equal to %d" + msgFieldEnum = "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: " + + "Member must satisfy enum value set: [%s]" +) + +const ( + // changeSetNamePattern is the ChangeSetName constraint. + changeSetNamePattern = "[a-zA-Z][-a-zA-Z0-9]*" + changeSetNameMaxLen = 128 + // changeSetsPageSize is the number of summaries a ListChangeSets page holds. + changeSetsPageSize = 100 + // changesPageBytes is the size at which DescribeChangeSet pages its + // changes. + changesPageBytes = 1 << 20 +) + +// changeSetRecord is a stored change set. ChangeSet is what reads report. +// The other fields are the request values the execution applies. Nil tags, +// capabilities and topics keep the stack's own. +type changeSetRecord struct { + ChangeSet cfn.ChangeSet `json:"changeSet"` + Template string `json:"template"` + Tags map[string]string `json:"tags"` + Capabilities []string `json:"capabilities"` + NotificationARNs []string `json:"notificationArns"` + // ClientToken is the CreateChangeSet token, and ExecuteToken the + // ExecuteChangeSet token of the execution that ran. + ClientToken string `json:"clientToken,omitempty"` + ExecuteToken string `json:"executeToken,omitempty"` +} + +// CreateChangeSet plans a change set. ChangeSetType CREATE plans a new stack +// and, when the name is free, records it as REVIEW_IN_PROGRESS. UPDATE, the +// default, plans an update of a live stack. The plan is worked out at once, so +// the change set is CREATE_COMPLETE and AVAILABLE, or FAILED when it would +// 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) { + if err := validateChangeSetInput(in); err != nil { + return nil, err + } + + if prior := m.retriedChangeSet(in); prior != nil { + return prior, nil + } + + var ( + sd *stackData + rec *changeSetRecord + err error + ) + + if in.ChangeSetType == cfn.ChangeSetTypeCreate { + sd, rec, err = m.planCreateChangeSet(ctx, in) + } else { + sd, rec, err = m.planUpdateChangeSet(ctx, in) + } + + if err != nil { + return nil, err + } + + out := rec.ChangeSet + + if aerr := sd.addChangeSet(rec); aerr != nil { + return nil, aerr + } + + return &out, nil +} + +// retriedChangeSet returns the change set an earlier request with the same +// name and ClientToken made, or nil. +func (m *Mock) retriedChangeSet(in *cfn.CreateChangeSetInput) *cfn.ChangeSet { + if in.ClientToken == "" { + return nil + } + + sd, _, ok := m.findStack(in.StackName) + if !ok { + return nil + } + + rec := sd.changeSet(func(cs *cfn.ChangeSet) bool { return cs.Name == in.ChangeSetName }) + if rec == nil { + return nil + } + + sd.mu.RLock() + defer sd.mu.RUnlock() + + if rec.ClientToken != in.ClientToken { + return nil + } + + out := rec.ChangeSet + + return &out +} + +// validateChangeSetInput checks the request fields and defaults the type. +func validateChangeSetInput(in *cfn.CreateChangeSetInput) error { + if in.StackName == "" { + return cerrors.New(cerrors.InvalidArgument, "StackName is required") + } + + if err := checkChangeSetName(in.ChangeSetName); err != nil { + return err + } + + if in.ChangeSetType == "" { + in.ChangeSetType = cfn.ChangeSetTypeUpdate + } + + types := []string{cfn.ChangeSetTypeCreate, cfn.ChangeSetTypeUpdate, cfn.ChangeSetTypeImport} + if !slices.Contains(types, in.ChangeSetType) { + return cerrors.Newf(cerrors.InvalidArgument, msgFieldEnum, in.ChangeSetType, "changeSetType", strings.Join(types, ", ")) + } + + if in.ChangeSetType == cfn.ChangeSetTypeImport { + return cerrors.New(cerrors.InvalidArgument, msgImportUnsupported) + } + + modes := []string{cfn.OnStackFailureDoNothing, cfn.OnStackFailureRollback, cfn.OnStackFailureDelete} + if in.OnStackFailure != "" && !slices.Contains(modes, in.OnStackFailure) { + return cerrors.Newf(cerrors.InvalidArgument, msgFieldEnum, in.OnStackFailure, "onStackFailure", strings.Join(modes, ", ")) + } + + if in.OnStackFailure == cfn.OnStackFailureDelete && in.ChangeSetType != cfn.ChangeSetTypeCreate { + return cerrors.New(cerrors.InvalidArgument, msgOnFailureDeleteUpdate) + } + + return nil +} + +func checkChangeSetName(name string) error { + if len(name) > changeSetNameMaxLen { + return cerrors.Newf(cerrors.InvalidArgument, msgFieldLength, name, "changeSetName", changeSetNameMaxLen) + } + + if !validChangeSetName(name) { + return cerrors.Newf(cerrors.InvalidArgument, msgFieldPattern, name, "changeSetName", changeSetNamePattern) + } + + return nil +} + +// validChangeSetName reports whether name matches changeSetNamePattern. +func validChangeSetName(name string) bool { + for i, r := range name { + letter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + if !letter && (i == 0 || (r != '-' && (r < '0' || r > '9'))) { + return false + } + } + + return name != "" +} + +// planCreateChangeSet plans a CREATE change set. It joins a stack already in +// REVIEW_IN_PROGRESS, or makes one once the template checks out. +func (m *Mock) planCreateChangeSet(ctx context.Context, in *cfn.CreateChangeSetInput) (*stackData, *changeSetRecord, error) { + sd, err := m.reviewStack(in) + if err != nil { + return nil, nil, err + } + + if sd != nil { + name, stackID := sd.identity() + + rec, perr := m.planNewStack(ctx, in, name, stackID) + + return sd, rec, perr + } + + name, stackID := in.StackName, m.newStackID(in.StackName) + + rec, err := m.planNewStack(ctx, in, name, stackID) + if err != nil { + return nil, nil, err + } + + sd = &stackData{ + resolved: map[string]cfn.ResolvedResource{}, + deleteIDs: map[string]string{}, + props: map[string]map[string]any{}, + stack: cfn.Stack{ + ID: stackID, Name: name, Status: cfn.StatusReviewInProgress, CreationTime: m.clock.Now(), + }, + } + + if !m.claimStackSlot(name, sd) { + return nil, nil, cerrors.Newf(cerrors.InvalidArgument, msgStackExists, name, in.ChangeSetName) + } + + m.emitStackEvent(sd, cfn.StatusReviewInProgress, reasonUserInitiated) + + return sd, rec, nil +} + +// reviewStack returns the REVIEW_IN_PROGRESS stack a CREATE change set +// joins, or nil when the name is free. +func (m *Mock) reviewStack(in *cfn.CreateChangeSetInput) (*stackData, error) { + sd, byID, found := m.findStack(in.StackName) + if found && sd.status() == cfn.StatusDeleteComplete { + found = false + } + + switch { + case found && sd.status() != cfn.StatusReviewInProgress: + return nil, cerrors.Newf(cerrors.InvalidArgument, msgStackExists, in.StackName, in.ChangeSetName) + case !found && byID: + return nil, cerrors.Newf(cerrors.InvalidArgument, msgStackMissing, in.StackName) + case !found: + return nil, nil + } + + return sd, nil +} + +// planNewStack checks a CREATE change set's template and parameters and +// lists every resource as an Add. +func (m *Mock) planNewStack(ctx context.Context, in *cfn.CreateChangeSetInput, name, stackID string) (*changeSetRecord, error) { + body, err := m.templateBody(ctx, in.TemplateBody, in.TemplateURL) + if err != nil { + return nil, err + } + + t, err := cfn.ParseTemplate(body) + if err != nil { + return nil, err + } + + params, values, err := m.mergeParameters(ctx, t, in.Parameters) + if err != nil { + return nil, err + } + + res := m.newResolver(name, stackID, values, in.NotificationARNs) + + effective, err := res.Prepare(t) + if err != nil { + return nil, err + } + + if cerr := cfn.CheckCapabilities(t, in.Capabilities); cerr != nil { + return nil, cerr + } + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + New: effective, NewProps: resolvableProps(effective, res), Registry: m.registry, + }) + + rec := m.newRecord(in, name, stackID, body, params) + rec.ChangeSet.Changes = changes + rec.ChangeSet.Tags = in.Tags + rec.ChangeSet.NotificationARNs = in.NotificationARNs + + return rec, nil +} + +// planUpdateChangeSet plans an UPDATE change set with the same planner +// UpdateStack uses. +func (m *Mock) planUpdateChangeSet(ctx context.Context, in *cfn.CreateChangeSetInput) (*stackData, *changeSetRecord, error) { + sd, _, ok := m.findStack(in.StackName) + if !ok || sd.status() == cfn.StatusDeleteComplete { + return nil, nil, cerrors.Newf(cerrors.InvalidArgument, msgStackMissing, in.StackName) + } + + if err := checkUpdatable(sd); err != nil { + return nil, nil, err + } + + uin := &cfn.UpdateStackInput{ + StackName: in.StackName, TemplateBody: in.TemplateBody, TemplateURL: in.TemplateURL, + Parameters: in.Parameters, Tags: in.Tags, Capabilities: in.Capabilities, + UsePreviousTemplate: in.UsePreviousTemplate, NotificationARNs: in.NotificationARNs, + } + + plan, err := m.planUpdate(ctx, sd, uin) + if err != nil { + return nil, nil, err + } + + name, id := sd.identity() + rec := m.newRecord(in, name, id, plan.body, plan.params) + rec.ChangeSet.NotificationARNs = plan.notificationARNs + + rec.ChangeSet.Tags = in.Tags + if in.Tags == nil { + rec.ChangeSet.Tags = sd.snapshotStack().Tags + } + + if m.noChanges(sd, plan, uin) { + rec.ChangeSet.Status = cfn.ChangeSetStatusFailed + rec.ChangeSet.StatusReason = msgNoChanges + rec.ChangeSet.ExecutionStatus = cfn.ExecutionUnavailable + + return sd, rec, nil + } + + rec.ChangeSet.Changes = m.planChanges(sd, plan) + + return sd, rec, nil +} + +// newRecord builds an AVAILABLE change set record from a request. +func (m *Mock) newRecord(in *cfn.CreateChangeSetInput, stackName, stackID, body string, params []cfn.Parameter) *changeSetRecord { + return &changeSetRecord{ + ChangeSet: cfn.ChangeSet{ + ID: idgen.AWSARN("cloudformation", m.region, m.accountID, "changeSet/"+in.ChangeSetName+"/"+idgen.UUID()), + Name: in.ChangeSetName, StackID: stackID, StackName: stackName, Type: in.ChangeSetType, + Description: in.Description, Status: cfn.ChangeSetStatusCreateComplete, + ExecutionStatus: cfn.ExecutionAvailable, CreationTime: m.clock.Now(), Parameters: params, + Capabilities: in.Capabilities, OnStackFailure: in.OnStackFailure, + }, + Template: body, Tags: in.Tags, Capabilities: in.Capabilities, NotificationARNs: in.NotificationARNs, + ClientToken: in.ClientToken, + } +} + +// planChanges lists what an update plan does to the stack's resources. +func (m *Mock) planChanges(sd *stackData, plan *updatePlan) []cfn.ResourceChange { + sd.mu.RLock() + live := make(map[string]cfn.LiveResource, len(sd.resolved)) + + for id, rr := range sd.resolved { + live[id] = cfn.LiveResource{PhysicalID: rr.RefValue, Props: sd.props[id]} + } + sd.mu.RUnlock() + + for id, lr := range live { + lr.Type = sd.rowType(id) + live[id] = lr + } + + seedResolver(sd, plan.newT, plan.newRes) + + prior := paramValuesFrom(plan.prior.params) + changed := map[string]bool{} + + for name, v := range paramValuesFrom(plan.params) { + if old, ok := prior[name]; !ok || old != v { + changed[name] = true + } + } + + return cfn.PlanChanges(&cfn.ChangePlanInput{ + Old: plan.oldT, New: plan.newT, Live: live, NewProps: resolvableProps(plan.newT, plan.newRes), + ChangedParams: changed, Registry: m.registry, + }) +} + +// resolvableProps resolves each resource's properties that can be resolved +// before execution. +func resolvableProps(t *cfn.Template, res *cfn.Resolver) map[string]map[string]any { + out := make(map[string]map[string]any, len(t.Resources)) + + for id, rdef := range t.Resources { + if props, err := resolveProps(res, rdef.Properties); err == nil { + out[id] = props + } + } + + return out +} + +// addChangeSet stores a change set, refusing a duplicate name and a stack +// that no longer accepts one of its type. +func (sd *stackData) addChangeSet(rec *changeSetRecord) error { + sd.mu.Lock() + defer sd.mu.Unlock() + + cs := &rec.ChangeSet + + if slices.ContainsFunc(sd.changeSets, func(r *changeSetRecord) bool { return r.ChangeSet.Name == cs.Name }) { + return cfn.NewException(cfn.ExceptionAlreadyExists, cerrors.Newf(cerrors.AlreadyExists, msgChangeSetExists, cs.Name)) + } + + review := sd.stack.Status == cfn.StatusReviewInProgress + + switch { + case cs.Type == cfn.ChangeSetTypeCreate && !review: + return cerrors.Newf(cerrors.InvalidArgument, msgStackExists, cs.StackName, cs.Name) + case cs.Type != cfn.ChangeSetTypeCreate && !updatableStatus(sd.stack.Status): + return cerrors.Newf(cerrors.InvalidArgument, msgStackCannotUpdate, sd.stack.ID, sd.stack.Status) + } + + sd.changeSets = append(sd.changeSets, rec) + + return nil +} + +// lookupChangeSet finds a change set by ARN, or by name within stackName. +func (m *Mock) lookupChangeSet(nameOrID, stackName string) (*stackData, *changeSetRecord, error) { + notFound := cfn.NewException(cfn.ExceptionChangeSetNotFound, + cerrors.Newf(cerrors.NotFound, msgChangeSetNotFound, nameOrID)) + + if strings.HasPrefix(nameOrID, "arn:") { + for _, sd := range m.sortedStacks() { + if rec := sd.changeSet(func(cs *cfn.ChangeSet) bool { return cs.ID == nameOrID }); rec != nil { + return sd, rec, nil + } + } + + return nil, nil, notFound + } + + if stackName == "" { + return nil, nil, cerrors.New(cerrors.InvalidArgument, msgNeedStackName) + } + + sd, _, ok := m.findStack(stackName) + if !ok { + return nil, nil, notFound + } + + rec := sd.changeSet(func(cs *cfn.ChangeSet) bool { return cs.Name == nameOrID }) + if rec == nil { + return nil, nil, notFound + } + + return sd, rec, nil +} + +func (sd *stackData) changeSet(match func(*cfn.ChangeSet) bool) *changeSetRecord { + sd.mu.RLock() + defer sd.mu.RUnlock() + + for _, rec := range sd.changeSets { + if match(&rec.ChangeSet) { + return rec + } + } + + return nil +} + +// view returns a copy of a change set for a reader, with NoEcho parameter +// values masked. +func (sd *stackData) view(rec *changeSetRecord) cfn.ChangeSet { + sd.mu.RLock() + defer sd.mu.RUnlock() + + cs := rec.ChangeSet + cs.Parameters = maskParameters(cs.Parameters) + cs.Tags = maps.Clone(cs.Tags) + cs.Capabilities = slices.Clone(cs.Capabilities) + cs.NotificationARNs = slices.Clone(cs.NotificationARNs) + cs.Changes = slices.Clone(cs.Changes) + + return cs +} + +// 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) { + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) + if err != nil { + return nil, err + } + + cs := sd.view(rec) + + if !in.IncludePropertyValues { + for i := range cs.Changes { + stripValues(&cs.Changes[i]) + } + } + + cs.Changes, cs.NextToken, err = pageChanges(cs.Changes, in.NextToken) + if err != nil { + return nil, err + } + + return &cs, nil +} + +// stripValues drops a change's property values and contexts. The details +// are copied first, since the stored change shares them. +func stripValues(c *cfn.ResourceChange) { + c.BeforeContext, c.AfterContext = "", "" + c.Details = slices.Clone(c.Details) + + for i := range c.Details { + t := &c.Details[i].Target + t.BeforeValue, t.AfterValue, t.AttributeChangeType = "", "", "" + } +} + +// pageChanges returns the changes from token on that fit in one response. +// A page always holds at least one change. +func pageChanges(changes []cfn.ResourceChange, token string) ([]cfn.ResourceChange, string, error) { + pt, err := pagination.DecodeToken(token) + if err != nil || pt.Offset > len(changes) { + return nil, "", cerrors.New(cerrors.InvalidArgument, msgInvalidNextToken) + } + + end, size := pt.Offset, 0 + + for end < len(changes) { + b, _ := json.Marshal(changes[end]) + if end > pt.Offset && size+len(b) > changesPageBytes { + break + } + + size += len(b) + end++ + } + + next := "" + if end < len(changes) { + next = pagination.EncodeToken(end) + } + + return changes[pt.Offset:end], next, nil +} + +// 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) { + sd, err := m.activeStack(in.StackName) + if err != nil { + return nil, err + } + + sd.mu.RLock() + all := make([]cfn.ChangeSet, 0, len(sd.changeSets)) + + for _, rec := range sd.changeSets { + cs := rec.ChangeSet + cs.Parameters, cs.Tags, cs.Capabilities, cs.NotificationARNs, cs.Changes = nil, nil, nil, nil, nil + all = append(all, cs) + } + sd.mu.RUnlock() + + page, err := pagination.Paginate(all, in.NextToken, changeSetsPageSize) + if err != nil { + return nil, cerrors.New(cerrors.InvalidArgument, msgInvalidNextToken) + } + + return &cfn.ChangeSetList{Summaries: page.Items, NextToken: page.NextPageToken}, nil +} + +// ExecuteChangeSet runs an AVAILABLE change set. The stack's other change +// 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 { + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) + if err != nil { + return err + } + + sd.mu.RLock() + retried := in.ClientRequestToken != "" && rec.ExecuteToken == in.ClientRequestToken + err = executable(rec) + sd.mu.RUnlock() + + if retried { + return nil + } + + if err != nil { + return err + } + + onFailure, err := failureMode(rec.ChangeSet.OnStackFailure, in.DisableRollback) + if err != nil { + return err + } + + run := execution{rec: rec, onFailure: onFailure, token: in.ClientRequestToken} + + if rec.ChangeSet.Type == cfn.ChangeSetTypeCreate { + return m.executeCreate(ctx, sd, &run) + } + + return m.executeUpdate(ctx, sd, &run) +} + +// execution is one ExecuteChangeSet call. +type execution struct { + rec *changeSetRecord + onFailure string + token string +} + +// executable rejects a change set that cannot run. The caller holds the +// stack lock. +func executable(rec *changeSetRecord) error { + cs := &rec.ChangeSet + + if cs.Status != cfn.ChangeSetStatusCreateComplete { + return cfn.NewException(cfn.ExceptionInvalidChangeSetStatus, + cerrors.Newf(cerrors.FailedPrecondition, msgCannotExecuteStatus, cs.ID, cs.Status)) + } + + if cs.ExecutionStatus != cfn.ExecutionAvailable { + return cfn.NewException(cfn.ExceptionInvalidChangeSetStatus, + cerrors.Newf(cerrors.FailedPrecondition, msgCannotExecuteExec, cs.ID, cs.ExecutionStatus)) + } + + return nil +} + +// failureMode resolves what a failed execution does. +func failureMode(onStackFailure string, disableRollback *bool) (string, error) { + switch { + case onStackFailure != "" && disableRollback != nil: + return "", cerrors.New(cerrors.InvalidArgument, msgBothFailureOptions) + case onStackFailure != "": + return onStackFailure, nil + case disableRollback != nil && *disableRollback: + return cfn.OnStackFailureDoNothing, nil + default: + return cfn.OnStackFailureRollback, nil + } +} + +// executeCreate creates the resources of a REVIEW_IN_PROGRESS stack. +func (m *Mock) executeCreate(ctx context.Context, sd *stackData, run *execution) error { + rec := run.rec + + t, err := cfn.ParseTemplate(rec.Template) + if err != nil { + return err + } + + name, id := sd.identity() + res := m.newResolver(name, id, paramValuesFrom(rec.ChangeSet.Parameters), rec.NotificationARNs) + + effective, err := res.Prepare(t) + if err != nil { + return err + } + + isReview := func(s string) bool { return s == cfn.StatusReviewInProgress } + + err = m.beginExecute(sd, run, isReview, cfn.StatusCreateInProgress, func(s *cfn.Stack) { + s.Parameters = rec.ChangeSet.Parameters + s.Description = t.Description + s.Tags = rec.Tags + s.Capabilities = rec.Capabilities + s.TemplateBody = rec.Template + s.NotificationARNs = rec.NotificationARNs + s.LastUpdated = m.clock.Now() + }) + if err != nil { + return err + } + + sd.finishExecute(rec, m.provision(ctx, sd, effective, res, run.onFailure)) + + return nil +} + +// executeUpdate applies an UPDATE change set through the UpdateStack path. +func (m *Mock) executeUpdate(ctx context.Context, sd *stackData, run *execution) error { + rec := run.rec + name, _ := sd.identity() + in := &cfn.UpdateStackInput{ + StackName: name, TemplateBody: rec.Template, Parameters: rec.ChangeSet.Parameters, + Tags: rec.Tags, Capabilities: rec.Capabilities, NotificationARNs: rec.NotificationARNs, + } + + plan, err := m.planUpdate(ctx, sd, in) + if err != nil { + return err + } + + if berr := m.beginExecute(sd, run, updatableStatus, cfn.StatusUpdateInProgress, nil); berr != nil { + return berr + } + + sd.finishExecute(rec, m.runUpdate(ctx, sd, in, plan, run.onFailure)) + + return nil +} + +// beginExecute starts a change set's execution in one locked step: it +// checks the change set and the stack status, deletes the stack's other +// change sets, applies setup to the stack and moves it to status. +func (m *Mock) beginExecute( + sd *stackData, run *execution, allowed func(string) bool, status string, setup func(*cfn.Stack), +) error { + sd.mu.Lock() + defer sd.mu.Unlock() + + rec := run.rec + + if err := executable(rec); err != nil { + return err + } + + if !allowed(sd.stack.Status) { + return cerrors.Newf(cerrors.InvalidArgument, msgStackCannotUpdate, sd.stack.ID, sd.stack.Status) + } + + rec.ChangeSet.ExecutionStatus = cfn.ExecutionInProgress + rec.ExecuteToken = run.token + sd.changeSets = []*changeSetRecord{rec} + + if setup != nil { + setup(&sd.stack) + } + + sd.stack.ChangeSetID = rec.ChangeSet.ID + sd.stack.DisableRollback = run.onFailure == cfn.OnStackFailureDoNothing + sd.stack.Status = status + sd.stack.StatusReason = reasonUserInitiated + sd.stack.Events = append(sd.stack.Events, + m.event(sd, sd.stack.Name, sd.stack.ID, stackResourceType, status, reasonUserInitiated)) + + return nil +} + +// finishExecute records how an execution ended. +func (sd *stackData) finishExecute(rec *changeSetRecord, ok bool) { + sd.mu.Lock() + defer sd.mu.Unlock() + + rec.ChangeSet.ExecutionStatus = cfn.ExecutionComplete + if !ok { + rec.ChangeSet.ExecutionStatus = cfn.ExecutionFailed + } +} + +// obsoleteChangeSets marks the change sets an update made outdated. +func (sd *stackData) obsoleteChangeSets() { + sd.mu.Lock() + defer sd.mu.Unlock() + + for _, rec := range sd.changeSets { + if rec.ChangeSet.ExecutionStatus == cfn.ExecutionAvailable { + rec.ChangeSet.ExecutionStatus = cfn.ExecutionObsolete + } + } +} + +// DeleteChangeSet deletes a change set that is not executing. A stack in +// 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 { + sd, rec, err := m.lookupChangeSet(in.ChangeSetName, in.StackName) + + var named *cfn.ExceptionError + if errors.As(err, &named) && named.Exception() == cfn.ExceptionChangeSetNotFound && in.StackName != "" && + !strings.HasPrefix(in.ChangeSetName, "arn:") { + if _, serr := m.activeStack(in.StackName); serr != nil { + return cerrors.Newf(cerrors.InvalidArgument, msgStackMissing, in.StackName) + } + + return nil + } + + if err != nil { + return err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if cs := &rec.ChangeSet; cs.ExecutionStatus == cfn.ExecutionInProgress { + return cfn.NewException(cfn.ExceptionInvalidChangeSetStatus, + cerrors.Newf(cerrors.FailedPrecondition, msgCannotDelete, cs.ID, cs.ExecutionStatus)) + } + + sd.changeSets = slices.DeleteFunc(sd.changeSets, func(r *changeSetRecord) bool { return r == rec }) + + return nil +} diff --git a/providers/aws/cloudformation/changeset_test.go b/providers/aws/cloudformation/changeset_test.go new file mode 100644 index 000000000..6dcfa0d63 --- /dev/null +++ b/providers/aws/cloudformation/changeset_test.go @@ -0,0 +1,891 @@ +package cloudformation + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +const csV1 = `{"Resources":{ + "P":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v1"}}, + "Q":{"Type":"Test::Param","Properties":{"Name":"/q","Value":"q"}} +}}` + +// csV2 changes P in place and renames Q, which replaces it. +const csV2 = `{"Resources":{ + "P":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v2"}}, + "Q":{"Type":"Test::Param","Properties":{"Name":"/q2","Value":"q"}} +}}` + +const csFailing = `{"Resources":{ + "P":{"Type":"Test::Param","Properties":{"Name":"/p","Value":"v1"}}, + "Bad":{"Type":"Test::Boom","DependsOn":"P"} +}}` + +func createCS(t *testing.T, m *Mock, in *cfn.CreateChangeSetInput) *cfn.ChangeSet { + t.Helper() + + cs, err := m.CreateChangeSet(context.Background(), in) + requireNoError(t, err) + + return cs +} + +func describeCS(t *testing.T, m *Mock, stack, name string) *cfn.ChangeSet { + t.Helper() + + cs, err := m.DescribeChangeSet(context.Background(), &cfn.DescribeChangeSetInput{StackName: stack, ChangeSetName: name}) + requireNoError(t, err) + + return cs +} + +func assertException(t *testing.T, err error, exception, msg string) { + t.Helper() + + var named *cfn.ExceptionError + if !errors.As(err, &named) { + t.Fatalf("want %s, got %v", exception, err) + } + + assertEqual(t, named.Exception(), exception, "exception") + + if msg != "" { + assertEqual(t, cerrors.Message(err), msg, "message") + } +} + +func assertValidation(t *testing.T, err error, msg string) { + t.Helper() + + if !cerrors.IsInvalidArgument(err) && !cerrors.IsNotFound(err) { + t.Fatalf("want a ValidationError, got %v", err) + } + + var named *cfn.ExceptionError + if errors.As(err, &named) { + t.Fatalf("want a ValidationError, got %s", named.Exception()) + } + + if msg != "" { + assertEqual(t, cerrors.Message(err), msg, "message") + } +} + +func TestCreateChangeSetCreateMakesReviewStack(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "cs1", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + Description: "first", Tags: map[string]string{"team": "a"}, + }) + + if !strings.Contains(cs.ID, ":changeSet/cs1/") || !strings.HasPrefix(cs.ID, "arn:aws:cloudformation:us-east-1:123456789012:") { + t.Fatalf("change set id %q", cs.ID) + } + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusReviewInProgress, "stack status") + assertEqual(t, st.StatusReason, "User Initiated", "stack reason") + assertEqual(t, cs.StackID, st.ID, "stack id") + assertEqual(t, len(st.Resources), 0, "no resources yet") + assertEqual(t, len(p.values), 0, "nothing provisioned") + + if !st.LastUpdated.IsZero() { + t.Fatalf("a review stack has no LastUpdatedTime") + } + + events, err := m.DescribeStackEvents(ctx, "s") + requireNoError(t, err) + assertEqual(t, len(events), 1, "events") + assertEqual(t, events[0].Status, cfn.StatusReviewInProgress, "event status") + + got := describeCS(t, m, "s", "cs1") + assertEqual(t, got.Status, cfn.ChangeSetStatusCreateComplete, "status") + assertEqual(t, got.ExecutionStatus, cfn.ExecutionAvailable, "execution status") + assertEqual(t, got.Description, "first", "description") + assertEqual(t, got.Tags["team"], "a", "tags") + assertEqual(t, len(got.Changes), 2, "changes") + assertEqual(t, got.Changes[0].Action, cfn.ChangeActionAdd, "action") + assertEqual(t, got.Changes[0].LogicalID, "P", "logical id") + assertEqual(t, got.Changes[0].ResourceType, "Test::Param", "type") + + // The review stack holds the name: CreateStack and UpdateStack refuse it. + _, err = m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + if !cerrors.IsAlreadyExists(err) { + t.Fatalf("CreateStack on a review stack: %v", err) + } + + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: csV1}) + assertValidation(t, err, "Stack:"+st.ID+" is in REVIEW_IN_PROGRESS state and can not be updated.") +} + +func TestExecuteCreateChangeSet(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + cs1 := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "cs1", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + cs2 := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "cs2", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV2, + }) + assertEqual(t, cs2.StackID, cs1.StackID, "second CREATE change set joins the review stack") + + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs1.ID})) + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusCreateComplete, "stack status") + assertEqual(t, st.ChangeSetID, cs1.ID, "stack change set id") + assertEqual(t, p.values["/p"], "v1", "P created") + assertEqual(t, p.values["/q"], "q", "Q created") + + got := describeCS(t, m, "s", "cs1") + assertEqual(t, got.ExecutionStatus, cfn.ExecutionComplete, "execution status") + + _, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{StackName: "s", ChangeSetName: "cs2"}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "ChangeSet [cs2] does not exist") + + list, err := m.ListChangeSets(ctx, &cfn.ListChangeSetsInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, len(list.Summaries), 1, "only the executed change set is left") + + events, err := m.DescribeStackEvents(ctx, "s") + requireNoError(t, err) + assertEqual(t, events[0].Status, cfn.StatusCreateComplete, "last event") + assertEqual(t, events[len(events)-1].Status, cfn.StatusReviewInProgress, "first event") +} + +func TestCreateChangeSetErrors(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "live", TemplateBody: csV1}) + requireNoError(t, err) + createCS(t, m, &cfn.CreateChangeSetInput{StackName: "review", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1}) + + reviewID := stackStatus(t, m, "review").ID + + tests := []struct { + name string + in cfn.CreateChangeSetInput + exc string + msg string + }{ + {"update missing stack", cfn.CreateChangeSetInput{StackName: "nope", ChangeSetName: "c", TemplateBody: csV1}, + "", "Stack [nope] does not exist"}, + {"create over live stack", cfn.CreateChangeSetInput{ + StackName: "live", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }, "", "Stack [live] already exists and cannot be created again with the changeSet [c]."}, + {"update review stack", cfn.CreateChangeSetInput{StackName: "review", ChangeSetName: "u", TemplateBody: csV1}, + "", "Stack:" + reviewID + " is in REVIEW_IN_PROGRESS state and can not be updated."}, + {"duplicate name", cfn.CreateChangeSetInput{ + StackName: "review", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }, cfn.ExceptionAlreadyExists, "ChangeSet [c] already exists"}, + {"bad name", cfn.CreateChangeSetInput{StackName: "live", ChangeSetName: "1bad", TemplateBody: csV2}, + "", "1 validation error detected: Value '1bad' at 'changeSetName' failed to satisfy constraint: " + + "Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*"}, + {"bad type", cfn.CreateChangeSetInput{StackName: "live", ChangeSetName: "c", ChangeSetType: "MERGE", TemplateBody: csV2}, + "", "1 validation error detected: Value 'MERGE' at 'changeSetType' failed to satisfy constraint: " + + "Member must satisfy enum value set: [CREATE, UPDATE, IMPORT]"}, + {"delete on update", cfn.CreateChangeSetInput{ + StackName: "live", ChangeSetName: "c", TemplateBody: csV2, OnStackFailure: cfn.OnStackFailureDelete, + }, "", msgOnFailureDeleteUpdate}, + {"bad template", cfn.CreateChangeSetInput{ + StackName: "fresh", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: "{", + }, "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, cerr := m.CreateChangeSet(ctx, &tc.in) + if tc.exc != "" { + assertException(t, cerr, tc.exc, tc.msg) + return + } + + assertValidation(t, cerr, tc.msg) + }) + } + + // A template error on a CREATE change set leaves no review stack behind. + if _, err = m.DescribeStacks(ctx, "fresh"); err == nil { + t.Fatalf("a failed CREATE change set must not create a stack") + } +} + +func TestUpdateChangeSetDescribesAndExecutes(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "upd", TemplateBody: csV2}) + other := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "other", TemplateBody: csV2}) + + got := describeCS(t, m, "s", cs.ID) + assertEqual(t, got.Status, cfn.ChangeSetStatusCreateComplete, "status") + assertEqual(t, len(got.Changes), 2, "changes") + + pc, qc := got.Changes[0], got.Changes[1] + assertEqual(t, pc.LogicalID+pc.Action+pc.Replacement+pc.PhysicalID, "PModifyFalse/p", "P change") + assertEqual(t, pc.Details[0].Target.Name, "Value", "P detail") + assertEqual(t, pc.Details[0].Target.RequiresRecreation, cfn.RecreationNever, "P recreation") + assertEqual(t, qc.LogicalID+qc.Action+qc.Replacement+qc.PolicyAction, "QModifyTrueReplaceAndDelete", "Q change") + assertEqual(t, qc.Details[0].Target.RequiresRecreation, cfn.RecreationAlways, "Q recreation") + + if got.Changes[0].Details[0].Target.BeforeValue != "" { + t.Fatalf("property values are only returned with IncludePropertyValues") + } + + withValues, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: cs.ID, IncludePropertyValues: true}) + requireNoError(t, err) + assertEqual(t, withValues.Changes[0].Details[0].Target.BeforeValue, "v1", "before value") + assertEqual(t, withValues.Changes[0].Details[0].Target.AfterValue, "v2", "after value") + + // Nothing changes until the change set runs. + assertEqual(t, p.values["/p"], "v1", "P untouched") + + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{StackName: "s", ChangeSetName: "upd"})) + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "stack status") + assertEqual(t, p.values["/p"], "v2", "P updated in place") + assertEqual(t, p.values["/q2"], "q", "Q replaced") + + if _, ok := p.values["/q"]; ok { + t.Fatalf("old Q must be deleted in cleanup") + } + + assertEqual(t, describeCS(t, m, "s", "upd").ExecutionStatus, cfn.ExecutionComplete, "executed") + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: other.ID}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "") +} + +func TestNoChangeChangeSetFails(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "same", TemplateBody: csV1}) + + got := describeCS(t, m, "s", "same") + assertEqual(t, got.Status, cfn.ChangeSetStatusFailed, "status") + assertEqual(t, got.ExecutionStatus, cfn.ExecutionUnavailable, "execution status") + assertEqual(t, got.StatusReason, msgNoChanges, "reason") + assertEqual(t, len(got.Changes), 0, "changes") + + err = m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID}) + assertException(t, err, cfn.ExceptionInvalidChangeSetStatus, + "ChangeSet ["+cs.ID+"] cannot be executed in its current status of [FAILED]") + + // UsePreviousTemplate with no other change is also empty. + createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "prev", UsePreviousTemplate: true}) + assertEqual(t, describeCS(t, m, "s", "prev").Status, cfn.ChangeSetStatusFailed, "previous template") +} + +func TestExecuteChangeSetStatusErrors(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + done := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "done", TemplateBody: csV2}) + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: done.ID})) + + err = m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: done.ID}) + assertException(t, err, cfn.ExceptionInvalidChangeSetStatus, + "ChangeSet ["+done.ID+"] cannot be executed in its current execution status of [EXECUTE_COMPLETE]") + + // A direct UpdateStack makes the stack's open change sets obsolete. + stale := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "stale", TemplateBody: csV1}) + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + assertEqual(t, describeCS(t, m, "s", "stale").ExecutionStatus, cfn.ExecutionObsolete, "obsolete") + + err = m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: stale.ID}) + assertException(t, err, cfn.ExceptionInvalidChangeSetStatus, + "ChangeSet ["+stale.ID+"] cannot be executed in its current execution status of [OBSOLETE]") +} + +func TestChangeSetLookup(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + + byARN, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: cs.ID}) + requireNoError(t, err) + assertEqual(t, byARN.Name, "c", "by ARN") + assertEqual(t, byARN.StackName, "s", "stack name") + + byStackID := describeCS(t, m, cs.StackID, "c") + assertEqual(t, byStackID.ID, cs.ID, "by stack id") + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: "c"}) + assertValidation(t, err, "StackName must be specified if ChangeSetName is not specified as an ARN.") + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{StackName: "s", ChangeSetName: "nope"}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "ChangeSet [nope] does not exist") + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{StackName: "missing", ChangeSetName: "c"}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "ChangeSet [c] does not exist") + + err = m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{StackName: "s", ChangeSetName: "nope"}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "") + + _, err = m.ListChangeSets(ctx, &cfn.ListChangeSetsInput{StackName: "missing"}) + assertValidation(t, err, "") +} + +func TestExecuteChangeSetOnStackFailure(t *testing.T) { + tests := []struct { + name string + onFailure string + disable *bool + wantStatus string + wantKept bool + }{ + {name: "default rolls back", wantStatus: cfn.StatusRollbackComplete}, + {name: "rollback", onFailure: cfn.OnStackFailureRollback, wantStatus: cfn.StatusRollbackComplete}, + {name: "do nothing", onFailure: cfn.OnStackFailureDoNothing, wantStatus: cfn.StatusCreateFailed, wantKept: true}, + {name: "disable rollback", disable: boolPtr(true), wantStatus: cfn.StatusCreateFailed, wantKept: true}, + {name: "delete", onFailure: cfn.OnStackFailureDelete, wantStatus: cfn.StatusDeleteComplete}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, + TemplateBody: csFailing, OnStackFailure: tc.onFailure, + }) + + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID, DisableRollback: tc.disable})) + + st, err := m.DescribeStacks(ctx, cs.StackID) + requireNoError(t, err) + assertEqual(t, st[0].Status, tc.wantStatus, "stack status") + + _, kept := p.values["/p"] + assertEqual(t, kept, tc.wantKept, "created resource kept") + + if tc.wantKept { + assertEqual(t, st[0].DisableRollback, true, "DisableRollback") + assertEqual(t, st[0].StatusReason, "The following resource(s) failed to create: [Bad].", "reason") + assertEqual(t, describeCS(t, m, "s", "c").ExecutionStatus, cfn.ExecutionFailed, "execution failed") + } + }) + } +} + +func TestExecuteChangeSetRejectsBothFailureOptions(t *testing.T) { + m := newParamMock(newParamProv()) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, + TemplateBody: csV1, OnStackFailure: cfn.OnStackFailureRollback, + }) + + err := m.ExecuteChangeSet(context.Background(), &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID, DisableRollback: boolPtr(false)}) + assertValidation(t, err, msgBothFailureOptions) + assertEqual(t, describeCS(t, m, "s", "c").ExecutionStatus, cfn.ExecutionAvailable, "still available") +} + +const keepV1 = `{"Resources":{ + "A":{"Type":"Test::Param","Properties":{"Name":"/a","Value":"data"}}, + "B":{"Type":"Test::Param","DependsOn":"A","Properties":{"Name":"/b","Value":"b"}} +}}` + +// keepFailing replaces A, then fails to replace B onto a name in use. +const keepFailing = `{"Resources":{ + "A":{"Type":"Test::Param","Properties":{"Name":"/a2","Value":"data"}}, + "B":{"Type":"Test::Param","DependsOn":"A","Properties":{"Name":"/taken","Value":"b"}} +}}` + +// failDoNothing leaves stack "s" UPDATE_FAILED after A was replaced and B +// failed, with rollback disabled. +func failDoNothing(t *testing.T, m *Mock, p paramProv) { + t.Helper() + + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: keepV1}) + requireNoError(t, err) + + p.values["/taken"] = "outside" + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", TemplateBody: keepFailing, OnStackFailure: cfn.OnStackFailureDoNothing, + }) + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID})) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateFailed, "stack status") +} + +// A failed update that is not rolled back keeps the old physical resource +// of a replacement. Only a later successful update cleans it up. +func TestDoNothingKeepsReplacedResources(t *testing.T) { + ctx := context.Background() + + t.Run("kept until a successful update", func(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + assertEqual(t, p.values["/a"], "data", "old A kept with its data") + assertEqual(t, p.values["/a2"], "data", "new A created") + assertEqual(t, p.values["/taken"], "outside", "outside resource untouched") + + _, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: strings.ReplaceAll(keepFailing, "/taken", "/b3")}) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "retried") + + if _, ok := p.values["/a"]; ok { + t.Fatalf("the old A must be cleaned up after the successful update") + } + + assertEqual(t, p.values["/a2"], "data", "new A kept") + }) + + t.Run("survives a snapshot and is deleted with the stack", func(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + data, err := m.Snapshot(ctx, false) + requireNoError(t, err) + + restored := newParamMock(p) + requireNoError(t, restored.Restore(ctx, json.RawMessage(data))) + requireNoError(t, restored.DeleteStack(ctx, "s")) + + for _, name := range []string{"/a", "/a2", "/b"} { + if _, ok := p.values[name]; ok { + t.Fatalf("%s left behind after DeleteStack", name) + } + } + + assertEqual(t, p.values["/taken"], "outside", "outside resource untouched") + }) +} + +// After a DO_NOTHING failure the stack keeps each resource's last applied +// state: A at its new name, B at its old one, because B never changed. An +// update back to the first template takes the retained /a back instead of +// creating it again, and never touches the name B failed on. +func TestDoNothingFailureKeepsLastAppliedState(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + body, err := m.GetTemplate(ctx, "s") + requireNoError(t, err) + + assertEqual(t, body, keepFailing, "the stack keeps the submitted template verbatim") + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: keepV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "back to the first template") + assertEqual(t, p.values["/a"], "data", "retained A taken back with its data") + assertEqual(t, p.values["/b"], "b", "B untouched") + assertEqual(t, p.values["/taken"], "outside", "outside resource untouched") + + if _, ok := p.values["/a2"]; ok { + t.Fatalf("the A created by the failed update must be cleaned up") + } + + assertEqual(t, len(m.mustData(t, "s").retained), 0, "nothing left retained") + + // A second failure now rolls back to the last applied state, not onto + // the name B failed on. + p2 := newParamProv() + m2 := newParamMock(p2) + failDoNothing(t, m2, p2) + + failing := strings.Replace(keepV1, `} +}}`, `}, + "Bad":{"Type":"Test::Boom","DependsOn":"B"} +}}`, 1) + + st, err = m2.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: failing}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "rollback to the last applied state") + assertEqual(t, p2.values["/taken"], "outside", "outside resource untouched") + assertEqual(t, p2.values["/b"], "b", "B untouched") + assertEqual(t, p2.values["/a"], "data", "the retained A taken back and rolled back keeps its data") + assertEqual(t, p2.values["/a2"], "data", "A points at /a2 again") + + // The retained A is still retained, so a later update takes it back. + st, err = m2.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: keepV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "back to the first template") + assertEqual(t, p2.values["/a"], "data", "A back at /a with its data") +} + +// The documented retry after UPDATE_FAILED: fix the cause, then update with +// the previous template. The resources that failed or never ran are applied. +func TestRetryAfterUpdateFailedWithPreviousTemplate(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + delete(p.values, "/taken") + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", UsePreviousTemplate: true, DisableRollback: true}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "retry status") + assertEqual(t, p.values["/taken"], "b", "B now at its new name") + assertEqual(t, p.values["/a2"], "data", "A kept at its new name") + + for _, gone := range []string{"/a", "/b"} { + if _, ok := p.values[gone]; ok { + t.Fatalf("%s must be cleaned up after the successful retry", gone) + } + } + + body, err := m.GetTemplate(ctx, "s") + requireNoError(t, err) + assertEqual(t, body, keepFailing, "template kept verbatim") +} + +// UpdateStack with DisableRollback leaves a failed update UPDATE_FAILED +// instead of rolling it back. +func TestUpdateStackDisableRollback(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + + _, 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, "status") + assertEqual(t, st.DisableRollback, true, "DisableRollback") + assertEqual(t, p.values["/p"], "v2", "Old not rolled back") + assertEqual(t, p.values["/n"], "new", "New kept") + + // Without it the next failure rolls back to the last applied state. + st, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: strings.Replace(paramV2, `"v2"`, `"v3"`, 1)}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateRollbackComplete, "rolled back") + assertEqual(t, st.DisableRollback, false, "DisableRollback cleared") + assertEqual(t, p.values["/p"], "v2", "Old back at its last applied value") + assertEqual(t, p.values["/n"], "new", "New kept") +} + +// A retained resource deleted out of band is created again by a later +// update, and the cleanup must not delete that new resource. +func TestRetainedCleanupSparesRecreatedResource(t *testing.T) { + ctx := context.Background() + p := newParamProv() + m := newParamMock(p) + failDoNothing(t, m, p) + + delete(p.values, "/a") + + st, err := m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: keepV1}) + requireNoError(t, err) + assertEqual(t, st.Status, cfn.StatusUpdateComplete, "status") + assertEqual(t, p.values["/a"], "data", "the recreated A survives cleanup") + + if _, ok := p.values["/a2"]; ok { + t.Fatalf("the A created by the failed update must be cleaned up") + } + + assertEqual(t, len(m.mustData(t, "s").retained), 0, "nothing left retained") +} + +func TestUpdateChangeSetDoNothingLeavesUpdateFailed(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", TemplateBody: paramV2, OnStackFailure: cfn.OnStackFailureDoNothing, + }) + requireNoError(t, m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID})) + + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusUpdateFailed, "stack status") + assertEqual(t, st.StatusReason, "The following resource(s) failed to create: [Bad].", "reason") + assertEqual(t, p.values["/p"], "v2", "no rollback of Old") + assertEqual(t, p.values["/n"], "new", "New kept") + + // A stack left UPDATE_FAILED can be updated again. + _, err = m.UpdateStack(ctx, &cfn.UpdateStackInput{StackName: "s", TemplateBody: paramV1}) + requireNoError(t, err) + assertEqual(t, stackStatus(t, m, "s").Status, cfn.StatusUpdateComplete, "retried") +} + +func TestDeleteChangeSet(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + + requireNoError(t, m.DeleteChangeSet(ctx, &cfn.DeleteChangeSetInput{StackName: "s", ChangeSetName: "c"})) + + _, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: cs.ID}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "") + + err = m.DeleteChangeSet(ctx, &cfn.DeleteChangeSetInput{ChangeSetName: cs.ID}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "") + + // By name, deleting a change set that is gone succeeds while the stack + // exists, which is what lets the CDK clear a fixed change set name. + requireNoError(t, m.DeleteChangeSet(ctx, &cfn.DeleteChangeSetInput{StackName: "s", ChangeSetName: "c"})) + + err = m.DeleteChangeSet(ctx, &cfn.DeleteChangeSetInput{StackName: "missing", ChangeSetName: "c"}) + assertValidation(t, err, "Stack [missing] does not exist") + + // Deleting the only change set leaves the review stack in place, as AWS + // does. A later CREATE change set reuses it. + st := stackStatus(t, m, "s") + assertEqual(t, st.Status, cfn.StatusReviewInProgress, "stack stays in review") + + list, err := m.ListChangeSets(ctx, &cfn.ListChangeSetsInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, len(list.Summaries), 0, "no change sets") + + again := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + assertEqual(t, again.StackID, st.ID, "same review stack") + + // DeleteStack removes the review stack and its change sets. + requireNoError(t, m.DeleteStack(ctx, "s")) + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: again.ID}) + assertException(t, err, cfn.ExceptionChangeSetNotFound, "") + + gone, err := m.DescribeStacks(ctx, st.ID) + requireNoError(t, err) + assertEqual(t, gone[0].Status, cfn.StatusDeleteComplete, "deleted") +} + +func TestListChangeSetsPaginates(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + const total = changeSetsPageSize + 5 + + for i := range total { + createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: fmt.Sprintf("c%03d", i), ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + } + + first, err := m.ListChangeSets(ctx, &cfn.ListChangeSetsInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, len(first.Summaries), changeSetsPageSize, "first page") + + if first.NextToken == "" { + t.Fatalf("want a NextToken") + } + + second, err := m.ListChangeSets(ctx, &cfn.ListChangeSetsInput{StackName: "s", NextToken: first.NextToken}) + requireNoError(t, err) + assertEqual(t, len(second.Summaries), 5, "second page") + assertEqual(t, second.NextToken, "", "last page") + assertEqual(t, second.Summaries[4].Name, "c104", "creation order") + assertEqual(t, len(second.Summaries[0].Changes), 0, "summaries carry no changes") +} + +// DescribeChangeSet pages the changes once a response passes 1 MB. +func TestDescribeChangeSetPagesLargeChanges(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + big := strings.Repeat("x", 400*1024) + body := fmt.Sprintf(`{"Resources":{ + "A":{"Type":"Test::Param","Properties":{"Name":"/a","Value":%q}}, + "B":{"Type":"Test::Param","Properties":{"Name":"/b","Value":%q}}, + "C":{"Type":"Test::Param","Properties":{"Name":"/c","Value":%q}} + }}`, big, big, big) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: body}) + + plain := describeCS(t, m, "s", "c") + assertEqual(t, len(plain.Changes), 3, "small changes fit one page") + assertEqual(t, plain.NextToken, "", "no token") + + var names []string + + token := "" + + for range 3 { + page, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ + ChangeSetName: cs.ID, IncludePropertyValues: true, NextToken: token, + }) + requireNoError(t, err) + + for _, c := range page.Changes { + names = append(names, c.LogicalID) + } + + if token = page.NextToken; token == "" { + break + } + } + + assertEqual(t, strings.Join(names, ","), "A,B,C", "all changes across pages") + + first, err := m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: cs.ID, IncludePropertyValues: true}) + requireNoError(t, err) + assertEqual(t, len(first.Changes), 2, "first page holds what fits in 1 MB") + + _, err = m.DescribeChangeSet(ctx, &cfn.DescribeChangeSetInput{ChangeSetName: cs.ID, NextToken: "bogus"}) + assertValidation(t, err, "") +} + +func TestChangeSetSnapshotRoundTrip(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "upd", TemplateBody: csV2, Tags: map[string]string{"k": "v"}, + }) + createCS(t, m, &cfn.CreateChangeSetInput{StackName: "r", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1}) + + data, err := m.Snapshot(ctx, false) + requireNoError(t, err) + + restored := newParamMock(p) + requireNoError(t, restored.Restore(ctx, json.RawMessage(data))) + + got := describeCS(t, restored, "s", "upd") + assertEqual(t, got.ID, cs.ID, "id kept") + assertEqual(t, got.ExecutionStatus, cfn.ExecutionAvailable, "status kept") + assertEqual(t, len(got.Changes), 2, "changes kept") + assertEqual(t, stackStatus(t, restored, "r").Status, cfn.StatusReviewInProgress, "review stack kept") + + requireNoError(t, restored.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID})) + assertEqual(t, p.values["/p"], "v2", "restored change set executes") + assertEqual(t, stackStatus(t, restored, "s").Tags["k"], "v", "change set tags applied") +} + +// Two concurrent executions of one change set: exactly one runs. +func TestConcurrentExecuteChangeSet(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + cs := createCS(t, m, &cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "c", TemplateBody: csV2}) + + const workers = 8 + + var ( + wg sync.WaitGroup + mu sync.Mutex + wins int + ) + + for range workers { + wg.Add(1) + + go func() { + defer wg.Done() + + if m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: cs.ID}) == nil { + mu.Lock() + wins++ + mu.Unlock() + } + }() + } + + wg.Wait() + assertEqual(t, wins, 1, "executions that ran") + assertEqual(t, *p.updates, 1, "P updated once") +} + +// A CreateChangeSet retry with the same ClientToken returns the change set it +// made. Another token, or none, is a duplicate name. An ExecuteChangeSet +// retry with the same ClientRequestToken succeeds without running again. +func TestChangeSetClientTokens(t *testing.T) { + p := newParamProv() + m := newParamMock(p) + ctx := context.Background() + + _, err := m.CreateStack(ctx, &cfn.CreateStackInput{StackName: "s", TemplateBody: csV1}) + requireNoError(t, err) + + in := cfn.CreateChangeSetInput{StackName: "s", ChangeSetName: "c", TemplateBody: csV2, ClientToken: "tok-1"} + first := createCS(t, m, &in) + + retry := in + again := createCS(t, m, &retry) + assertEqual(t, again.ID, first.ID, "retry returns the same change set") + + for _, token := range []string{"tok-2", ""} { + other := in + other.ClientToken = token + _, err = m.CreateChangeSet(ctx, &other) + assertException(t, err, cfn.ExceptionAlreadyExists, "ChangeSet [c] already exists") + } + + exec := &cfn.ExecuteChangeSetInput{ChangeSetName: first.ID, ClientRequestToken: "run-1"} + requireNoError(t, m.ExecuteChangeSet(ctx, exec)) + requireNoError(t, m.ExecuteChangeSet(ctx, exec)) + assertEqual(t, *p.updates, 1, "executed once") + + err = m.ExecuteChangeSet(ctx, &cfn.ExecuteChangeSetInput{ChangeSetName: first.ID, ClientRequestToken: "run-2"}) + assertException(t, err, cfn.ExceptionInvalidChangeSetStatus, "") +} + +// GetTemplateSummary on a stack still in review summarizes the template of +// its change set. +func TestGetTemplateSummaryReviewStack(t *testing.T) { + m := newParamMock(newParamProv()) + ctx := context.Background() + + createCS(t, m, &cfn.CreateChangeSetInput{ + StackName: "s", ChangeSetName: "c", ChangeSetType: cfn.ChangeSetTypeCreate, TemplateBody: csV1, + }) + + sum, err := m.GetTemplateSummary(ctx, &cfn.GetTemplateSummaryInput{StackName: "s"}) + requireNoError(t, err) + assertEqual(t, strings.Join(sum.ResourceTypes, ","), "Test::Param", "resource types") +} + +func boolPtr(b bool) *bool { return &b } diff --git a/providers/aws/cloudformation/cloudformation.go b/providers/aws/cloudformation/cloudformation.go index 881534750..839353fba 100644 --- a/providers/aws/cloudformation/cloudformation.go +++ b/providers/aws/cloudformation/cloudformation.go @@ -52,6 +52,90 @@ type stackData struct { // rollbackFailed lists the resources a failed update rollback could not // restore. It is set only while the stack is UPDATE_ROLLBACK_FAILED. rollbackFailed []string + // changeSets holds the stack's change sets in creation order. + changeSets []*changeSetRecord + // retained holds the old physical resources of replacements made by a + // failed update that was not rolled back. The next successful update + // deletes them in its cleanup phase, and DeleteStack deletes them. + retained []retainedResource +} + +// retainedResource is the old physical resource of a replacement that is +// waiting for cleanup. +type retainedResource struct { + LogicalID string `json:"logicalId"` + Type string `json:"type"` + Resolved cfn.ResolvedResource `json:"resolved"` + Props map[string]any `json:"props,omitempty"` + DeleteID string `json:"deleteId"` +} + +func (r *retainedResource) replacement() replacement { + return replacement{id: r.LogicalID, old: liveResource{ + typ: r.Type, resolved: r.Resolved, props: r.Props, deleteID: r.DeleteID, + }} +} + +// retain records the old resources of replacements a failed update keeps. +func (sd *stackData) retain(replaced []replacement) { + sd.mu.Lock() + defer sd.mu.Unlock() + + for i := range replaced { + old := &replaced[i].old + sd.retained = append(sd.retained, retainedResource{ + LogicalID: replaced[i].id, Type: old.typ, Resolved: old.resolved, Props: old.props, DeleteID: old.deleteID, + }) + } +} + +// drainRetained forgets the retained old resources and returns the ones to +// delete. A retained resource whose physical id a live resource of the +// stack now holds again is dropped, not deleted. +func (sd *stackData) drainRetained() []replacement { + sd.mu.Lock() + defer sd.mu.Unlock() + + live := make(map[string]bool, len(sd.resolved)) + for _, rr := range sd.resolved { + live[rr.RefValue] = true + } + + var out []replacement + + for i := range sd.retained { + if !live[sd.retained[i].Resolved.RefValue] { + out = append(out, sd.retained[i].replacement()) + } + } + + sd.retained = nil + + return out +} + +// takeRetained removes and returns the retained old resource of id whose +// custom name property is name. +func (sd *stackData) takeRetained(id, rtype, nameProp, name string) (liveResource, bool) { + if name == "" { + return liveResource{}, false + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + for i := range sd.retained { + r := &sd.retained[i] + if r.LogicalID == id && r.Type == rtype && cfn.PropString(r.Props, nameProp) == name { + old := r.replacement().old + + sd.retained = append(sd.retained[:i], sd.retained[i+1:]...) + + return old, true + } + } + + return liveResource{}, false } // New builds a CloudFormation mock with an empty provisioner registry. Callers diff --git a/providers/aws/cloudformation/converge.go b/providers/aws/cloudformation/converge.go index 01abeb846..c9db33542 100644 --- a/providers/aws/cloudformation/converge.go +++ b/providers/aws/cloudformation/converge.go @@ -25,6 +25,9 @@ type convergeOpts struct { // cleanupStatus is the stack status recorded before the cleanup phase, // or "" to record none. cleanupStatus string + // cleanRetained has the cleanup phase also delete the old resources + // retained from earlier replacements. + cleanRetained bool } // applyFailure is one resource a converge pass could not bring to its target. @@ -47,6 +50,10 @@ type liveResource struct { type replacement struct { id string old liveResource + // reclaimed marks a replacement whose new resource was taken back from + // the retained old resources. A rollback retains it again instead of + // deleting it. + reclaimed bool } // Resource status reasons CloudFormation records on a replacement. @@ -90,7 +97,12 @@ func (m *Mock) converge( m.emitStackEvent(sd, o.cleanupStatus, "") } - m.cleanup(ctx, sd, t, o.skip, replaced) + var retained []replacement + if len(failures) == 0 && o.cleanRetained { + retained = sd.drainRetained() + } + + m.cleanup(ctx, sd, t, o.skip, append(retained, replaced...)) return failures, nil } @@ -213,6 +225,10 @@ func (m *Mock) replaceOne( ) *applyFailure { m.emitResourceEvent(sd, id, live.resolved.RefValue, live.typ, cfn.ResourceUpdateInProgress, reasonReplacing) + if f, handled := m.reclaimRetained(ctx, sd, res, id, rdef, live, replaced); handled { + return f + } + if f := m.createOne(ctx, sd, res, id, rdef, replaceEvents()); f != nil { return f } @@ -269,6 +285,15 @@ func (m *Mock) createOne( return fail(err) } + m.recordCreated(sd, res, id, rdef.Type, out, props, ev.complete) + + return nil +} + +// recordCreated records a resource a provisioner just created. +func (m *Mock) recordCreated( + sd *stackData, res *cfn.Resolver, id, rtype string, out *cfn.ProvisionedResource, props map[string]any, status string, +) { deleteID := out.DeleteID if deleteID == "" { deleteID = out.PhysicalID @@ -276,12 +301,58 @@ func (m *Mock) createOne( m.record(sd, res, id, cfn.ResolvedResource{RefValue: out.PhysicalID, Attributes: out.Attributes}, props, deleteID) m.upsertResource(sd, &cfn.StackResource{ - LogicalID: id, PhysicalID: out.PhysicalID, Type: rdef.Type, - Status: ev.complete, Timestamp: m.clock.Now(), + LogicalID: id, PhysicalID: out.PhysicalID, Type: rtype, Status: status, Timestamp: m.clock.Now(), }) - m.emitResourceEvent(sd, id, out.PhysicalID, rdef.Type, ev.complete, "") + m.emitResourceEvent(sd, id, out.PhysicalID, rtype, status, "") +} - return nil +// reclaimRetained handles a replacement onto the custom name of an old +// resource the stack retained from a failed update. The name is created +// again when the old resource is gone. When it still exists it is taken +// back and updated in place. The resource being replaced is queued for +// cleanup either way. handled is false when no retained resource has the +// name. +func (m *Mock) reclaimRetained( + ctx context.Context, sd *stackData, res *cfn.Resolver, id string, rdef cfn.ResourceDef, + live *liveResource, replaced *[]replacement, +) (f *applyFailure, handled bool) { + prov := m.registry[rdef.Type] + + named, ok := prov.(cfn.NamedResource) + if !ok { + return nil, false + } + + props, err := resolveProps(res, rdef.Properties) + if err != nil { + return nil, false + } + + old, ok := sd.takeRetained(id, rdef.Type, named.NameProperty(), cfn.PropString(props, named.NameProperty())) + if !ok { + return nil, false + } + + out, err := prov.Create(ctx, m.resourceRequest(res, id, rdef.Type, props)) + + switch { + case err == nil: + m.recordCreated(sd, res, id, rdef.Type, out, props, cfn.ResourceUpdateComplete) + case cerrors.IsAlreadyExists(err): + m.record(sd, res, id, old.resolved, old.props, old.deleteID) + *replaced = append(*replaced, replacement{id: id, old: *live, reclaimed: true}) + + return m.updateOne(ctx, sd, res, id, rdef, &old, props), true + default: + m.emitResourceEvent(sd, id, "", rdef.Type, cfn.ResourceUpdateFailed, cerrors.Message(err)) + sd.retain([]replacement{{id: id, old: old}}) + + return &applyFailure{logicalID: id, verb: verbUpdate, err: err}, true + } + + *replaced = append(*replaced, replacement{id: id, old: *live}) + + return nil, true } // updateOne changes a live resource in place. The physical id stays the same. @@ -371,8 +442,16 @@ func (m *Mock) cleanup( } } -// teardown deletes every provisioned resource in reverse creation order. +// teardown deletes every provisioned resource in reverse creation order, +// then any old resources retained from replacements. func (m *Mock) teardown(ctx context.Context, sd *stackData) { + defer func() { + retained := sd.drainRetained() + for i := len(retained) - 1; i >= 0; i-- { + _ = m.deletePhysical(ctx, sd, retained[i].id, &retained[i].old) + } + }() + sd.mu.RLock() order := append([]string(nil), sd.provisionOrder...) sd.mu.RUnlock() @@ -426,7 +505,9 @@ func (m *Mock) restoreReplaced(ctx context.Context, sd *stackData, replaced []re for i := len(replaced) - 1; i >= 0; i-- { r := &replaced[i] - if cur, ok := sd.live(r.id); ok { + if cur, ok := sd.live(r.id); ok && r.reclaimed { + sd.retain([]replacement{{id: r.id, old: cur}}) + } else if ok { _ = m.deletePhysical(ctx, sd, r.id, &cur) } diff --git a/providers/aws/cloudformation/orchestrate.go b/providers/aws/cloudformation/orchestrate.go index 5da17fd06..a4eb05bae 100644 --- a/providers/aws/cloudformation/orchestrate.go +++ b/providers/aws/cloudformation/orchestrate.go @@ -72,18 +72,40 @@ func (m *Mock) CreateStack(ctx context.Context, in *cfn.CreateStackInput) (*cfn. m.emitStackEvent(sd, cfn.StatusCreateInProgress, "User Initiated") - if failures, _ := m.converge(ctx, sd, effective, resolver, convergeOpts{stopOnFailure: true}); len(failures) > 0 { - reason := failureSummary(failures) + " Rollback requested by user." + m.provision(ctx, sd, effective, resolver, cfn.OnStackFailureRollback) + + 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 { + failures, _ := m.converge(ctx, sd, t, res, convergeOpts{stopOnFailure: true}) + if len(failures) == 0 { + m.emitStackEvent(sd, cfn.StatusCreateComplete, "") + return true + } + + reason := failureSummary(failures) + + switch onFailure { + case cfn.OnStackFailureDoNothing: + m.emitStackEvent(sd, cfn.StatusCreateFailed, reason) + case cfn.OnStackFailureDelete: + m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reason+" Delete requested by user.") + m.finishDelete(ctx, sd) + default: + reason += " Rollback requested by user." m.emitStackEvent(sd, cfn.StatusRollbackInProgress, reason) m.teardown(ctx, sd) m.emitTerminalEvent(sd, cfn.StatusRollbackComplete, reason) - } else { - m.emitStackEvent(sd, cfn.StatusCreateComplete, "") } - out := sd.snapshotStack() - - return &out, nil + return false } // claimStackSlot atomically inserts sd for name, or replaces a prior diff --git a/providers/aws/cloudformation/snapshot.go b/providers/aws/cloudformation/snapshot.go index 4f4b6bdb3..8879529d5 100644 --- a/providers/aws/cloudformation/snapshot.go +++ b/providers/aws/cloudformation/snapshot.go @@ -26,6 +26,8 @@ type stackSnapshot struct { DeleteIDs map[string]string `json:"deleteIds,omitempty"` Props map[string]map[string]any `json:"props,omitempty"` RollbackFailed []string `json:"rollbackFailed,omitempty"` + ChangeSets []changeSetRecord `json:"changeSets,omitempty"` + Retained []retainedResource `json:"retained,omitempty"` } // Snapshot captures every stack's state under its own name so a restore @@ -42,6 +44,8 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { DeleteIDs: cloneStringMap(sd.deleteIDs), Props: cloneProps(sd.props), RollbackFailed: append([]string(nil), sd.rollbackFailed...), + ChangeSets: cloneChangeSets(sd.changeSets), + Retained: append([]retainedResource(nil), sd.retained...), } sd.mu.RUnlock() } @@ -64,6 +68,11 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { deleteIDs: ss.DeleteIDs, props: ss.Props, rollbackFailed: ss.RollbackFailed, + retained: ss.Retained, + } + + for i := range ss.ChangeSets { + sd.changeSets = append(sd.changeSets, &ss.ChangeSets[i]) } if sd.resolved == nil { @@ -84,6 +93,17 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { return nil } +// 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 { + out := make([]changeSetRecord, len(in)) + for i, rec := range in { + out[i] = *rec + } + + return out +} + func cloneResolved(in map[string]cfn.ResolvedResource) map[string]cfn.ResolvedResource { out := make(map[string]cfn.ResolvedResource, len(in)) for k, v := range in { diff --git a/providers/aws/cloudformation/template_source.go b/providers/aws/cloudformation/template_source.go index 4d8120570..62dde9bda 100644 --- a/providers/aws/cloudformation/template_source.go +++ b/providers/aws/cloudformation/template_source.go @@ -77,6 +77,37 @@ func (m *Mock) ValidateTemplate(ctx context.Context, in *cfn.ValidateTemplateInp return cfn.Summarize(t), nil } +// GetTemplateSummary summarizes the template of a live stack, or one given +// 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) { + if in.StackName == "" { + return m.ValidateTemplate(ctx, &cfn.ValidateTemplateInput{TemplateBody: in.TemplateBody, TemplateURL: in.TemplateURL}) + } + + sd, err := m.activeStack(in.StackName) + if err != nil { + return nil, err + } + + // A stack still in review has no template of its own yet. AWS + // summarizes the template of its latest change set. + sd.mu.RLock() + + body := sd.stack.TemplateBody + if n := len(sd.changeSets); body == "" && n > 0 { + body = sd.changeSets[n-1].Template + } + sd.mu.RUnlock() + + t, err := cfn.ParseTemplate(body) + if err != nil { + return nil, err + } + + return cfn.Summarize(t), nil +} + // s3Object names the object a TemplateURL points at. type s3Object struct { bucket, key, versionID string diff --git a/providers/aws/cloudformation/update.go b/providers/aws/cloudformation/update.go index 9cc341374..df4301d4b 100644 --- a/providers/aws/cloudformation/update.go +++ b/providers/aws/cloudformation/update.go @@ -26,7 +26,8 @@ const ( // updatableStatus reports the stack statuses UpdateStack accepts. func updatableStatus(status string) bool { switch status { - case cfn.StatusCreateComplete, cfn.StatusUpdateComplete, cfn.StatusUpdateRollbackComplete: + case cfn.StatusCreateComplete, cfn.StatusUpdateComplete, cfn.StatusUpdateRollbackComplete, + cfn.StatusCreateFailed, cfn.StatusUpdateFailed: return true default: return false @@ -86,18 +87,48 @@ func (m *Mock) UpdateStack(ctx context.Context, in *cfn.UpdateStackInput) (*cfn. return nil, berr } + onFailure := cfn.OnStackFailureRollback + if in.DisableRollback { + onFailure = cfn.OnStackFailureDoNothing + } + + sd.obsoleteChangeSets() + sd.setDisableRollback(in.DisableRollback) + m.runUpdate(ctx, sd, in, plan, onFailure) + + out := sd.snapshotStack() + + return &out, nil +} + +// 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 { m.applyStackMeta(sd, in, plan) - forward := convergeOpts{stopOnFailure: true, cleanupStatus: cfn.StatusUpdateCompleteCleanupInProgress} - if failures, replaced := m.converge(ctx, sd, plan.newT, plan.newRes, forward); len(failures) > 0 { - m.rollbackUpdate(ctx, sd, plan, failureSummary(failures), replaced) - } else { + forward := convergeOpts{ + stopOnFailure: true, cleanupStatus: cfn.StatusUpdateCompleteCleanupInProgress, cleanRetained: true, + } + + failures, replaced := m.converge(ctx, sd, plan.newT, plan.newRes, forward) + if len(failures) == 0 { m.emitStackEvent(sd, cfn.StatusUpdateComplete, "") + return true } - out := sd.snapshotStack() + if onFailure != cfn.OnStackFailureDoNothing { + m.rollbackUpdate(ctx, sd, plan, failureSummary(failures), replaced) + return false + } - return &out, nil + sd.retain(replaced) + m.emitStackEvent(sd, cfn.StatusUpdateFailed, failureSummary(failures)) + + return false } // checkUpdatable rejects an update of a stack in a state that does not allow @@ -185,9 +216,57 @@ func (m *Mock) planUpdate(ctx context.Context, sd *stackData, in *cfn.UpdateStac backfillProps(sd, p.oldT, p.oldRes) + if st := sd.status(); st == cfn.StatusUpdateFailed || st == cfn.StatusCreateFailed { + p.oldT = appliedTemplate(sd, p.oldT, p.oldRes) + } + return p, nil } +// appliedTemplate returns what a stack left UPDATE_FAILED or CREATE_FAILED +// really holds, for the update planner and a rollback to diff against. The +// stack keeps its submitted template, but only some of its resources reached +// it. A resource that did keeps its definition. One that failed keeps the +// properties and type it was last applied with. One that was never created +// is left out. +func appliedTemplate(sd *stackData, t *cfn.Template, res *cfn.Resolver) *cfn.Template { + out := *t + out.Resources = make(map[string]cfn.ResourceDef, len(t.Resources)) + + sd.mu.RLock() + + ids := make([]string, 0, len(sd.resolved)) + for id := range sd.resolved { + ids = append(ids, id) + } + sd.mu.RUnlock() + + for _, id := range ids { + live, ok := sd.live(id) + if !ok { + continue + } + + if rdef, declared := t.Resources[id]; declared && rdef.Type == live.typ { + if props, err := resolveProps(res, rdef.Properties); err == nil && cfn.SameProperties(live.props, props) { + out.Resources[id] = rdef + continue + } + } + + out.Resources[id] = cfn.ResourceDef{Type: live.typ, Properties: live.props} + } + + return &out +} + +func (sd *stackData) setDisableRollback(v bool) { + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.stack.DisableRollback = v +} + // checkTypesKept rejects a template that gives a live logical ID a new type. func checkTypesKept(sd *stackData, t *cfn.Template) error { var changed []string @@ -516,14 +595,21 @@ func (m *Mock) DeleteStack(ctx context.Context, name string) error { } m.emitStackEvent(sd, cfn.StatusDeleteInProgress, reasonUserInitiated) + m.finishDelete(ctx, sd) + + return nil +} + +// finishDelete tears down a stack in DELETE_IN_PROGRESS, drops its change +// sets and marks it DELETE_COMPLETE. +func (m *Mock) finishDelete(ctx context.Context, sd *stackData) { m.teardown(ctx, sd) sd.mu.Lock() sd.stack.DeletionTime = m.clock.Now() sd.rollbackFailed = nil + sd.changeSets = nil sd.mu.Unlock() m.emitStackEvent(sd, cfn.StatusDeleteComplete, "") - - return nil } diff --git a/providers/aws/cloudformation_custom_name_test.go b/providers/aws/cloudformation_custom_name_test.go new file mode 100644 index 000000000..163981d0b --- /dev/null +++ b/providers/aws/cloudformation_custom_name_test.go @@ -0,0 +1,116 @@ +package aws + +import ( + "context" + "slices" + "testing" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" + notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" + storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" +) + +// customNameCase is one resource type with a custom physical name and a way +// to check that the named resource still exists. +type customNameCase struct { + rtype string + name string + props string + exists func(ctx context.Context, p *Provider) bool +} + +func customNameCases() []customNameCase { + return []customNameCase{ + {"AWS::S3::Bucket", "taken-bucket", `{"BucketName":"taken-bucket"}`, + func(ctx context.Context, p *Provider) bool { + buckets, err := p.S3.ListBuckets(ctx) + return err == nil && slices.ContainsFunc(buckets, func(b storagedriver.BucketInfo) bool { return b.Name == "taken-bucket" }) + }}, + {"AWS::DynamoDB::Table", "taken-table", `{"TableName":"taken-table","BillingMode":"PAY_PER_REQUEST", + "AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}], + "KeySchema":[{"AttributeName":"id","KeyType":"HASH"}]}`, + func(ctx context.Context, p *Provider) bool { + _, err := p.DynamoDB.DescribeTable(ctx, "taken-table") + return err == nil + }}, + {"AWS::SQS::Queue", "taken-queue", `{"QueueName":"taken-queue"}`, + func(ctx context.Context, p *Provider) bool { + queues, err := p.SQS.ListQueues(ctx, "taken-queue") + return err == nil && len(queues) == 1 + }}, + {"AWS::SNS::Topic", "taken-topic", `{"TopicName":"taken-topic"}`, + func(ctx context.Context, p *Provider) bool { + topics, err := p.SNS.ListTopics(ctx, scope.Scope{}) + return err == nil && slices.ContainsFunc(topics, func(tp notifdriver.TopicInfo) bool { return tp.Name == "taken-topic" }) + }}, + {"AWS::Lambda::Function", "taken-fn", `{"FunctionName":"taken-fn","Runtime":"python3.12","Handler":"i.h", + "Role":"arn:aws:iam::123456789012:role/r","Code":{"ZipFile":"def h(e, c): pass"}}`, + func(ctx context.Context, p *Provider) bool { + _, err := p.Lambda.GetFunction(ctx, "taken-fn") + return err == nil + }}, + {"AWS::IAM::Role", "taken-role", `{"RoleName":"taken-role","AssumeRolePolicyDocument":{"Version":"2012-10-17", + "Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}}`, + func(ctx context.Context, p *Provider) bool { + _, err := p.IAM.GetRole(ctx, "taken-role") + return err == nil + }}, + {"AWS::SecretsManager::Secret", "taken-secret", `{"Name":"taken-secret","SecretString":"s"}`, + func(ctx context.Context, p *Provider) bool { + _, err := p.SecretsManager.GetSecret(ctx, "taken-secret") + return err == nil + }}, + {"AWS::SSM::Parameter", "/taken/param", `{"Name":"/taken/param","Type":"String","Value":"v"}`, + func(ctx context.Context, p *Provider) bool { + _, err := p.SSM.GetParameter(ctx, "/taken/param", false) + return err == nil + }}, + } +} + +// A stack whose custom-named resource already exists fails to create with +// " already exists". It never adopts the resource, so its rollback +// leaves the resource another stack owns in place. +func TestCFNCustomNameTakenNeverAdopts(t *testing.T) { + for _, tc := range customNameCases() { + t.Run(tc.rtype, func(t *testing.T) { + ctx := context.Background() + p := New() + body := `{"Resources":{"R":{"Type":"` + tc.rtype + `","Properties":` + tc.props + `}}}` + caps := []string{cfn.CapabilityNamedIAM} + + if _, err := p.CloudFormation.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "owner", TemplateBody: body, Capabilities: caps, + }); err != nil { + t.Fatalf("owner CreateStack: %v", err) + } + + st, err := p.CloudFormation.CreateStack(ctx, &cfn.CreateStackInput{ + StackName: "intruder", TemplateBody: body, Capabilities: caps, + }) + if err != nil { + t.Fatalf("intruder CreateStack: %v", err) + } + + if st.Status != cfn.StatusRollbackComplete { + t.Fatalf("intruder status %s, want ROLLBACK_COMPLETE", st.Status) + } + + events, err := p.CloudFormation.DescribeStackEvents(ctx, "intruder") + if err != nil { + t.Fatalf("DescribeStackEvents: %v", err) + } + + if !slices.ContainsFunc(events, func(e cfn.StackEvent) bool { + return e.Status == cfn.ResourceCreateFailed && e.StatusReason == tc.name+" already exists" + }) { + t.Fatalf("no CREATE_FAILED %q event in %+v", tc.name+" already exists", events) + } + + if !tc.exists(ctx, p) { + t.Fatalf("the owner stack's %s was deleted by the intruder's rollback", tc.name) + } + }) + } +} diff --git a/providers/aws/cloudformation_provisioners.go b/providers/aws/cloudformation_provisioners.go index 048f27b62..63694dbc6 100644 --- a/providers/aws/cloudformation_provisioners.go +++ b/providers/aws/cloudformation_provisioners.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" cfnprovider "github.com/stackshy/cloudemu/v2/providers/aws/cloudformation" cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" @@ -175,6 +176,17 @@ func fitNameParts(a, b string, budget int) (fitA, fitB string) { } } +// nameTaken reports a create that failed because the physical name is in +// use the way CloudFormation does, " already exists". Other errors +// pass through. +func nameTaken(name string, err error) error { + if cerrors.IsAlreadyExists(err) { + return cerrors.Newf(cerrors.AlreadyExists, "%s already exists", name) + } + + return err +} + // --- AWS::S3::Bucket --- type s3BucketProvisioner struct{ s3 storagedriver.Bucket } @@ -183,7 +195,7 @@ type s3BucketProvisioner struct{ s3 storagedriver.Bucket } func (p s3BucketProvisioner) Create(ctx context.Context, req cfn.ResourceRequest) (*cfn.ProvisionedResource, error) { name := physicalName(&req, "BucketName", true) if err := p.s3.CreateBucket(ctx, name); err != nil { - return nil, err + return nil, nameTaken(name, err) } return &cfn.ProvisionedResource{ @@ -222,7 +234,7 @@ func (p dynamoTableProvisioner) Create(ctx context.Context, req cfn.ResourceRequ cfg := dynamoTableConfig(name, req.Properties) if err := p.db.CreateTable(ctx, cfg); err != nil { - return nil, err + return nil, nameTaken(name, err) } attrs := map[string]string{"Arn": ""} @@ -306,6 +318,12 @@ func (p sqsQueueProvisioner) Create(ctx context.Context, req cfn.ResourceRequest MessageRetention: propInt(req.Properties, "MessageRetentionPeriod"), } + // SQS CreateQueue returns an existing queue of the same name, but + // CloudFormation never adopts one. + if err := p.checkFree(ctx, name); err != nil { + return nil, err + } + info, err := p.sqs.CreateQueue(ctx, cfg) if err != nil { return nil, err @@ -333,6 +351,22 @@ func sqsAttributeDefaults() map[string]int { } } +// checkFree fails when a queue with the name already exists. +func (p sqsQueueProvisioner) checkFree(ctx context.Context, name string) error { + queues, err := p.sqs.ListQueues(ctx, name) + if err != nil { + return err + } + + for i := range queues { + if queues[i].Name == name { + return cerrors.Newf(cerrors.AlreadyExists, "%s already exists", name) + } + } + + return nil +} + // SQS attribute defaults. const ( sqsDefaultVisibilityTimeout = 30 @@ -395,7 +429,7 @@ func (p snsTopicProvisioner) Create(ctx context.Context, req cfn.ResourceRequest FifoTopic: propBool(req.Properties, "FifoTopic") || strings.HasSuffix(name, ".fifo"), }) if err != nil { - return nil, err + return nil, nameTaken(name, err) } // SNS Ref returns the topic ARN (ResourceID); the driver deletes by name. @@ -443,7 +477,7 @@ func (p lambdaFunctionProvisioner) Create(ctx context.Context, req cfn.ResourceR info, err := p.lambda.CreateFunction(ctx, cfg) if err != nil { - return nil, err + return nil, nameTaken(name, err) } return &cfn.ProvisionedResource{ @@ -506,7 +540,7 @@ func (p iamRoleProvisioner) Create(ctx context.Context, req cfn.ResourceRequest) MaxSessionDuration: propInt(req.Properties, "MaxSessionDuration"), }) if err != nil { - return nil, err + return nil, nameTaken(name, err) } return &cfn.ProvisionedResource{ @@ -544,7 +578,7 @@ func (p secretProvisioner) Create(ctx context.Context, req cfn.ResourceRequest) KMSKeyID: cfn.PropString(req.Properties, "KmsKeyId"), }, []byte(cfn.PropString(req.Properties, "SecretString"))) if err != nil { - return nil, err + return nil, nameTaken(name, err) } // Secrets Manager Ref returns the secret ARN; the driver deletes by name. @@ -592,7 +626,7 @@ func (p ssmParameterProvisioner) Create(ctx context.Context, req cfn.ResourceReq Description: cfn.PropString(req.Properties, "Description"), Tier: cfn.PropString(req.Properties, "Tier"), }); err != nil { - return nil, err + return nil, nameTaken(name, err) } return &cfn.ProvisionedResource{ diff --git a/server/aws/cloudformation/changeset.go b/server/aws/cloudformation/changeset.go new file mode 100644 index 000000000..0740966c6 --- /dev/null +++ b/server/aws/cloudformation/changeset.go @@ -0,0 +1,309 @@ +package cloudformation + +import ( + "encoding/xml" + "net/http" + "net/url" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +// changeTypeResource is the only Change Type CloudFormation reports. +const changeTypeResource = "Resource" + +// The change set actions. +const ( + actionCreateChangeSet = "CreateChangeSet" + actionDescribeChangeSet = "DescribeChangeSet" + actionListChangeSets = "ListChangeSets" + actionExecuteChangeSet = "ExecuteChangeSet" + actionDeleteChangeSet = "DeleteChangeSet" +) + +// serveChangeSet dispatches the change set actions. +func (h *Handler) serveChangeSet(w http.ResponseWriter, r *http.Request) { + switch r.Form.Get("Action") { + case actionCreateChangeSet: + h.createChangeSet(w, r) + case actionDescribeChangeSet: + h.describeChangeSet(w, r) + case actionListChangeSets: + h.listChangeSets(w, r) + case actionExecuteChangeSet: + h.executeChangeSet(w, r) + case actionDeleteChangeSet: + h.deleteChangeSet(w, r) + } +} + +func (h *Handler) createChangeSet(w http.ResponseWriter, r *http.Request) { + cs, err := h.api.CreateChangeSet(r.Context(), createChangeSetInput(r.Form)) + if err != nil { + writeErr(w, err) + return + } + + var resp createChangeSetResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.ID = cs.ID + resp.Result.StackID = cs.StackID + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) describeChangeSet(w http.ResponseWriter, r *http.Request) { + cs, err := h.api.DescribeChangeSet(r.Context(), &cfn.DescribeChangeSetInput{ + ChangeSetName: r.Form.Get("ChangeSetName"), + StackName: r.Form.Get("StackName"), + NextToken: r.Form.Get("NextToken"), + IncludePropertyValues: strings.EqualFold(r.Form.Get("IncludePropertyValues"), "true"), + }) + if err != nil { + writeErr(w, err) + return + } + + resp := describeChangeSetResponse{Xmlns: Namespace, Result: toChangeSetXML(cs), Meta: meta()} + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) listChangeSets(w http.ResponseWriter, r *http.Request) { + list, err := h.api.ListChangeSets(r.Context(), &cfn.ListChangeSetsInput{ + StackName: r.Form.Get("StackName"), + NextToken: r.Form.Get("NextToken"), + }) + if err != nil { + writeErr(w, err) + return + } + + var resp listChangeSetsResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.NextToken = list.NextToken + + for i := range list.Summaries { + cs := &list.Summaries[i] + resp.Result.Summaries = append(resp.Result.Summaries, changeSetSummaryXML{ + StackID: cs.StackID, StackName: cs.StackName, ChangeSetID: cs.ID, ChangeSetName: cs.Name, + ExecutionStatus: cs.ExecutionStatus, Status: cs.Status, StatusReason: cs.StatusReason, + CreationTime: isoTime(cs.CreationTime), Description: cs.Description, + }) + } + + awsquery.WriteXMLResponse(w, resp) +} + +func (h *Handler) executeChangeSet(w http.ResponseWriter, r *http.Request) { + in := &cfn.ExecuteChangeSetInput{ + ChangeSetName: r.Form.Get("ChangeSetName"), + StackName: r.Form.Get("StackName"), + ClientRequestToken: r.Form.Get("ClientRequestToken"), + } + + if r.Form.Has("DisableRollback") { + disable := strings.EqualFold(r.Form.Get("DisableRollback"), "true") + in.DisableRollback = &disable + } + + if err := h.api.ExecuteChangeSet(r.Context(), in); err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, executeChangeSetResponse{Xmlns: Namespace, Meta: meta()}) +} + +func (h *Handler) deleteChangeSet(w http.ResponseWriter, r *http.Request) { + err := h.api.DeleteChangeSet(r.Context(), &cfn.DeleteChangeSetInput{ + ChangeSetName: r.Form.Get("ChangeSetName"), + StackName: r.Form.Get("StackName"), + }) + if err != nil { + writeErr(w, err) + return + } + + awsquery.WriteXMLResponse(w, deleteChangeSetResponse{Xmlns: Namespace, Meta: meta()}) +} + +// createChangeSetInput reads a CreateChangeSet form. Absent topics keep the +// stack's, and an empty list removes them, as on UpdateStack. +func createChangeSetInput(form url.Values) *cfn.CreateChangeSetInput { + return &cfn.CreateChangeSetInput{ + StackName: form.Get("StackName"), + ChangeSetName: form.Get("ChangeSetName"), + ChangeSetType: form.Get("ChangeSetType"), + Description: form.Get("Description"), + TemplateBody: form.Get("TemplateBody"), + TemplateURL: form.Get("TemplateURL"), + UsePreviousTemplate: strings.EqualFold(form.Get("UsePreviousTemplate"), "true"), + Parameters: parseParameters(form), + Tags: parseTags(form), + Capabilities: awsquery.ListStrings(form, "Capabilities.member"), + NotificationARNs: updateNotificationARNs(form), + OnStackFailure: form.Get("OnStackFailure"), + ClientToken: form.Get("ClientToken"), + } +} + +func toChangeSetXML(cs *cfn.ChangeSet) describeChangeSetResult { + x := describeChangeSetResult{ + ChangeSetName: cs.Name, ChangeSetID: cs.ID, StackID: cs.StackID, StackName: cs.StackName, + Description: cs.Description, CreationTime: isoTime(cs.CreationTime), + ExecutionStatus: cs.ExecutionStatus, Status: cs.Status, StatusReason: cs.StatusReason, + NotificationARNs: cs.NotificationARNs, Capabilities: cs.Capabilities, + OnStackFailure: cs.OnStackFailure, NextToken: cs.NextToken, + } + + for _, p := range cs.Parameters { + x.Parameters = append(x.Parameters, parameterXML{ + ParameterKey: p.Key, ParameterValue: p.Value, ResolvedValue: p.ResolvedValue, + }) + } + + for k, v := range cs.Tags { + x.Tags = append(x.Tags, tagXML{Key: k, Value: v}) + } + + for i := range cs.Changes { + x.Changes = append(x.Changes, changeXML{Type: changeTypeResource, ResourceChange: toResourceChangeXML(&cs.Changes[i])}) + } + + return x +} + +func toResourceChangeXML(c *cfn.ResourceChange) resourceChangeXML { + x := resourceChangeXML{ + PolicyAction: c.PolicyAction, Action: c.Action, LogicalResourceID: c.LogicalID, + PhysicalResourceID: c.PhysicalID, ResourceType: c.ResourceType, Replacement: c.Replacement, + Scope: c.Scope, BeforeContext: c.BeforeContext, AfterContext: c.AfterContext, + } + + for i := range c.Details { + d := &c.Details[i] + x.Details = append(x.Details, changeDetailXML{ + Target: changeTargetXML{ + Attribute: d.Target.Attribute, Name: d.Target.Name, RequiresRecreation: d.Target.RequiresRecreation, + BeforeValue: d.Target.BeforeValue, AfterValue: d.Target.AfterValue, + AttributeChangeType: d.Target.AttributeChangeType, + }, + Evaluation: d.Evaluation, ChangeSource: d.ChangeSource, CausingEntity: d.CausingEntity, + }) + } + + return x +} + +type createChangeSetResponse struct { + XMLName xml.Name `xml:"CreateChangeSetResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + ID string `xml:"Id"` + StackID string `xml:"StackId"` + } `xml:"CreateChangeSetResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type changeTargetXML struct { + Attribute string `xml:"Attribute"` + Name string `xml:"Name,omitempty"` + RequiresRecreation string `xml:"RequiresRecreation"` + BeforeValue string `xml:"BeforeValue,omitempty"` + AfterValue string `xml:"AfterValue,omitempty"` + AttributeChangeType string `xml:"AttributeChangeType,omitempty"` +} + +type changeDetailXML struct { + Target changeTargetXML `xml:"Target"` + Evaluation string `xml:"Evaluation"` + ChangeSource string `xml:"ChangeSource"` + CausingEntity string `xml:"CausingEntity,omitempty"` +} + +type resourceChangeXML struct { + PolicyAction string `xml:"PolicyAction,omitempty"` + Action string `xml:"Action"` + LogicalResourceID string `xml:"LogicalResourceId"` + PhysicalResourceID string `xml:"PhysicalResourceId,omitempty"` + ResourceType string `xml:"ResourceType"` + Replacement string `xml:"Replacement,omitempty"` + Scope []string `xml:"Scope>member"` + Details []changeDetailXML `xml:"Details>member"` + BeforeContext string `xml:"BeforeContext,omitempty"` + AfterContext string `xml:"AfterContext,omitempty"` +} + +type changeXML struct { + Type string `xml:"Type"` + ResourceChange resourceChangeXML `xml:"ResourceChange"` +} + +type describeChangeSetResult struct { + ChangeSetName string `xml:"ChangeSetName"` + ChangeSetID string `xml:"ChangeSetId"` + StackID string `xml:"StackId"` + StackName string `xml:"StackName"` + Description string `xml:"Description,omitempty"` + Parameters []parameterXML `xml:"Parameters>member"` + CreationTime string `xml:"CreationTime"` + ExecutionStatus string `xml:"ExecutionStatus"` + Status string `xml:"Status"` + StatusReason string `xml:"StatusReason,omitempty"` + NotificationARNs []string `xml:"NotificationARNs>member"` + RollbackConfiguration struct{} `xml:"RollbackConfiguration"` + Capabilities []string `xml:"Capabilities>member"` + Tags []tagXML `xml:"Tags>member"` + Changes []changeXML `xml:"Changes>member"` + NextToken string `xml:"NextToken,omitempty"` + IncludeNestedStacks bool `xml:"IncludeNestedStacks"` + OnStackFailure string `xml:"OnStackFailure,omitempty"` +} + +type describeChangeSetResponse struct { + XMLName xml.Name `xml:"DescribeChangeSetResponse"` + Xmlns string `xml:"xmlns,attr"` + Result describeChangeSetResult `xml:"DescribeChangeSetResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type changeSetSummaryXML struct { + StackID string `xml:"StackId"` + StackName string `xml:"StackName"` + ChangeSetID string `xml:"ChangeSetId"` + ChangeSetName string `xml:"ChangeSetName"` + ExecutionStatus string `xml:"ExecutionStatus"` + Status string `xml:"Status"` + StatusReason string `xml:"StatusReason,omitempty"` + CreationTime string `xml:"CreationTime"` + Description string `xml:"Description,omitempty"` + IncludeNestedStacks bool `xml:"IncludeNestedStacks"` +} + +type listChangeSetsResponse struct { + XMLName xml.Name `xml:"ListChangeSetsResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + Summaries []changeSetSummaryXML `xml:"Summaries>member"` + NextToken string `xml:"NextToken,omitempty"` + } `xml:"ListChangeSetsResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type executeChangeSetResponse struct { + XMLName xml.Name `xml:"ExecuteChangeSetResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"ExecuteChangeSetResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + +type deleteChangeSetResponse struct { + XMLName xml.Name `xml:"DeleteChangeSetResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct{} `xml:"DeleteChangeSetResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} diff --git a/server/aws/cloudformation/changeset_sdk_test.go b/server/aws/cloudformation/changeset_sdk_test.go new file mode 100644 index 000000000..a70b71eb5 --- /dev/null +++ b/server/aws/cloudformation/changeset_sdk_test.go @@ -0,0 +1,349 @@ +package cloudformation_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfn "github.com/aws/aws-sdk-go-v2/service/cloudformation" + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" +) + +const queuesV1 = `Resources: + Tuned: + Type: AWS::SQS::Queue + Properties: + QueueName: cs-tuned + VisibilityTimeout: 30 + Renamed: + Type: AWS::SQS::Queue + Properties: + QueueName: cs-old +` + +// queuesV2 changes Tuned in place and renames Renamed, which replaces it. +const queuesV2 = `Resources: + Tuned: + Type: AWS::SQS::Queue + Properties: + QueueName: cs-tuned + VisibilityTimeout: 60 + Renamed: + Type: AWS::SQS::Queue + Properties: + QueueName: cs-new +` + +func waitChangeSet(t *testing.T, c *awscfn.Client, id string) *awscfn.DescribeChangeSetOutput { + t.Helper() + + w := awscfn.NewChangeSetCreateCompleteWaiter(c, func(o *awscfn.ChangeSetCreateCompleteWaiterOptions) { + o.MinDelay, o.MaxDelay = time.Millisecond, time.Millisecond + }) + + out, err := w.WaitForOutput(context.Background(), &awscfn.DescribeChangeSetInput{ChangeSetName: aws.String(id)}, time.Second) + if err != nil { + t.Fatalf("change set waiter: %v", err) + } + + return out +} + +func TestChangeSetCreateThenUpdateRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + created, err := c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("cs-stack"), ChangeSetName: aws.String("create"), + ChangeSetType: cfntypes.ChangeSetTypeCreate, TemplateBody: aws.String(queuesV1), + }) + if err != nil { + t.Fatalf("CreateChangeSet: %v", err) + } + + if st := describeStack(t, c, "cs-stack"); st.StackStatus != cfntypes.StackStatusReviewInProgress { + t.Fatalf("stack status %s", st.StackStatus) + } + + out := waitChangeSet(t, c, *created.Id) + if out.ExecutionStatus != cfntypes.ExecutionStatusAvailable || len(out.Changes) != 2 { + t.Fatalf("create change set: %s, %d changes", out.ExecutionStatus, len(out.Changes)) + } + + add := out.Changes[0] + if add.Type != cfntypes.ChangeTypeResource || add.ResourceChange.Action != cfntypes.ChangeActionAdd || + aws.ToString(add.ResourceChange.ResourceType) != "AWS::SQS::Queue" { + t.Fatalf("add change: %+v", add.ResourceChange) + } + + if _, err = c.ExecuteChangeSet(ctx, &awscfn.ExecuteChangeSetInput{ChangeSetName: created.Id}); err != nil { + t.Fatalf("ExecuteChangeSet: %v", err) + } + + st := describeStack(t, c, "cs-stack") + if st.StackStatus != cfntypes.StackStatusCreateComplete || aws.ToString(st.ChangeSetId) != *created.Id { + t.Fatalf("after create: %s, change set %q", st.StackStatus, aws.ToString(st.ChangeSetId)) + } + + upd, err := c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("cs-stack"), ChangeSetName: aws.String("update"), TemplateBody: aws.String(queuesV2), + }) + if err != nil { + t.Fatalf("CreateChangeSet UPDATE: %v", err) + } + + desc := waitChangeSet(t, c, *upd.Id) + checkUpdateChanges(t, desc.Changes) + + if _, err = c.ExecuteChangeSet(ctx, &awscfn.ExecuteChangeSetInput{ + StackName: aws.String("cs-stack"), ChangeSetName: aws.String("update"), + }); err != nil { + t.Fatalf("ExecuteChangeSet UPDATE: %v", err) + } + + if st = describeStack(t, c, "cs-stack"); st.StackStatus != cfntypes.StackStatusUpdateComplete { + t.Fatalf("after update: %s", st.StackStatus) + } + + res, err := c.DescribeStackResources(ctx, &awscfn.DescribeStackResourcesInput{StackName: aws.String("cs-stack")}) + if err != nil { + t.Fatalf("DescribeStackResources: %v", err) + } + + for _, r := range res.StackResources { + if aws.ToString(r.LogicalResourceId) == "Renamed" && !strings.HasSuffix(aws.ToString(r.PhysicalResourceId), "/cs-new") { + t.Fatalf("Renamed not replaced: %s", aws.ToString(r.PhysicalResourceId)) + } + } +} + +func checkUpdateChanges(t *testing.T, changes []cfntypes.Change) { + t.Helper() + + if len(changes) != 2 { + t.Fatalf("want 2 changes, got %d", len(changes)) + } + + renamed, tuned := changes[0].ResourceChange, changes[1].ResourceChange + if aws.ToString(renamed.LogicalResourceId) != "Renamed" || renamed.Action != cfntypes.ChangeActionModify || + renamed.Replacement != cfntypes.ReplacementTrue || renamed.PolicyAction != cfntypes.PolicyActionReplaceAndDelete { + t.Fatalf("Renamed change: %+v", renamed) + } + + d := renamed.Details[0] + if d.Target.Attribute != cfntypes.ResourceAttributeProperties || aws.ToString(d.Target.Name) != "QueueName" || + d.Target.RequiresRecreation != cfntypes.RequiresRecreationAlways || + d.Evaluation != cfntypes.EvaluationTypeStatic || d.ChangeSource != cfntypes.ChangeSourceDirectModification { + t.Fatalf("Renamed detail: %+v / %+v", d, d.Target) + } + + if tuned.Replacement != cfntypes.ReplacementFalse || len(tuned.Scope) != 1 || tuned.Scope[0] != cfntypes.ResourceAttributeProperties || + !strings.HasSuffix(aws.ToString(tuned.PhysicalResourceId), "/cs-tuned") { + t.Fatalf("Tuned change: %+v", tuned) + } + + if tuned.Details[0].Target.RequiresRecreation != cfntypes.RequiresRecreationNever { + t.Fatalf("Tuned recreation: %s", tuned.Details[0].Target.RequiresRecreation) + } +} + +func TestChangeSetErrorsRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{ + StackName: aws.String("s"), TemplateBody: aws.String(queuesV1), + }); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + same, err := c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("s"), ChangeSetName: aws.String("same"), TemplateBody: aws.String(queuesV1), + }) + if err != nil { + t.Fatalf("CreateChangeSet: %v", err) + } + + desc, err := c.DescribeChangeSet(ctx, &awscfn.DescribeChangeSetInput{ChangeSetName: same.Id}) + if err != nil { + t.Fatalf("DescribeChangeSet: %v", err) + } + + if desc.Status != cfntypes.ChangeSetStatusFailed || desc.ExecutionStatus != cfntypes.ExecutionStatusUnavailable || + !strings.HasPrefix(aws.ToString(desc.StatusReason), "The submitted information didn't contain changes.") { + t.Fatalf("no-change change set: %s %s %q", desc.Status, desc.ExecutionStatus, aws.ToString(desc.StatusReason)) + } + + _, err = c.ExecuteChangeSet(ctx, &awscfn.ExecuteChangeSetInput{ChangeSetName: same.Id}) + + var badStatus *cfntypes.InvalidChangeSetStatusException + if !errors.As(err, &badStatus) { + t.Fatalf("want InvalidChangeSetStatusException, got %v", err) + } + + _, err = c.DescribeChangeSet(ctx, &awscfn.DescribeChangeSetInput{StackName: aws.String("s"), ChangeSetName: aws.String("nope")}) + + var notFound *cfntypes.ChangeSetNotFoundException + if !errors.As(err, ¬Found) { + t.Fatalf("want ChangeSetNotFoundException, got %v", err) + } + + _, err = c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("s"), ChangeSetName: aws.String("same"), TemplateBody: aws.String(queuesV2), + }) + + var exists *cfntypes.AlreadyExistsException + if !errors.As(err, &exists) { + t.Fatalf("want AlreadyExistsException, got %v", err) + } + + _, err = c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("absent"), ChangeSetName: aws.String("u"), TemplateBody: aws.String(queuesV2), + }) + if code, msg := apiErrorCode(t, err); code != "ValidationError" || msg != "Stack [absent] does not exist" { + t.Fatalf("UPDATE on a missing stack: %s %s", code, msg) + } + + if _, err = c.DeleteChangeSet(ctx, &awscfn.DeleteChangeSetInput{ChangeSetName: same.Id}); err != nil { + t.Fatalf("DeleteChangeSet: %v", err) + } + + list, err := c.ListChangeSets(ctx, &awscfn.ListChangeSetsInput{StackName: aws.String("s")}) + if err != nil || len(list.Summaries) != 0 { + t.Fatalf("ListChangeSets after delete: %v, %d", err, len(list.Summaries)) + } +} + +func TestListChangeSetsRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + for _, name := range []string{"one", "two"} { + if _, err := c.CreateChangeSet(ctx, &awscfn.CreateChangeSetInput{ + StackName: aws.String("review"), ChangeSetName: aws.String(name), Description: aws.String("d-" + name), + ChangeSetType: cfntypes.ChangeSetTypeCreate, TemplateBody: aws.String(queuesV1), + }); err != nil { + t.Fatalf("CreateChangeSet %s: %v", name, err) + } + } + + var got []cfntypes.ChangeSetSummary + + p := awscfn.NewListChangeSetsPaginator(c, &awscfn.ListChangeSetsInput{StackName: aws.String("review")}) + for p.HasMorePages() { + page, err := p.NextPage(ctx) + if err != nil { + t.Fatalf("ListChangeSets: %v", err) + } + + got = append(got, page.Summaries...) + } + + if len(got) != 2 || aws.ToString(got[1].ChangeSetName) != "two" || aws.ToString(got[1].Description) != "d-two" || + got[0].Status != cfntypes.ChangeSetStatusCreateComplete || got[0].ExecutionStatus != cfntypes.ExecutionStatusAvailable || + aws.ToString(got[0].StackName) != "review" || got[0].CreationTime == nil { + t.Fatalf("summaries: %+v", got) + } +} + +func TestChangeSetClientTokenRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + in := &awscfn.CreateChangeSetInput{ + StackName: aws.String("tok"), ChangeSetName: aws.String("c"), ChangeSetType: cfntypes.ChangeSetTypeCreate, + TemplateBody: aws.String(queuesV1), ClientToken: aws.String("t1"), + } + + first, err := c.CreateChangeSet(ctx, in) + if err != nil { + t.Fatalf("CreateChangeSet: %v", err) + } + + again, err := c.CreateChangeSet(ctx, in) + if err != nil || aws.ToString(again.Id) != aws.ToString(first.Id) { + t.Fatalf("retry: %v, id %s want %s", err, aws.ToString(again.Id), aws.ToString(first.Id)) + } + + in.ClientToken = aws.String("t2") + + var exists *cfntypes.AlreadyExistsException + if _, err = c.CreateChangeSet(ctx, in); !errors.As(err, &exists) { + t.Fatalf("other token: want AlreadyExistsException, got %v", err) + } + + exec := &awscfn.ExecuteChangeSetInput{ChangeSetName: first.Id, ClientRequestToken: aws.String("r1")} + for range 2 { + if _, err = c.ExecuteChangeSet(ctx, exec); err != nil { + t.Fatalf("ExecuteChangeSet: %v", err) + } + } +} + +// After an update that fails with DisableRollback, GetTemplate returns the +// submitted body byte for byte, comments included, as Terraform compares it. +func TestUpdateFailedKeepsSubmittedTemplateRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{StackName: aws.String("tf"), TemplateBody: aws.String(queuesV1)}); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + v2 := "# second version\n" + queuesV2 + " Broken: # never created\n Type: AWS::Unknown::Thing\n" + + if _, err := c.UpdateStack(ctx, &awscfn.UpdateStackInput{ + StackName: aws.String("tf"), TemplateBody: aws.String(v2), DisableRollback: aws.Bool(true), + }); err != nil { + t.Fatalf("UpdateStack: %v", err) + } + + st := describeStack(t, c, "tf") + if st.StackStatus != cfntypes.StackStatusUpdateFailed || !aws.ToBool(st.DisableRollback) { + t.Fatalf("status %s, DisableRollback %v", st.StackStatus, aws.ToBool(st.DisableRollback)) + } + + out, err := c.GetTemplate(ctx, &awscfn.GetTemplateInput{StackName: aws.String("tf")}) + if err != nil || aws.ToString(out.TemplateBody) != v2 { + t.Fatalf("GetTemplate: %v\n%q\nwant\n%q", err, aws.ToString(out.TemplateBody), v2) + } +} + +// aws cloudformation deploy reads the live stack's parameter keys with +// GetTemplateSummary before it plans an update. +func TestGetTemplateSummaryRealSDK(t *testing.T) { + ctx := context.Background() + c, _ := bootWithProvider(t) + + body := "AWSTemplateFormatVersion: '2010-09-09'\nDescription: summary\n" + + "Parameters:\n Name: {Type: String, Default: q}\n" + queuesV1 + + if _, err := c.CreateStack(ctx, &awscfn.CreateStackInput{StackName: aws.String("sum"), TemplateBody: aws.String(body)}); err != nil { + t.Fatalf("CreateStack: %v", err) + } + + for _, in := range []*awscfn.GetTemplateSummaryInput{ + {StackName: aws.String("sum")}, + {TemplateBody: aws.String(body)}, + } { + out, err := c.GetTemplateSummary(ctx, in) + if err != nil { + t.Fatalf("GetTemplateSummary: %v", err) + } + + if len(out.Parameters) != 1 || aws.ToString(out.Parameters[0].ParameterKey) != "Name" || + aws.ToString(out.Parameters[0].ParameterType) != "String" || aws.ToString(out.Parameters[0].DefaultValue) != "q" || + aws.ToString(out.Description) != "summary" || aws.ToString(out.Version) != "2010-09-09" || + len(out.ResourceTypes) != 1 || out.ResourceTypes[0] != "AWS::SQS::Queue" { + t.Fatalf("summary: %+v", out) + } + } + + _, err := c.GetTemplateSummary(ctx, &awscfn.GetTemplateSummaryInput{StackName: aws.String("nope")}) + if code, _ := apiErrorCode(t, err); code != "ValidationError" { + t.Fatalf("missing stack: %s", code) + } +} diff --git a/server/aws/cloudformation/handler.go b/server/aws/cloudformation/handler.go index 90a32f5d7..47f99f1cc 100644 --- a/server/aws/cloudformation/handler.go +++ b/server/aws/cloudformation/handler.go @@ -24,6 +24,12 @@ // ListStackResources API.ListStackResources // GetTemplate API.GetTemplate // ValidateTemplate API.ValidateTemplate +// GetTemplateSummary API.GetTemplateSummary +// CreateChangeSet API.CreateChangeSet +// DescribeChangeSet API.DescribeChangeSet +// ListChangeSets API.ListChangeSets +// ExecuteChangeSet API.ExecuteChangeSet +// DeleteChangeSet API.DeleteChangeSet // // Templates may be JSON or YAML, given inline (TemplateBody) or as an S3 // object URL (TemplateURL). @@ -60,6 +66,12 @@ var cfnActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup "ListStackResources": {}, "GetTemplate": {}, "ValidateTemplate": {}, + "GetTemplateSummary": {}, + actionCreateChangeSet: {}, + actionDescribeChangeSet: {}, + actionListChangeSets: {}, + actionExecuteChangeSet: {}, + actionDeleteChangeSet: {}, } // Handler serves CloudFormation query-protocol requests against a stack API. @@ -123,6 +135,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getTemplate(w, r) case "ValidateTemplate": h.validateTemplate(w, r) + case "GetTemplateSummary": + h.getTemplateSummary(w, r) + case actionCreateChangeSet, actionDescribeChangeSet, actionListChangeSets, actionExecuteChangeSet, actionDeleteChangeSet: + h.serveChangeSet(w, r) default: awsquery.WriteXMLError(w, http.StatusBadRequest, "InvalidAction", "unknown CloudFormation action: "+r.Form.Get("Action")) @@ -139,7 +155,12 @@ func writeErr(w http.ResponseWriter, err error) { switch { case errors.As(err, &named): - awsquery.WriteXMLError(w, http.StatusBadRequest, named.Exception(), msg) + status := http.StatusBadRequest + if named.Exception() == cfn.ExceptionChangeSetNotFound { + status = http.StatusNotFound + } + + awsquery.WriteXMLError(w, status, named.Exception(), msg) case cerrors.IsNotFound(err): awsquery.WriteXMLError(w, http.StatusBadRequest, "ValidationError", msg) case cerrors.IsAlreadyExists(err): diff --git a/server/aws/cloudformation/operations.go b/server/aws/cloudformation/operations.go index b25e7902f..0e4bbf6a4 100644 --- a/server/aws/cloudformation/operations.go +++ b/server/aws/cloudformation/operations.go @@ -198,6 +198,39 @@ func (h *Handler) stackIdentity(r *http.Request, nameOrID string) (id, name stri return stacks[0].ID, stacks[0].Name } +func (h *Handler) getTemplateSummary(w http.ResponseWriter, r *http.Request) { + sum, err := h.api.GetTemplateSummary(r.Context(), &cfn.GetTemplateSummaryInput{ + StackName: r.Form.Get("StackName"), + TemplateBody: r.Form.Get("TemplateBody"), + TemplateURL: r.Form.Get("TemplateURL"), + }) + if err != nil { + writeErr(w, err) + return + } + + var resp getTemplateSummaryResponse + resp.Xmlns = Namespace + resp.Meta = meta() + resp.Result.Description = sum.Description + resp.Result.Capabilities = sum.Capabilities + resp.Result.CapabilitiesReason = sum.CapabilitiesReason + resp.Result.DeclaredTransforms = sum.DeclaredTransforms + resp.Result.ResourceTypes = sum.ResourceTypes + resp.Result.Version = sum.Version + + for _, p := range sum.Parameters { + x := parameterDeclarationXML{ParameterKey: p.Key, ParameterType: p.Type, NoEcho: p.NoEcho, Description: p.Description} + if p.HasDefault { + x.DefaultValue = &p.DefaultValue + } + + resp.Result.Parameters = append(resp.Result.Parameters, x) + } + + awsquery.WriteXMLResponse(w, resp) +} + func (h *Handler) validateTemplate(w http.ResponseWriter, r *http.Request) { sum, err := h.api.ValidateTemplate(r.Context(), &cfn.ValidateTemplateInput{ TemplateBody: r.Form.Get("TemplateBody"), diff --git a/server/aws/cloudformation/types.go b/server/aws/cloudformation/types.go index caf98f975..f4529633a 100644 --- a/server/aws/cloudformation/types.go +++ b/server/aws/cloudformation/types.go @@ -55,6 +55,7 @@ func updateInput(form url.Values) cfn.UpdateStackInput { Capabilities: awsquery.ListStrings(form, "Capabilities.member"), UsePreviousTemplate: strings.EqualFold(form.Get("UsePreviousTemplate"), "true"), + DisableRollback: strings.EqualFold(form.Get("DisableRollback"), "true"), NotificationARNs: updateNotificationARNs(form), } } @@ -181,6 +182,7 @@ type stackXML struct { Tags []tagXML `xml:"Tags>member,omitempty"` Capabilities []string `xml:"Capabilities>member,omitempty"` NotificationARNs []string `xml:"NotificationARNs>member,omitempty"` + ChangeSetID string `xml:"ChangeSetId,omitempty"` } type describeStacksResponse struct { @@ -300,14 +302,37 @@ type validateTemplateResponse struct { Meta responseMetadata `xml:"ResponseMetadata"` } +type parameterDeclarationXML struct { + ParameterKey string `xml:"ParameterKey"` + DefaultValue *string `xml:"DefaultValue,omitempty"` + ParameterType string `xml:"ParameterType"` + NoEcho bool `xml:"NoEcho"` + Description string `xml:"Description,omitempty"` +} + +type getTemplateSummaryResponse struct { + XMLName xml.Name `xml:"GetTemplateSummaryResponse"` + Xmlns string `xml:"xmlns,attr"` + Result struct { + Parameters []parameterDeclarationXML `xml:"Parameters>member"` + Description string `xml:"Description,omitempty"` + Capabilities []string `xml:"Capabilities>member,omitempty"` + CapabilitiesReason string `xml:"CapabilitiesReason,omitempty"` + ResourceTypes []string `xml:"ResourceTypes>member"` + Version string `xml:"Version,omitempty"` + DeclaredTransforms []string `xml:"DeclaredTransforms>member"` + } `xml:"GetTemplateSummaryResult"` + Meta responseMetadata `xml:"ResponseMetadata"` +} + // --- mapping helpers --- func toStackXML(s *cfn.Stack) stackXML { x := stackXML{ StackID: s.ID, StackName: s.Name, Description: s.Description, CreationTime: isoTime(s.CreationTime), LastUpdatedTime: isoTime(s.LastUpdated), - StackStatus: s.Status, StackStatusReason: s.StatusReason, - Capabilities: s.Capabilities, NotificationARNs: s.NotificationARNs, + StackStatus: s.Status, StackStatusReason: s.StatusReason, DisableRollback: s.DisableRollback, + Capabilities: s.Capabilities, NotificationARNs: s.NotificationARNs, ChangeSetID: s.ChangeSetID, } for _, p := range s.Parameters { diff --git a/services/cloudformation/changeplan.go b/services/cloudformation/changeplan.go new file mode 100644 index 000000000..aeb902387 --- /dev/null +++ b/services/cloudformation/changeplan.go @@ -0,0 +1,432 @@ +package cloudformation + +import ( + "encoding/json" + "sort" + "strings" +) + +// LiveResource is a deployed resource as the change planner sees it. +type LiveResource struct { + Type string + PhysicalID string + // Props holds the resolved properties the resource was last applied with. + Props map[string]any +} + +// ChangePlanInput is what PlanChanges compares. Old and New have their +// conditions applied. Old is nil for a stack that does not exist yet. +type ChangePlanInput struct { + Old, New *Template + Live map[string]LiveResource + // NewProps holds each resource's resolved new properties. A resource is + // missing when its properties cannot be resolved before execution, such + // as when they reference a resource that is not created yet. + NewProps map[string]map[string]any + // ChangedParams names the parameters whose values change. + ChangedParams map[string]bool + Registry Registry +} + +// How a planned change can affect the values other resources read from it. +const ( + effectModify = iota + 1 + effectReplace +) + +// PlanChanges lists what an update from Old to New does to each resource, in +// logical ID order: Add for a new resource, Remove for a dropped one, and +// Modify for a kept resource with a changed property. A Modify reports each +// cause. A template edit is a static DirectModification. A changed parameter +// is a static ParameterReference. A reference to a resource that may be +// replaced, or an attribute of one that changes, is a dynamic +// ResourceReference or ResourceAttribute, whose value is only known at +// execution. Resources are walked in dependency order, so a change reaches +// the resources that read from it. +func PlanChanges(in *ChangePlanInput) []ResourceChange { + order, err := OrderResources(in.New) + if err != nil { + order = sortedKeys(in.New.Resources) + } + + effects := map[string]int{} + + var out []ResourceChange + + for _, id := range order { + rdef := in.New.Resources[id] + + live, ok := in.Live[id] + if !ok { + out = append(out, ResourceChange{ + Action: ChangeActionAdd, LogicalID: id, ResourceType: rdef.Type, + AfterContext: afterContext(in.NewProps[id], rdef.Properties, effects), + }) + + // A new resource's Ref and attributes are only known once it + // exists. + effects[id] = effectReplace + + continue + } + + if c, changed := in.modifyChange(id, rdef.Type, &live, effects); changed { + out = append(out, c) + } + } + + for id, live := range in.Live { + if _, kept := in.New.Resources[id]; !kept { + out = append(out, ResourceChange{ + Action: ChangeActionRemove, LogicalID: id, PhysicalID: live.PhysicalID, + ResourceType: live.Type, PolicyAction: PolicyDelete, BeforeContext: propertiesContext(live.Props), + }) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].LogicalID < out[j].LogicalID }) + + return out +} + +// modifyChange builds the Modify change of a kept resource, and records how +// it affects the resources that read from it. It reports false when the +// resource does not change. +func (in *ChangePlanInput) modifyChange(id, rtype string, live *LiveResource, effects map[string]int) (ResourceChange, bool) { + var oldRaw map[string]any + if in.Old != nil { + oldRaw = in.Old.Resources[id].Properties + } + + newRaw := in.New.Resources[id].Properties + newProps, resolved := in.NewProps[id] + + names := propertyNames(oldRaw, newRaw, live.Props, newProps) + details := make([]ChangeDetail, 0, len(names)) + + for _, name := range names { + p := propertyChange{ + oldRaw: oldRaw[name], newRaw: newRaw[name], hasOldRaw: in.Old != nil, + before: live.Props[name], after: newProps[name], resolved: resolved, + } + target := in.target(rtype, name) + details = append(details, in.propertyDetails(&p, &target, effects)...) + } + + if len(details) == 0 { + return ResourceChange{}, false + } + + c := ResourceChange{ + Action: ChangeActionModify, LogicalID: id, PhysicalID: live.PhysicalID, ResourceType: rtype, + Replacement: replacementOf(details), Scope: scopeOf(details), Details: details, + BeforeContext: propertiesContext(live.Props), + } + + if resolved { + c.AfterContext = afterContext(newProps, newRaw, effects) + } + + effects[id] = effectModify + + if c.Replacement != ReplacementFalse { + effects[id] = effectReplace + } + + if c.Replacement == ReplacementTrue { + c.PolicyAction = PolicyReplaceAndDelete + } + + return c, true +} + +// propertyChange is one property of a kept resource: its template values and +// its resolved values before and after. +type propertyChange struct { + oldRaw, newRaw any + hasOldRaw bool + before, after any + resolved bool +} + +// propertyDetails lists the causes of a change to one property. +func (in *ChangePlanInput) propertyDetails(p *propertyChange, target *ChangeTarget, effects map[string]int) []ChangeDetail { + var out []ChangeDetail + + add := func(evaluation, source, entity string) { + out = append(out, ChangeDetail{Target: *target, Evaluation: evaluation, ChangeSource: source, CausingEntity: entity}) + } + + if p.hasOldRaw && !sameValue(p.oldRaw, p.newRaw) { + add(EvaluationStatic, SourceDirectModification, "") + } + + in.referenceCauses(p.newRaw, effects, add) + + // A resolved value can change with no cause in the template, such as a + // pseudo parameter that now resolves differently. + if len(out) == 0 && p.resolved && !sameValue(p.before, p.after) { + add(EvaluationStatic, SourceDirectModification, "") + } + + for i := range out { + setValues(&out[i].Target, p, out[i].Evaluation) + } + + return out +} + +// referenceCauses reports the references in a property value whose values +// change: a changed parameter, a resource that may be replaced, or an +// attribute of a resource that changes. +func (in *ChangePlanInput) referenceCauses(node any, effects map[string]int, add func(evaluation, source, entity string)) { + refs, atts := referencedEntities(node) + + for _, name := range refs { + switch { + case in.ChangedParams[name]: + add(EvaluationStatic, SourceParameterReference, name) + case effects[name] == effectReplace: + add(EvaluationDynamic, SourceResourceReference, name) + } + } + + for _, att := range atts { + logical, _, _ := strings.Cut(att, ".") + if effects[logical] != 0 { + add(EvaluationDynamic, SourceResourceAttribute, att) + } + } +} + +// target is the change target of one property. A Tags change targets the +// Tags attribute, which never recreates the resource. +func (in *ChangePlanInput) target(rtype, name string) ChangeTarget { + if name == AttributeTags { + return ChangeTarget{Attribute: AttributeTags, RequiresRecreation: RecreationNever} + } + + t := ChangeTarget{Attribute: AttributeProperties, Name: name, RequiresRecreation: RecreationAlways} + + if schema, ok := in.Registry[rtype].(ReplacementSchema); ok && !schema.RequiresReplacement(name) { + t.RequiresRecreation = RecreationNever + } + + return t +} + +// knownAfterApply is the after value of a change that is only known once +// the change set runs. +const knownAfterApply = "{{changeSet:KNOWN_AFTER_APPLY}}" + +// setValues fills a target's before and after values. +func setValues(t *ChangeTarget, p *propertyChange, evaluation string) { + t.BeforeValue = valueString(p.before) + t.AttributeChangeType = ChangeActionModify + + switch { + case evaluation == EvaluationDynamic: + t.AfterValue = knownAfterApply + case p.resolved: + t.AfterValue = valueString(p.after) + } + + switch { + case p.before == nil: + t.AttributeChangeType = ChangeActionAdd + case p.resolved && p.after == nil: + t.AttributeChangeType = ChangeActionRemove + } +} + +// replacementOf rolls a resource's details up into its Replacement value. A +// property that always recreates the resource makes it True when its value +// is known now, and Conditional when it is only known at execution. +func replacementOf(details []ChangeDetail) string { + out := ReplacementFalse + + for i := range details { + d := &details[i] + if d.Target.RequiresRecreation != RecreationAlways { + continue + } + + if d.Evaluation == EvaluationStatic { + return ReplacementTrue + } + + out = ReplacementConditional + } + + return out +} + +// scopeOf lists the attributes a resource's details touch. +func scopeOf(details []ChangeDetail) []string { + var props, tags bool + + for i := range details { + attr := details[i].Target.Attribute + props = props || attr == AttributeProperties + tags = tags || attr == AttributeTags + } + + var out []string + + if props { + out = append(out, AttributeProperties) + } + + if tags { + out = append(out, AttributeTags) + } + + return out +} + +// propertyNames returns the sorted names any of the property maps set. +func propertyNames(maps ...map[string]any) []string { + names := map[string]bool{} + + for _, m := range maps { + for k := range m { + names[k] = true + } + } + + return sortedKeys(names) +} + +// referencedEntities returns, sorted, the names a property value reads with +// Ref or a plain Fn::Sub variable, and the "Resource.Attribute" names it +// reads with Fn::GetAtt or a dotted Fn::Sub variable. +func referencedEntities(node any) (refs, atts []string) { + refSet, attSet := map[string]bool{}, map[string]bool{} + + walkIntrinsics(node, func(fn string, arg any) { + switch fn { + case fnRef: + if s, ok := arg.(string); ok { + refSet[s] = true + } + case fnGetAtt: + if logical, attr := splitGetAtt(arg); logical != "" { + attSet[logical+"."+attr] = true + } + case fnSub: + subEntities(arg, refSet, attSet) + } + }) + + return sortedKeys(refSet), sortedKeys(attSet) +} + +// subEntities records the variables of an Fn::Sub template that are not +// given in its variable map. +func subEntities(arg any, refs, atts map[string]bool) { + var ( + tmpl string + locals map[string]any + ) + + switch v := arg.(type) { + case string: + tmpl = v + case []any: + if len(v) > 0 { + tmpl, _ = v[0].(string) + } + + if len(v) > 1 { + locals, _ = v[1].(map[string]any) + } + } + + for _, tok := range subVarPattern.FindAllString(tmpl, -1) { + name := tok[2 : len(tok)-1] + if _, local := locals[name]; local || strings.HasPrefix(name, "!") { + continue + } + + if strings.Contains(name, ".") { + atts[name] = true + } else { + refs[name] = true + } + } +} + +// valueString renders a property value the way a change target reports it: +// a string as is, anything else as JSON. +func valueString(v any) string { + if v == nil { + return "" + } + + if s, ok := v.(string); ok { + return s + } + + b, err := json.Marshal(v) + if err != nil { + return "" + } + + return string(b) +} + +// afterContext renders a resource's resolved new properties as its after +// context. A property that reads a value only known at execution shows the +// KNOWN_AFTER_APPLY placeholder instead of the value it resolves to now. +func afterContext(props, raw map[string]any, effects map[string]int) string { + if props == nil { + return "" + } + + out := make(map[string]any, len(props)) + + for name, v := range props { + out[name] = v + + if dynamicValue(raw[name], effects) { + out[name] = knownAfterApply + } + } + + return propertiesContext(out) +} + +// dynamicValue reports whether a property value reads a resource that may be +// replaced, or an attribute of a resource that changes. +func dynamicValue(node any, effects map[string]int) bool { + refs, atts := referencedEntities(node) + + for _, name := range refs { + if effects[name] == effectReplace { + return true + } + } + + for _, att := range atts { + if logical, _, _ := strings.Cut(att, "."); effects[logical] != 0 { + return true + } + } + + return false +} + +// propertiesContext renders a resource's properties as the JSON context a +// change reports, or "" when they are not known. +func propertiesContext(props map[string]any) string { + if props == nil { + return "" + } + + b, err := json.Marshal(map[string]any{AttributeProperties: props}) + if err != nil { + return "" + } + + return string(b) +} diff --git a/services/cloudformation/changeset.go b/services/cloudformation/changeset.go new file mode 100644 index 000000000..831e65957 --- /dev/null +++ b/services/cloudformation/changeset.go @@ -0,0 +1,211 @@ +package cloudformation + +import "time" + +// StatusReviewInProgress is the status of a stack that a CREATE change set +// made and that has not been executed yet. +const StatusReviewInProgress = "REVIEW_IN_PROGRESS" + +// Change set types. +const ( + ChangeSetTypeCreate = "CREATE" + ChangeSetTypeUpdate = "UPDATE" + ChangeSetTypeImport = "IMPORT" +) + +// Change set Status values. Change sets are planned synchronously, so a new +// one is already CREATE_COMPLETE or FAILED when CreateChangeSet returns. +const ( + ChangeSetStatusCreateComplete = "CREATE_COMPLETE" + ChangeSetStatusFailed = "FAILED" +) + +// Change set ExecutionStatus values. +const ( + ExecutionUnavailable = "UNAVAILABLE" + ExecutionAvailable = "AVAILABLE" + ExecutionInProgress = "EXECUTE_IN_PROGRESS" + ExecutionComplete = "EXECUTE_COMPLETE" + ExecutionFailed = "EXECUTE_FAILED" + ExecutionObsolete = "OBSOLETE" +) + +// OnStackFailure values: what executing a change set does when the stack +// operation fails. +const ( + OnStackFailureRollback = "ROLLBACK" + OnStackFailureDoNothing = "DO_NOTHING" + OnStackFailureDelete = "DELETE" +) + +// Change actions. A property value change also reports one of these as its +// AttributeChangeType. +const ( + ChangeActionAdd = "Add" + ChangeActionModify = "Modify" + ChangeActionRemove = "Remove" +) + +// Replacement values of a Modify change. +const ( + ReplacementTrue = "True" + ReplacementFalse = "False" + ReplacementConditional = "Conditional" +) + +// RequiresRecreation values of a change target. +const ( + RecreationNever = "Never" + RecreationAlways = "Always" +) + +// Resource attributes a change can target. +const ( + AttributeProperties = "Properties" + AttributeTags = "Tags" +) + +// Change sources: the kind of entity that caused a change. +const ( + SourceDirectModification = "DirectModification" + SourceParameterReference = "ParameterReference" + SourceResourceReference = "ResourceReference" + SourceResourceAttribute = "ResourceAttribute" +) + +// Evaluation types of a change detail. +const ( + EvaluationStatic = "Static" + EvaluationDynamic = "Dynamic" +) + +// Policy actions taken on the physical resource of a change. +const ( + PolicyDelete = "Delete" + PolicyReplaceAndDelete = "ReplaceAndDelete" +) + +// Exception names the change set operations report. +const ( + ExceptionAlreadyExists = "AlreadyExistsException" + ExceptionChangeSetNotFound = "ChangeSetNotFound" + ExceptionInvalidChangeSetStatus = "InvalidChangeSetStatus" +) + +// ChangeTarget is the part of a resource a change detail touches. The value +// fields are filled only when a caller asks for property values. +type ChangeTarget struct { + Attribute string + Name string + RequiresRecreation string + + BeforeValue string + AfterValue string + AttributeChangeType string +} + +// ChangeDetail is one reason a resource changes. +type ChangeDetail struct { + Target ChangeTarget + Evaluation string + ChangeSource string + CausingEntity string +} + +// ResourceChange is what executing a change set does to one resource. +type ResourceChange struct { + Action string + LogicalID string + PhysicalID string + ResourceType string + Replacement string + PolicyAction string + Scope []string + Details []ChangeDetail + // BeforeContext and AfterContext hold the resource's properties as JSON. + // They are returned only when a caller asks for property values. + BeforeContext string + AfterContext string +} + +// ChangeSet is a stored change set as DescribeChangeSet and ListChangeSets +// report it. +type ChangeSet struct { + ID string + Name string + StackID string + StackName string + Type string + Description string + Status string + StatusReason string + ExecutionStatus string + CreationTime time.Time + Parameters []Parameter + Tags map[string]string + Capabilities []string + NotificationARNs []string + OnStackFailure string + Changes []ResourceChange + // NextToken is set on a DescribeChangeSet page that has more changes. + NextToken string `json:"-"` +} + +// CreateChangeSetInput is the request to create a change set. ChangeSetType +// defaults to UPDATE. +type CreateChangeSetInput struct { + StackName string + ChangeSetName string + ChangeSetType string + Description string + TemplateBody string + TemplateURL string + UsePreviousTemplate bool + Parameters []Parameter + Tags map[string]string + Capabilities []string + NotificationARNs []string + OnStackFailure string + // ClientToken makes a retry of the same request return the change set it + // made instead of failing on the duplicate name. + ClientToken string +} + +// DescribeChangeSetInput names a change set by ARN, or by name within +// StackName. +type DescribeChangeSetInput struct { + ChangeSetName string + StackName string + NextToken string + IncludePropertyValues bool +} + +// ListChangeSetsInput is the request to list a stack's change sets. +type ListChangeSetsInput struct { + StackName string + NextToken string +} + +// ChangeSetList is one page of change set summaries. The summaries carry no +// Changes. +type ChangeSetList struct { + Summaries []ChangeSet + NextToken string +} + +// ExecuteChangeSetInput is the request to execute a change set. +// DisableRollback is nil when the request leaves it out. +type ExecuteChangeSetInput struct { + ChangeSetName string + StackName string + DisableRollback *bool + // ClientRequestToken makes a retry of an execution that already started + // succeed without running it again. + ClientRequestToken string +} + +// DeleteChangeSetInput is the request to delete a change set. +type DeleteChangeSetInput struct { + ChangeSetName string + StackName string +} diff --git a/services/cloudformation/changeset_test.go b/services/cloudformation/changeset_test.go new file mode 100644 index 000000000..44c4486af --- /dev/null +++ b/services/cloudformation/changeset_test.go @@ -0,0 +1,259 @@ +package cloudformation_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" +) + +func mustTemplate(t *testing.T, body string) *cfn.Template { + t.Helper() + + tmpl, err := cfn.ParseTemplate(body) + require.NoError(t, err) + + return tmpl +} + +func planRegistry() cfn.Registry { + return cfn.Registry{"Test::Named": namedProv{}, "Test::Plain": plainProv{}} +} + +func changeFor(t *testing.T, changes []cfn.ResourceChange, id string) cfn.ResourceChange { + t.Helper() + + for _, c := range changes { + if c.LogicalID == id { + return c + } + } + + t.Fatalf("no change for %s in %+v", id, changes) + + return cfn.ResourceChange{} +} + +func TestPlanChangesNewStackAddsEveryResource(t *testing.T) { + t.Parallel() + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + New: mustTemplate(t, `{"Resources":{ + "B":{"Type":"Test::Named","Properties":{"Name":"b"}}, + "A":{"Type":"Test::Plain"} + }}`), + Registry: planRegistry(), + }) + + require.Len(t, changes, 2) + assert.Equal(t, "A", changes[0].LogicalID) + assert.Equal(t, cfn.ChangeActionAdd, changes[0].Action) + assert.Equal(t, "Test::Plain", changes[0].ResourceType) + assert.Empty(t, changes[0].Replacement) + assert.Empty(t, changes[0].Details) + assert.Empty(t, changes[0].PhysicalID) + assert.Equal(t, "B", changes[1].LogicalID) +} + +const planOld = `{ + "Parameters":{"Size":{"Type":"String"}}, + "Resources":{ + "Src":{"Type":"Test::Named","Properties":{"Name":"src","Size":"1"}}, + "ByRef":{"Type":"Test::Named","Properties":{"Name":{"Ref":"Src"}}}, + "ByAtt":{"Type":"Test::Named","Properties":{"Label":{"Fn::GetAtt":["Src","Arn"]}}}, + "ByParam":{"Type":"Test::Named","Properties":{"Size":{"Ref":"Size"}}}, + "Gone":{"Type":"Test::Plain"} + } +}` + +func planLive() map[string]cfn.LiveResource { + return map[string]cfn.LiveResource{ + "Src": {Type: "Test::Named", PhysicalID: "src", Props: map[string]any{"Name": "src", "Size": "1"}}, + "ByRef": {Type: "Test::Named", PhysicalID: "byref", Props: map[string]any{"Name": "src"}}, + "ByAtt": {Type: "Test::Named", PhysicalID: "byatt", Props: map[string]any{"Label": "arn:src"}}, + "ByParam": {Type: "Test::Named", PhysicalID: "byparam", Props: map[string]any{"Size": "5"}}, + "Gone": {Type: "Test::Plain", PhysicalID: "gone"}, + } +} + +// A new Name on Src replaces it. The replacement reaches ByRef through Ref, so +// ByRef's Name, which also requires replacement, may be replaced. ByAtt reads +// an attribute of the modified Src. ByParam uses a changed parameter. +func TestPlanChangesUpdateMatrix(t *testing.T) { + t.Parallel() + + newBody := `{ + "Parameters":{"Size":{"Type":"String"}}, + "Resources":{ + "Src":{"Type":"Test::Named","Properties":{"Name":"src2","Size":"1"}}, + "ByRef":{"Type":"Test::Named","Properties":{"Name":{"Ref":"Src"}}}, + "ByAtt":{"Type":"Test::Named","Properties":{"Label":{"Fn::GetAtt":["Src","Arn"]}}}, + "ByParam":{"Type":"Test::Named","Properties":{"Size":{"Ref":"Size"}}}, + "Fresh":{"Type":"Test::Plain","Properties":{"Label":{"Ref":"Src"},"Size":"3"}} + } + }` + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + Old: mustTemplate(t, planOld), + New: mustTemplate(t, newBody), + Live: planLive(), + NewProps: map[string]map[string]any{ + "Src": {"Name": "src2", "Size": "1"}, "ByRef": {"Name": "src"}, "Fresh": {"Label": "src", "Size": "3"}, + }, + ChangedParams: map[string]bool{"Size": true}, + Registry: planRegistry(), + }) + + require.Len(t, changes, 6) + + src := changeFor(t, changes, "Src") + assert.Equal(t, cfn.ChangeActionModify, src.Action) + assert.Equal(t, "src", src.PhysicalID) + assert.Equal(t, cfn.ReplacementTrue, src.Replacement) + assert.Equal(t, cfn.PolicyReplaceAndDelete, src.PolicyAction) + assert.Equal(t, []string{cfn.AttributeProperties}, src.Scope) + require.Len(t, src.Details, 1) + assert.Equal(t, cfn.ChangeDetail{ + Target: cfn.ChangeTarget{ + Attribute: cfn.AttributeProperties, Name: "Name", RequiresRecreation: cfn.RecreationAlways, + BeforeValue: "src", AfterValue: "src2", AttributeChangeType: cfn.ChangeActionModify, + }, + Evaluation: cfn.EvaluationStatic, ChangeSource: cfn.SourceDirectModification, + }, src.Details[0]) + + byRef := changeFor(t, changes, "ByRef") + assert.Equal(t, cfn.ReplacementConditional, byRef.Replacement) + assert.Empty(t, byRef.PolicyAction) + require.Len(t, byRef.Details, 1) + assert.Equal(t, cfn.EvaluationDynamic, byRef.Details[0].Evaluation) + assert.Equal(t, cfn.SourceResourceReference, byRef.Details[0].ChangeSource) + assert.Equal(t, "Src", byRef.Details[0].CausingEntity) + assert.Equal(t, cfn.RecreationAlways, byRef.Details[0].Target.RequiresRecreation) + + byAtt := changeFor(t, changes, "ByAtt") + assert.Equal(t, cfn.ReplacementFalse, byAtt.Replacement) + require.Len(t, byAtt.Details, 1) + assert.Equal(t, cfn.SourceResourceAttribute, byAtt.Details[0].ChangeSource) + assert.Equal(t, "Src.Arn", byAtt.Details[0].CausingEntity) + assert.Equal(t, cfn.RecreationNever, byAtt.Details[0].Target.RequiresRecreation) + + byParam := changeFor(t, changes, "ByParam") + assert.Equal(t, cfn.ReplacementFalse, byParam.Replacement) + require.Len(t, byParam.Details, 1) + assert.Equal(t, cfn.EvaluationStatic, byParam.Details[0].Evaluation) + assert.Equal(t, cfn.SourceParameterReference, byParam.Details[0].ChangeSource) + assert.Equal(t, "Size", byParam.Details[0].CausingEntity) + + gone := changeFor(t, changes, "Gone") + assert.Equal(t, cfn.ChangeActionRemove, gone.Action) + assert.Equal(t, "gone", gone.PhysicalID) + assert.Equal(t, cfn.PolicyDelete, gone.PolicyAction) + assert.Empty(t, gone.Replacement) + + fresh := changeFor(t, changes, "Fresh") + assert.Equal(t, cfn.ChangeActionAdd, fresh.Action) + + // A value only known once Src is replaced shows as the placeholder in + // the after context, never as the stale resolved value. + assert.JSONEq(t, `{"Properties":{"Name":"{{changeSet:KNOWN_AFTER_APPLY}}"}}`, byRef.AfterContext) + assert.JSONEq(t, `{"Properties":{"Label":"{{changeSet:KNOWN_AFTER_APPLY}}","Size":"3"}}`, fresh.AfterContext) +} + +// An unchanged resource is not listed, and a resource whose referenced +// resource is only modified in place keeps its Ref value, so it is not listed +// either. +func TestPlanChangesInPlaceDoesNotPropagateRef(t *testing.T) { + t.Parallel() + + oldBody := `{"Resources":{ + "Src":{"Type":"Test::Named","Properties":{"Name":"src","Size":"1"}}, + "ByRef":{"Type":"Test::Named","Properties":{"Label":{"Ref":"Src"}}}, + "Same":{"Type":"Test::Plain","Properties":{"X":"1"}} + }}` + newBody := `{"Resources":{ + "Src":{"Type":"Test::Named","Properties":{"Name":"src","Size":"2"}}, + "ByRef":{"Type":"Test::Named","Properties":{"Label":{"Ref":"Src"}}}, + "Same":{"Type":"Test::Plain","Properties":{"X":"1"}} + }}` + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + Old: mustTemplate(t, oldBody), + New: mustTemplate(t, newBody), + Live: map[string]cfn.LiveResource{ + "Src": {Type: "Test::Named", PhysicalID: "src", Props: map[string]any{"Name": "src", "Size": "1"}}, + "ByRef": {Type: "Test::Named", PhysicalID: "r", Props: map[string]any{"Label": "src"}}, + "Same": {Type: "Test::Plain", PhysicalID: "s", Props: map[string]any{"X": "1"}}, + }, + Registry: planRegistry(), + }) + + require.Len(t, changes, 1) + assert.Equal(t, "Src", changes[0].LogicalID) + assert.Equal(t, cfn.ReplacementFalse, changes[0].Replacement) + assert.Equal(t, cfn.RecreationNever, changes[0].Details[0].Target.RequiresRecreation) +} + +// A Tags change is reported under the Tags attribute with no property name. +// A type with no ReplacementSchema replaces on every change. +func TestPlanChangesTagsAndSchemaless(t *testing.T) { + t.Parallel() + + oldBody := `{"Resources":{ + "Tagged":{"Type":"Test::Named","Properties":{"Tags":[{"Key":"a","Value":"1"}]}}, + "Plain":{"Type":"Test::Plain","Properties":{"X":"1"}} + }}` + newBody := `{"Resources":{ + "Tagged":{"Type":"Test::Named","Properties":{"Tags":[{"Key":"a","Value":"2"}]}}, + "Plain":{"Type":"Test::Plain","Properties":{"X":"2"}} + }}` + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + Old: mustTemplate(t, oldBody), + New: mustTemplate(t, newBody), + Live: map[string]cfn.LiveResource{ + "Tagged": {Type: "Test::Named", PhysicalID: "t", Props: map[string]any{"Tags": []any{map[string]any{"Key": "a", "Value": "1"}}}}, + "Plain": {Type: "Test::Plain", PhysicalID: "p"}, + }, + NewProps: map[string]map[string]any{"Tagged": {"Tags": []any{map[string]any{"Key": "a", "Value": "2"}}}}, + Registry: planRegistry(), + }) + + require.Len(t, changes, 2) + + plain := changeFor(t, changes, "Plain") + assert.Equal(t, cfn.ReplacementTrue, plain.Replacement) + assert.Equal(t, cfn.RecreationAlways, plain.Details[0].Target.RequiresRecreation) + + tagged := changeFor(t, changes, "Tagged") + assert.Equal(t, []string{cfn.AttributeTags}, tagged.Scope) + assert.Equal(t, cfn.ReplacementFalse, tagged.Replacement) + assert.Equal(t, cfn.ChangeTarget{ + Attribute: cfn.AttributeTags, RequiresRecreation: cfn.RecreationNever, + BeforeValue: `[{"Key":"a","Value":"1"}]`, AfterValue: `[{"Key":"a","Value":"2"}]`, + AttributeChangeType: cfn.ChangeActionModify, + }, tagged.Details[0].Target) +} + +// A resolved value that changed with no template edit, such as an SSM +// parameter that now resolves differently, is still reported. +func TestPlanChangesResolvedOnlyChange(t *testing.T) { + t.Parallel() + + body := `{"Resources":{"Q":{"Type":"Test::Named","Properties":{"Size":{"Fn::Sub":"${AWS::NotificationARNs}"}}}}}` + + changes := cfn.PlanChanges(&cfn.ChangePlanInput{ + Old: mustTemplate(t, body), + New: mustTemplate(t, body), + Live: map[string]cfn.LiveResource{"Q": {Type: "Test::Named", PhysicalID: "q", Props: map[string]any{"Size": "a"}}}, + NewProps: map[string]map[string]any{"Q": {"Size": "b"}}, + Registry: planRegistry(), + }) + + require.Len(t, changes, 1) + assert.Equal(t, cfn.SourceDirectModification, changes[0].Details[0].ChangeSource) + assert.Equal(t, cfn.ReplacementFalse, changes[0].Replacement) + assert.JSONEq(t, `{"Properties":{"Size":"a"}}`, changes[0].BeforeContext) + assert.JSONEq(t, `{"Properties":{"Size":"b"}}`, changes[0].AfterContext) +} diff --git a/services/cloudformation/format_test.go b/services/cloudformation/format_test.go index f398fa516..ce3f0b5aa 100644 --- a/services/cloudformation/format_test.go +++ b/services/cloudformation/format_test.go @@ -362,7 +362,8 @@ func TestSummarizeParameters(t *testing.T) { sum := cfn.Summarize(tmpl) assert.Equal(t, "my stack", sum.Description) assert.Equal(t, []cfn.TemplateParameter{ - {Key: "Secret", NoEcho: true}, - {Key: "Size", DefaultValue: "3", HasDefault: true, Description: "how many"}, + {Key: "Secret", Type: "String", NoEcho: true}, + {Key: "Size", Type: "Number", DefaultValue: "3", HasDefault: true, Description: "how many"}, }, sum.Parameters) + assert.Equal(t, []string{"AWS::S3::Bucket"}, sum.ResourceTypes) } diff --git a/services/cloudformation/summary.go b/services/cloudformation/summary.go index 727def974..c09d30909 100644 --- a/services/cloudformation/summary.go +++ b/services/cloudformation/summary.go @@ -31,18 +31,28 @@ var iamNameProps = map[string]string{ //nolint:gochecknoglobals // static lookup "AWS::IAM::UserToGroupAddition": "", } -// Summarize builds the ValidateTemplate view of a parsed template. +// Summarize builds the ValidateTemplate and GetTemplateSummary view of a +// parsed template. func Summarize(t *Template) *TemplateSummary { - out := &TemplateSummary{Description: t.Description, DeclaredTransforms: transforms(t.Transform)} + out := &TemplateSummary{ + Description: t.Description, DeclaredTransforms: transforms(t.Transform), Version: t.FormatVersion, + } for _, name := range sortedKeys(t.Parameters) { def := t.Parameters[name] out.Parameters = append(out.Parameters, TemplateParameter{ - Key: name, DefaultValue: scalarString(def.Default), HasDefault: def.Default != nil, + Key: name, Type: def.Type, DefaultValue: scalarString(def.Default), HasDefault: def.Default != nil, NoEcho: def.NoEcho, Description: def.Description, }) } + types := map[string]bool{} + for _, r := range t.Resources { + types[r.Type] = true + } + + out.ResourceTypes = sortedKeys(types) + out.Capabilities, out.CapabilitiesReason = requiredCapabilities(t) return out diff --git a/services/cloudformation/types.go b/services/cloudformation/types.go index 18e56c02a..51e8af866 100644 --- a/services/cloudformation/types.go +++ b/services/cloudformation/types.go @@ -17,6 +17,7 @@ const ( StatusUpdateInProgress = "UPDATE_IN_PROGRESS" StatusUpdateComplete = "UPDATE_COMPLETE" + StatusUpdateFailed = "UPDATE_FAILED" StatusUpdateRollbackInProgress = "UPDATE_ROLLBACK_IN_PROGRESS" StatusUpdateRollbackComplete = "UPDATE_ROLLBACK_COMPLETE" StatusUpdateRollbackFailed = "UPDATE_ROLLBACK_FAILED" @@ -109,6 +110,12 @@ type Stack struct { // NotificationARNs are the SNS topics the stack reports events to. NotificationARNs []string + // ChangeSetID is the change set the stack was last created or updated + // from. + ChangeSetID string + // DisableRollback records that a failed operation leaves the stack as it + // is instead of rolling it back. + DisableRollback bool } // StackSummary is the condensed stack view ListStacks returns. @@ -146,6 +153,9 @@ type UpdateStackInput struct { Capabilities []string // UsePreviousTemplate reuses the stack's current template. UsePreviousTemplate bool + // DisableRollback leaves a failed update UPDATE_FAILED instead of + // rolling it back. + DisableRollback bool // NotificationARNs replaces the stack's topics. Nil keeps them. NotificationARNs []string @@ -169,19 +179,31 @@ type ValidateTemplateInput struct { // TemplateParameter is one parameter declaration as ValidateTemplate reports it. type TemplateParameter struct { Key string + Type string DefaultValue string HasDefault bool NoEcho bool Description string } -// TemplateSummary is the ValidateTemplate result. +// TemplateSummary is the ValidateTemplate and GetTemplateSummary result. type TemplateSummary struct { Description string Parameters []TemplateParameter Capabilities []string CapabilitiesReason string DeclaredTransforms []string + // ResourceTypes and Version are reported by GetTemplateSummary only. + ResourceTypes []string + Version string +} + +// GetTemplateSummaryInput names the template to summarize: a live stack's, +// or one given as a body or URL. +type GetTemplateSummaryInput struct { + StackName string + TemplateBody string + TemplateURL string } // API is the CloudFormation control surface a wire handler drives. The AWS @@ -199,4 +221,11 @@ type API interface { ListStackResources(ctx context.Context, stackName string) ([]StackResource, error) GetTemplate(ctx context.Context, stackName string) (string, error) ValidateTemplate(ctx context.Context, in *ValidateTemplateInput) (*TemplateSummary, error) + GetTemplateSummary(ctx context.Context, in *GetTemplateSummaryInput) (*TemplateSummary, error) + + CreateChangeSet(ctx context.Context, in *CreateChangeSetInput) (*ChangeSet, error) + DescribeChangeSet(ctx context.Context, in *DescribeChangeSetInput) (*ChangeSet, error) + ListChangeSets(ctx context.Context, in *ListChangeSetsInput) (*ChangeSetList, error) + ExecuteChangeSet(ctx context.Context, in *ExecuteChangeSetInput) error + DeleteChangeSet(ctx context.Context, in *DeleteChangeSetInput) error }