Skip to content

Commit 4d99c32

Browse files
authored
chore: render env stack updates from the env controller (#1897)
https://user-images.githubusercontent.com/879348/106506161-2edfae00-647e-11eb-9aee-42d1e5d81b1e.mov _By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice._
1 parent 0e0498d commit 4d99c32

File tree

8 files changed

+441
-33
lines changed

8 files changed

+441
-33
lines changed

internal/pkg/deploy/cloudformation/cloudformation.go

Lines changed: 119 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/aws/copilot-cli/internal/pkg/aws/cloudformation"
1818
"github.com/aws/copilot-cli/internal/pkg/aws/cloudformation/stackset"
1919
"github.com/aws/copilot-cli/internal/pkg/aws/ecs"
20+
"github.com/aws/copilot-cli/internal/pkg/deploy"
2021
"github.com/aws/copilot-cli/internal/pkg/stream"
2122
"github.com/aws/copilot-cli/internal/pkg/term/log"
2223
"github.com/aws/copilot-cli/internal/pkg/term/progress"
@@ -30,7 +31,8 @@ const (
3031
waitForStackTimeout = 1*time.Hour + 30*time.Minute
3132

3233
// CloudFormation resource types.
33-
ecsServiceResourceType = "AWS::ECS::Service"
34+
ecsServiceResourceType = "AWS::ECS::Service"
35+
envControllerResourceType = "Custom::EnvControllerFunction"
3436
)
3537

3638
// StackConfiguration represents the set of methods needed to deploy a cloudformation stack.
@@ -174,7 +176,7 @@ func (cf CloudFormation) renderStackChanges(in *renderStackChangesInput) error {
174176
defer cancelWait()
175177
g, ctx := errgroup.WithContext(waitCtx)
176178

177-
renderer, err := cf.createStackRenderer(g, ctx, changeSetID, in.stackName, in.stackDescription, progress.RenderOptions{})
179+
renderer, err := cf.createChangeSetRenderer(g, ctx, changeSetID, in.stackName, in.stackDescription, progress.RenderOptions{})
178180
if err != nil {
179181
return err
180182
}
@@ -190,7 +192,7 @@ func (cf CloudFormation) renderStackChanges(in *renderStackChangesInput) error {
190192
return nil
191193
}
192194

193-
func (cf CloudFormation) createStackRenderer(group *errgroup.Group, ctx context.Context, changeSetID, stackName, description string, opts progress.RenderOptions) (progress.DynamicRenderer, error) {
195+
func (cf CloudFormation) createChangeSetRenderer(group *errgroup.Group, ctx context.Context, changeSetID, stackName, description string, opts progress.RenderOptions) (progress.DynamicRenderer, error) {
194196
changeSet, err := cf.cfnClient.DescribeChangeSet(changeSetID, stackName)
195197
if err != nil {
196198
return nil, err
@@ -206,30 +208,34 @@ func (cf CloudFormation) createStackRenderer(group *errgroup.Group, ctx context.
206208

207209
streamer := stream.NewStackStreamer(cf.cfnClient, stackName, changeSet.CreationTime)
208210
children, err := cf.changeRenderers(changeRenderersInput{
209-
g: group,
210-
ctx: ctx,
211-
stackStreamer: streamer,
212-
changes: changeSet.Changes,
213-
descriptions: descriptions,
214-
opts: progress.NestedRenderOptions(opts),
211+
g: group,
212+
ctx: ctx,
213+
stackName: stackName,
214+
stackStreamer: streamer,
215+
changes: changeSet.Changes,
216+
changeSetTimestamp: changeSet.CreationTime,
217+
descriptions: descriptions,
218+
opts: progress.NestedRenderOptions(opts),
215219
})
216220
if err != nil {
217221
return nil, err
218222
}
219-
renderer := progress.ListeningStackRenderer(streamer, stackName, description, children, opts)
223+
renderer := progress.ListeningChangeSetRenderer(streamer, stackName, description, children, opts)
220224
group.Go(func() error {
221225
return stream.Stream(ctx, streamer)
222226
})
223227
return renderer, nil
224228
}
225229

226230
type changeRenderersInput struct {
227-
g *errgroup.Group // Group that all goroutines belong.
228-
ctx context.Context // Context associated with the group.
229-
stackStreamer progress.StackSubscriber // Streamer for the stack where changes belong.
230-
changes []*sdkcloudformation.Change // List of changes that will be applied to the stack.
231-
descriptions map[string]string // Descriptions for the logical IDs of the changes.
232-
opts progress.RenderOptions // Display options that should be applied to the changes.
231+
g *errgroup.Group // Group that all goroutines belong.
232+
ctx context.Context // Context associated with the group.
233+
stackName string // Name of the stack.
234+
stackStreamer progress.StackSubscriber // Streamer for the stack where changes belong.
235+
changes []*sdkcloudformation.Change // List of changes that will be applied to the stack.
236+
changeSetTimestamp time.Time // ChangeSet creation time.
237+
descriptions map[string]string // Descriptions for the logical IDs of the changes.
238+
opts progress.RenderOptions // Display options that should be applied to the changes.
233239
}
234240

235241
// changeRenderers filters changes by resources that have a description and returns the appropriate progress.Renderer for each resource type.
@@ -243,6 +249,21 @@ func (cf CloudFormation) changeRenderers(in changeRenderersInput) ([]progress.Re
243249
}
244250
var renderer progress.Renderer
245251
switch {
252+
case aws.StringValue(change.ResourceChange.ResourceType) == envControllerResourceType:
253+
r, err := cf.createEnvControllerRenderer(&envControllerRendererInput{
254+
g: in.g,
255+
ctx: in.ctx,
256+
workloadStackName: in.stackName,
257+
workloadTimestamp: in.changeSetTimestamp,
258+
change: change,
259+
description: description,
260+
serviceStack: in.stackStreamer,
261+
renderOpts: in.opts,
262+
})
263+
if err != nil {
264+
return nil, err
265+
}
266+
renderer = r
246267
case aws.StringValue(change.ResourceChange.ResourceType) == ecsServiceResourceType:
247268
renderer = progress.ListeningECSServiceResourceRenderer(in.stackStreamer, cf.ecsClient, logicalID, description, progress.ECSServiceRendererOpts{
248269
Group: in.g,
@@ -254,19 +275,63 @@ func (cf CloudFormation) changeRenderers(in changeRenderersInput) ([]progress.Re
254275
changeSetID := aws.StringValue(change.ResourceChange.ChangeSetId)
255276
stackName := parseStackNameFromARN(aws.StringValue(change.ResourceChange.PhysicalResourceId))
256277

257-
r, err := cf.createStackRenderer(in.g, in.ctx, changeSetID, stackName, description, in.opts)
278+
r, err := cf.createChangeSetRenderer(in.g, in.ctx, changeSetID, stackName, description, in.opts)
258279
if err != nil {
259280
return nil, err
260281
}
261282
renderer = r
262283
default:
263-
renderer = progress.ListeningResourceRenderer(in.stackStreamer, logicalID, description, in.opts)
284+
renderer = progress.ListeningResourceRenderer(in.stackStreamer, logicalID, description, progress.ResourceRendererOpts{
285+
RenderOpts: in.opts,
286+
})
264287
}
265288
resources = append(resources, renderer)
266289
}
267290
return resources, nil
268291
}
269292

293+
type envControllerRendererInput struct {
294+
g *errgroup.Group
295+
ctx context.Context
296+
workloadStackName string
297+
workloadTimestamp time.Time
298+
change *sdkcloudformation.Change
299+
description string
300+
serviceStack progress.StackSubscriber
301+
renderOpts progress.RenderOptions
302+
}
303+
304+
func (cf CloudFormation) createEnvControllerRenderer(in *envControllerRendererInput) (progress.DynamicRenderer, error) {
305+
switch {
306+
case isEnvControllerInAction(in.change):
307+
// We can't use the workloadStackName to retrieve the envStackName because app names and env names can contain
308+
// dashes. So we fetch the tags belonging to the workload stack and generate the stack name from there.
309+
workload, err := cf.cfnClient.Describe(in.workloadStackName)
310+
if err != nil {
311+
return nil, err
312+
}
313+
envStackName := fmt.Sprintf("%s-%s", parseAppNameFromTags(workload.Tags), parseEnvNameFromTags(workload.Tags))
314+
body, err := cf.cfnClient.TemplateBody(envStackName)
315+
if err != nil {
316+
return nil, err
317+
}
318+
descriptions, err := cloudformation.ParseTemplateDescriptions(body)
319+
if err != nil {
320+
return nil, fmt.Errorf("parse cloudformation template for resource descriptions: %w", err)
321+
}
322+
streamer := stream.NewStackStreamer(cf.cfnClient, envStackName, in.workloadTimestamp)
323+
in.g.Go(func() error {
324+
return stream.Stream(in.ctx, streamer)
325+
})
326+
return progress.ListeningStackRenderer(streamer, envStackName, in.description, descriptions, in.renderOpts), nil
327+
default:
328+
logicalID := aws.StringValue(in.change.ResourceChange.LogicalResourceId)
329+
return progress.ListeningResourceRenderer(in.serviceStack, logicalID, in.description, progress.ResourceRendererOpts{
330+
RenderOpts: in.renderOpts,
331+
}), nil
332+
}
333+
}
334+
270335
func (cf CloudFormation) errOnFailedStack(stackName string) error {
271336
stack, err := cf.cfnClient.Describe(stackName)
272337
if err != nil {
@@ -307,6 +372,42 @@ func parseStackNameFromARN(stackARN string) string {
307372
return strings.Split(stackARN, "/")[1]
308373
}
309374

375+
func isEnvControllerInAction(controller *sdkcloudformation.Change) bool {
376+
switch action := aws.StringValue(controller.ResourceChange.Action); action {
377+
case sdkcloudformation.ChangeActionAdd:
378+
return true
379+
case sdkcloudformation.ChangeActionModify:
380+
for _, detail := range controller.ResourceChange.Details {
381+
attribute := aws.StringValue(detail.Target.Name)
382+
if attribute == "Parameters" {
383+
// The env controller modifies the env stack only if the "Parameters" field is modified.
384+
return true
385+
}
386+
}
387+
return false
388+
default:
389+
return false
390+
}
391+
}
392+
393+
func parseAppNameFromTags(tags []*sdkcloudformation.Tag) string {
394+
for _, t := range tags {
395+
if aws.StringValue(t.Key) == deploy.AppTagKey {
396+
return aws.StringValue(t.Value)
397+
}
398+
}
399+
return ""
400+
}
401+
402+
func parseEnvNameFromTags(tags []*sdkcloudformation.Tag) string {
403+
for _, t := range tags {
404+
if aws.StringValue(t.Key) == deploy.EnvTagKey {
405+
return aws.StringValue(t.Value)
406+
}
407+
}
408+
return ""
409+
}
410+
310411
func stopSpinner(spinner *progress.Spinner, err error, label string) {
311412
if err == nil {
312413
spinner.Stop(log.Ssuccessf("%s\n", label))

internal/pkg/deploy/cloudformation/cloudformation_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,98 @@ Resources:
252252
require.Contains(t, buf.String(), "[completed]", "Rollout state of service should be rendered")
253253
}
254254

255+
func testDeployWorkload_WithEnvControllerRenderer(t *testing.T, svcStackName string, when func(w progress.FileWriter, cf CloudFormation) error) {
256+
// GIVEN
257+
ctrl := gomock.NewController(t)
258+
defer ctrl.Finish()
259+
mockCFN := mocks.NewMockcfnClient(ctrl)
260+
deploymentTime := time.Date(2020, time.November, 23, 18, 0, 0, 0, time.UTC)
261+
262+
mockCFN.EXPECT().Create(gomock.Any()).Return("1234", nil)
263+
mockCFN.EXPECT().DescribeChangeSet("1234", svcStackName).Return(&cloudformation.ChangeSetDescription{
264+
Changes: []*sdkcloudformation.Change{
265+
{
266+
ResourceChange: &sdkcloudformation.ResourceChange{
267+
LogicalResourceId: aws.String("EnvControllerAction"),
268+
ResourceType: aws.String("Custom::EnvControllerFunction"),
269+
Action: aws.String(sdkcloudformation.ChangeActionAdd),
270+
},
271+
},
272+
},
273+
}, nil)
274+
mockCFN.EXPECT().TemplateBodyFromChangeSet("1234", svcStackName).Return(`
275+
Resources:
276+
EnvControllerAction:
277+
Metadata:
278+
'aws:copilot:description': "Updating environment"
279+
`, nil)
280+
mockCFN.EXPECT().Describe(svcStackName).Return(&cloudformation.StackDescription{
281+
Tags: []*sdkcloudformation.Tag{
282+
{
283+
Key: aws.String("copilot-application"),
284+
Value: aws.String("my-app"),
285+
},
286+
{
287+
Key: aws.String("copilot-environment"),
288+
Value: aws.String("my-env"),
289+
},
290+
},
291+
}, nil)
292+
mockCFN.EXPECT().TemplateBody("my-app-my-env").Return(`
293+
Resources:
294+
PublicLoadBalancer:
295+
Metadata:
296+
'aws:copilot:description': "Updating ALB"
297+
`, nil)
298+
mockCFN.EXPECT().DescribeStackEvents(&sdkcloudformation.DescribeStackEventsInput{
299+
StackName: aws.String(svcStackName),
300+
}).Return(&sdkcloudformation.DescribeStackEventsOutput{
301+
StackEvents: []*sdkcloudformation.StackEvent{
302+
{
303+
EventId: aws.String("1"),
304+
LogicalResourceId: aws.String(svcStackName),
305+
ResourceType: aws.String("AWS::CloudFormation::Stack"),
306+
ResourceStatus: aws.String("CREATE_COMPLETE"),
307+
Timestamp: aws.Time(deploymentTime),
308+
},
309+
},
310+
}, nil).AnyTimes()
311+
mockCFN.EXPECT().DescribeStackEvents(&sdkcloudformation.DescribeStackEventsInput{
312+
StackName: aws.String("my-app-my-env"),
313+
}).Return(&sdkcloudformation.DescribeStackEventsOutput{
314+
StackEvents: []*sdkcloudformation.StackEvent{
315+
{
316+
EventId: aws.String("1"),
317+
LogicalResourceId: aws.String("PublicLoadBalancer"),
318+
ResourceType: aws.String("AWS::ElasticLoadBalancingV2::LoadBalancer"),
319+
ResourceStatus: aws.String("CREATE_COMPLETE"),
320+
Timestamp: aws.Time(deploymentTime),
321+
},
322+
{
323+
EventId: aws.String("2"),
324+
LogicalResourceId: aws.String("my-app-my-env"),
325+
ResourceType: aws.String("AWS::CloudFormation::Stack"),
326+
ResourceStatus: aws.String("CREATE_COMPLETE"),
327+
Timestamp: aws.Time(deploymentTime),
328+
},
329+
},
330+
}, nil).AnyTimes()
331+
332+
mockCFN.EXPECT().Describe(svcStackName).Return(&cloudformation.StackDescription{
333+
StackStatus: aws.String("CREATE_COMPLETE"),
334+
}, nil)
335+
client := CloudFormation{cfnClient: mockCFN}
336+
buf := new(strings.Builder)
337+
338+
// WHEN
339+
err := when(mockFileWriter{Writer: buf}, client)
340+
341+
// THEN
342+
require.NoError(t, err)
343+
require.Contains(t, buf.String(), "Updating environment", "env stack description is rendered")
344+
require.Contains(t, buf.String(), "Updating ALB", "resource in the env stack should be rendered")
345+
}
346+
255347
func testDeployWorkload_RenderNewlyCreatedStackWithAddons(t *testing.T, stackName string, when func(w progress.FileWriter, cf CloudFormation) error) {
256348
ctrl := gomock.NewController(t)
257349
defer ctrl.Finish()

internal/pkg/deploy/cloudformation/workload_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func (m *mockStackConfig) Tags() []*sdkcloudformation.Tag {
5454

5555
func TestCloudFormation_DeployService(t *testing.T) {
5656
serviceConfig := &mockStackConfig{
57-
name: "hello",
57+
name: "myapp-myenv-mysvc",
5858
template: "template",
5959
parameters: map[string]string{
6060
"port": "80",
@@ -83,13 +83,16 @@ func TestCloudFormation_DeployService(t *testing.T) {
8383
testDeployWorkload_StackStreamerFailureShouldCancelRenderer(t, when)
8484
})
8585
t.Run("returns an error if stack creation fails", func(t *testing.T) {
86-
testDeployWorkload_StreamUntilStackCreationFails(t, "hello", when)
86+
testDeployWorkload_StreamUntilStackCreationFails(t, "myapp-myenv-mysvc", when)
87+
})
88+
t.Run("renders a stack with an EnvController", func(t *testing.T) {
89+
testDeployWorkload_WithEnvControllerRenderer(t, "myapp-myenv-mysvc", when)
8790
})
8891
t.Run("renders a stack with an ECS service", func(t *testing.T) {
89-
testDeployWorkload_RenderNewlyCreatedStackWithECSService(t, "hello", when)
92+
testDeployWorkload_RenderNewlyCreatedStackWithECSService(t, "myapp-myenv-mysvc", when)
9093
})
9194
t.Run("renders a stack with addons template if stack creation is successful", func(t *testing.T) {
92-
testDeployWorkload_RenderNewlyCreatedStackWithAddons(t, "hello", when)
95+
testDeployWorkload_RenderNewlyCreatedStackWithAddons(t, "myapp-myenv-mysvc", when)
9396
})
9497
}
9598

internal/pkg/stream/cloudformation.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package stream
55

66
import (
77
"fmt"
8+
"sync"
89
"time"
910

1011
"github.com/aws/aws-sdk-go/aws"
@@ -37,6 +38,7 @@ type StackStreamer struct {
3738
done chan struct{}
3839
pastEventIDs map[string]bool
3940
eventsToFlush []StackEvent
41+
mu sync.Mutex
4042
}
4143

4244
// NewStackStreamer creates a StackStreamer from a cloudformation client, stack name, and the change set creation timestamp.
@@ -52,6 +54,8 @@ func NewStackStreamer(cfn StackEventsDescriber, stackName string, csCreationTime
5254

5355
// Subscribe returns a read-only channel that will receive stack events from the StackStreamer.
5456
func (s *StackStreamer) Subscribe() <-chan StackEvent {
57+
s.mu.Lock()
58+
defer s.mu.Unlock()
5559
c := make(chan StackEvent)
5660
s.subscribers = append(s.subscribers, c)
5761
return c
@@ -116,8 +120,15 @@ func (s *StackStreamer) Fetch() (next time.Time, err error) {
116120

117121
// Notify flushes all new events to the streamer's subscribers.
118122
func (s *StackStreamer) Notify() {
123+
// Copy current list of subscribers over, so that we can we add more subscribers while
124+
// notifying previous subscribers of older events.
125+
s.mu.Lock()
126+
var subs []chan StackEvent
127+
subs = append(subs, s.subscribers...)
128+
s.mu.Unlock()
129+
119130
for _, event := range s.eventsToFlush {
120-
for _, sub := range s.subscribers {
131+
for _, sub := range subs {
121132
sub <- event
122133
}
123134
}
@@ -126,6 +137,9 @@ func (s *StackStreamer) Notify() {
126137

127138
// Close closes all subscribed channels notifying them that no more events will be sent.
128139
func (s *StackStreamer) Close() {
140+
s.mu.Lock()
141+
defer s.mu.Unlock()
142+
129143
for _, sub := range s.subscribers {
130144
close(sub)
131145
}

0 commit comments

Comments
 (0)