Skip to content

Commit ba68716

Browse files
authored
chore: replace auto-upgrade of environments with compatibility checkers (#3698)
Previously, Copilot automatically upgrades an environment during workload's `deploy` commands if the environment is not on the latest template version. With this PR, it stops doing that, and instead will check if the workload's manifest is attempting to use a feature that is not yet supported by the environment's template; if yes, then Copilot errors out and asks user to upgrade their environment. Similar logic applies to the "pipeline + `svc package`" workflow. Note: this PR can be merged only after #3688 . By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License.
1 parent 6252cab commit ba68716

File tree

9 files changed

+337
-180
lines changed

9 files changed

+337
-180
lines changed

internal/pkg/cli/job_deploy.go

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/aws/aws-sdk-go/aws"
1010
"github.com/aws/aws-sdk-go/service/ssm"
11+
"github.com/aws/copilot-cli/internal/pkg/describe"
1112

1213
"github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack"
1314
"github.com/aws/copilot-cli/internal/pkg/exec"
@@ -30,14 +31,14 @@ import (
3031
type deployJobOpts struct {
3132
deployWkldVars
3233

33-
store store
34-
ws wsWlDirReader
35-
unmarshal func(in []byte) (manifest.WorkloadManifest, error)
36-
newInterpolator func(app, env string) interpolator
37-
cmd runner
38-
sessProvider *sessions.Provider
39-
envUpgradeCmd actionCommand
40-
newJobDeployer func() (workloadDeployer, error)
34+
store store
35+
ws wsWlDirReader
36+
unmarshal func(in []byte) (manifest.WorkloadManifest, error)
37+
newInterpolator func(app, env string) interpolator
38+
cmd runner
39+
sessProvider *sessions.Provider
40+
newJobDeployer func() (workloadDeployer, error)
41+
envFeaturesDescriber versionCompatibilityChecker
4142

4243
sel wsSelector
4344

@@ -138,9 +139,6 @@ func (o *deployJobOpts) Execute() error {
138139
return err
139140
}
140141
}
141-
if err := o.envUpgradeCmd.Execute(); err != nil {
142-
return fmt.Errorf(`execute "env upgrade --app %s --name %s": %v`, o.appName, o.envName, err)
143-
}
144142
mft, err := workloadManifest(&workloadManifestInput{
145143
name: o.name,
146144
appName: o.appName,
@@ -153,6 +151,9 @@ func (o *deployJobOpts) Execute() error {
153151
return err
154152
}
155153
o.appliedManifest = mft
154+
if err := validateManifestCompatibilityWithEnv(mft, o.envName, o.envFeaturesDescriber); err != nil {
155+
return err
156+
}
156157
deployer, err := o.newJobDeployer()
157158
if err != nil {
158159
return err
@@ -217,22 +218,22 @@ func (o *deployJobOpts) configureClients() error {
217218
return fmt.Errorf("create default session: %w", err)
218219
}
219220

220-
cmd, err := newEnvUpgradeOpts(envUpgradeVars{
221-
appName: o.appName,
222-
name: env.Name,
223-
})
224-
if err != nil {
225-
return fmt.Errorf("new env upgrade command: %v", err)
226-
}
227-
o.envUpgradeCmd = cmd
228-
229221
// client to retrieve caller identity.
230222
caller, err := identity.New(defaultSess).Get()
231223
if err != nil {
232224
return fmt.Errorf("get identity: %w", err)
233225
}
234226
o.rootUserARN = caller.RootUserARN
235227

228+
envDescriber, err := describe.NewEnvDescriber(describe.NewEnvDescriberConfig{
229+
App: o.appName,
230+
Env: o.envName,
231+
ConfigStore: o.store,
232+
})
233+
if err != nil {
234+
return err
235+
}
236+
o.envFeaturesDescriber = envDescriber
236237
return nil
237238
}
238239

internal/pkg/cli/job_deploy_test.go

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -199,35 +199,55 @@ func TestJobDeployOpts_Execute(t *testing.T) {
199199

200200
wantedError error
201201
}{
202-
"error if failed to upgrade environment": {
203-
mock: func(m *deployMocks) {
204-
m.mockEnvUpgrader.EXPECT().Execute().Return(mockError)
205-
},
206-
207-
wantedError: fmt.Errorf(`execute "env upgrade --app phonetool --name prod-iad": some error`),
208-
},
209202
"error out if fail to read workload manifest": {
210203
mock: func(m *deployMocks) {
211-
m.mockEnvUpgrader.EXPECT().Execute().Return(nil)
212204
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return(nil, mockError)
213205
},
214206

215207
wantedError: fmt.Errorf("read manifest file for upload: some error"),
216208
},
217209
"error out if fail to interpolate workload manifest": {
218210
mock: func(m *deployMocks) {
219-
m.mockEnvUpgrader.EXPECT().Execute().Return(nil)
220211
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return([]byte(""), nil)
221212
m.mockInterpolator.EXPECT().Interpolate("").Return("", mockError)
222213
},
223214

224215
wantedError: fmt.Errorf("interpolate environment variables for upload manifest: some error"),
225216
},
217+
"error if fail to get a list of available features from the environment": {
218+
mock: func(m *deployMocks) {
219+
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return([]byte(""), nil)
220+
m.mockInterpolator.EXPECT().Interpolate("").Return("", nil)
221+
m.mockEnvFeaturesDescriber.EXPECT().AvailableFeatures().Return(nil, mockError)
222+
},
223+
224+
wantedError: fmt.Errorf("get available features of the prod-iad environment stack: some error"),
225+
},
226+
"error if some required features are not available in the environment": {
227+
mock: func(m *deployMocks) {
228+
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return([]byte(""), nil)
229+
m.mockInterpolator.EXPECT().Interpolate("").Return("", nil)
230+
m.mockMft = &mockWorkloadMft{
231+
mockRequiredEnvironmentFeatures: func() []string {
232+
return []string{"mockFeature1", "mockFeature3"}
233+
},
234+
}
235+
m.mockEnvFeaturesDescriber.EXPECT().AvailableFeatures().Return([]string{"mockFeature1", "mockFeature2"}, nil)
236+
m.mockEnvFeaturesDescriber.EXPECT().Version().Return("v1.mock", nil)
237+
},
238+
wantedError: fmt.Errorf(`environment "prod-iad" is not on a version that supports the "mockFeature3" feature`),
239+
},
226240
"error if failed to upload artifacts": {
227241
mock: func(m *deployMocks) {
228-
m.mockEnvUpgrader.EXPECT().Execute().Return(nil)
229242
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return([]byte(""), nil)
230243
m.mockInterpolator.EXPECT().Interpolate("").Return("", nil)
244+
m.mockMft = &mockWorkloadMft{
245+
mockRequiredEnvironmentFeatures: func() []string {
246+
return []string{"mockFeature1"}
247+
},
248+
}
249+
m.mockEnvFeaturesDescriber.EXPECT().AvailableFeatures().Return([]string{"mockFeature1", "mockFeature2"}, nil)
250+
m.mockEnvFeaturesDescriber.EXPECT().Version().Times(0)
231251
m.mockDeployer.EXPECT().IsServiceAvailableInRegion("").Return(false, nil)
232252
m.mockDeployer.EXPECT().UploadArtifacts().Return(nil, mockError)
233253
},
@@ -236,9 +256,15 @@ func TestJobDeployOpts_Execute(t *testing.T) {
236256
},
237257
"error if failed to deploy service": {
238258
mock: func(m *deployMocks) {
239-
m.mockEnvUpgrader.EXPECT().Execute().Return(nil)
240259
m.mockWsReader.EXPECT().ReadWorkloadManifest(mockJobName).Return([]byte(""), nil)
241260
m.mockInterpolator.EXPECT().Interpolate("").Return("", nil)
261+
m.mockMft = &mockWorkloadMft{
262+
mockRequiredEnvironmentFeatures: func() []string {
263+
return []string{"mockFeature1"}
264+
},
265+
}
266+
m.mockEnvFeaturesDescriber.EXPECT().AvailableFeatures().Return([]string{"mockFeature1", "mockFeature2"}, nil)
267+
m.mockEnvFeaturesDescriber.EXPECT().Version().Times(0)
242268
m.mockDeployer.EXPECT().UploadArtifacts().Return(&deploy.UploadArtifactsOutput{}, nil)
243269
m.mockDeployer.EXPECT().DeployWorkload(gomock.Any()).Return(nil, mockError)
244270
m.mockDeployer.EXPECT().IsServiceAvailableInRegion("").Return(false, nil)
@@ -255,10 +281,10 @@ func TestJobDeployOpts_Execute(t *testing.T) {
255281
defer ctrl.Finish()
256282

257283
m := &deployMocks{
258-
mockDeployer: mocks.NewMockworkloadDeployer(ctrl),
259-
mockEnvUpgrader: mocks.NewMockactionCommand(ctrl),
260-
mockInterpolator: mocks.NewMockinterpolator(ctrl),
261-
mockWsReader: mocks.NewMockwsWlDirReader(ctrl),
284+
mockDeployer: mocks.NewMockworkloadDeployer(ctrl),
285+
mockInterpolator: mocks.NewMockinterpolator(ctrl),
286+
mockWsReader: mocks.NewMockwsWlDirReader(ctrl),
287+
mockEnvFeaturesDescriber: mocks.NewMockversionCompatibilityChecker(ctrl),
262288
}
263289
tc.mock(m)
264290

@@ -278,9 +304,9 @@ func TestJobDeployOpts_Execute(t *testing.T) {
278304
return m.mockInterpolator
279305
},
280306
unmarshal: func(b []byte) (manifest.WorkloadManifest, error) {
281-
return &mockWorkloadMft{}, nil
307+
return m.mockMft, nil
282308
},
283-
envUpgradeCmd: m.mockEnvUpgrader,
309+
envFeaturesDescriber: m.mockEnvFeaturesDescriber,
284310

285311
targetApp: &config.Application{},
286312
targetEnv: &config.Environment{},

internal/pkg/cli/job_package.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ func (o *packageJobOpts) Execute() error {
140140
return o.packageCmd.Execute()
141141
}
142142

143+
// RecommendActions suggests recommended actions before the packaged template is used for deployment.
144+
func (o *packageJobOpts) RecommendActions() error {
145+
return o.packageCmd.RecommendActions()
146+
}
147+
143148
func (o *packageJobOpts) askJobName() error {
144149
if o.name != "" {
145150
return nil

internal/pkg/cli/job_package_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,39 @@ func TestPackageJobOpts_Execute(t *testing.T) {
262262
})
263263
}
264264
}
265+
266+
func TestPackageJobOpts_RecommendActions(t *testing.T) {
267+
testCases := map[string]struct {
268+
mockDependencies func(*gomock.Controller, *packageJobOpts)
269+
270+
wantedErr error
271+
}{
272+
"reuse svc package RecommandActions": {
273+
mockDependencies: func(ctrl *gomock.Controller, opts *packageJobOpts) {
274+
mockCmd := mocks.NewMockactionCommand(ctrl)
275+
mockCmd.EXPECT().RecommendActions().Return(nil)
276+
opts.packageCmd = mockCmd
277+
},
278+
wantedErr: nil,
279+
},
280+
}
281+
282+
for name, tc := range testCases {
283+
t.Run(name, func(t *testing.T) {
284+
// GIVEN
285+
ctrl := gomock.NewController(t)
286+
defer ctrl.Finish()
287+
288+
opts := &packageJobOpts{
289+
packageCmd: mocks.NewMockactionCommand(ctrl),
290+
}
291+
tc.mockDependencies(ctrl, opts)
292+
293+
// WHEN
294+
err := opts.RecommendActions()
295+
296+
// THEN
297+
require.Equal(t, tc.wantedErr, err)
298+
})
299+
}
300+
}

internal/pkg/cli/svc_deploy.go

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ type deploySvcOpts struct {
5050
unmarshal func([]byte) (manifest.WorkloadManifest, error)
5151
newInterpolator func(app, env string) interpolator
5252
cmd runner
53-
envUpgradeCmd actionCommand
5453
sessProvider *sessions.Provider
5554
newSvcDeployer func() (workloadDeployer, error)
5655
envFeaturesDescriber versionCompatibilityChecker
@@ -82,6 +81,7 @@ func newSvcDeployOpts(vars deployWkldVars) (*deploySvcOpts, error) {
8281

8382
store := config.NewSSMStore(identity.New(defaultSession), ssm.New(defaultSession), aws.StringValue(defaultSession.Config.Region))
8483
prompter := prompt.New()
84+
8585
opts := &deploySvcOpts{
8686
deployWkldVars: vars,
8787

@@ -171,9 +171,6 @@ func (o *deploySvcOpts) Execute() error {
171171
return err
172172
}
173173
}
174-
if err := o.envUpgradeCmd.Execute(); err != nil {
175-
return fmt.Errorf(`execute "env upgrade --app %s --name %s": %v`, o.appName, o.envName, err)
176-
}
177174
mft, err := workloadManifest(&workloadManifestInput{
178175
name: o.name,
179176
appName: o.appName,
@@ -186,6 +183,9 @@ func (o *deploySvcOpts) Execute() error {
186183
return err
187184
}
188185
o.appliedManifest = mft
186+
if err := validateManifestCompatibilityWithEnv(mft, o.envName, o.envFeaturesDescriber); err != nil {
187+
return err
188+
}
189189
deployer, err := o.newSvcDeployer()
190190
if err != nil {
191191
return err
@@ -315,15 +315,6 @@ func (o *deploySvcOpts) configureClients() error {
315315
}
316316
o.svcType = svc.Type
317317

318-
cmd, err := newEnvUpgradeOpts(envUpgradeVars{
319-
appName: o.appName,
320-
name: env.Name,
321-
})
322-
if err != nil {
323-
return fmt.Errorf("new env upgrade command: %v", err)
324-
}
325-
o.envUpgradeCmd = cmd
326-
327318
// client to retrieve an application's resources created with CloudFormation.
328319
defaultSess, err := o.sessProvider.Default()
329320
if err != nil {
@@ -337,6 +328,15 @@ func (o *deploySvcOpts) configureClients() error {
337328
}
338329
o.rootUserARN = caller.RootUserARN
339330

331+
envDescriber, err := describe.NewEnvDescriber(describe.NewEnvDescriberConfig{
332+
App: o.appName,
333+
Env: o.envName,
334+
ConfigStore: o.store,
335+
})
336+
if err != nil {
337+
return err
338+
}
339+
o.envFeaturesDescriber = envDescriber
340340
return nil
341341
}
342342

@@ -349,7 +349,7 @@ type workloadManifestInput struct {
349349
unmarshal func([]byte) (manifest.WorkloadManifest, error)
350350
}
351351

352-
func workloadManifest(in *workloadManifestInput) (interface{}, error) {
352+
func workloadManifest(in *workloadManifestInput) (manifest.WorkloadManifest, error) {
353353
raw, err := in.ws.ReadWorkloadManifest(in.name)
354354
if err != nil {
355355
return nil, fmt.Errorf("read manifest file for %s: %w", in.name, err)
@@ -372,7 +372,7 @@ func workloadManifest(in *workloadManifestInput) (interface{}, error) {
372372
return envMft, nil
373373
}
374374

375-
func isManifestCompatibleWithEnvironment(mft manifest.WorkloadManifest, envName string, env versionCompatibilityChecker) error {
375+
func validateManifestCompatibilityWithEnv(mft manifest.WorkloadManifest, envName string, env versionCompatibilityChecker) error {
376376
availableFeatures, err := env.AvailableFeatures()
377377
if err != nil {
378378
return fmt.Errorf("get available features of the %s environment stack: %w", envName, err)
@@ -388,14 +388,16 @@ func isManifestCompatibleWithEnvironment(mft manifest.WorkloadManifest, envName
388388
if _, ok := available[f]; !ok {
389389
logMsg := fmt.Sprintf(`Your manifest configuration requires your environment %q to have the feature %q available.`, envName, template.FriendlyEnvFeatureName(f))
390390
if v := template.LeastVersionForFeature(f); v != "" {
391-
logMsg += fmt.Sprintf(`The least environment version that supports the feature is %s.`, v)
391+
logMsg += fmt.Sprintf(` The least environment version that supports the feature is %s.`, v)
392392
}
393393
if currVersion, err := env.Version(); err == nil {
394-
logMsg += fmt.Sprintf(" Your environment is on %s.\n", currVersion)
394+
logMsg += fmt.Sprintf(" Your environment is on %s.", currVersion)
395395
}
396-
logMsg += fmt.Sprintf(`Please upgrade your environment by running %s.`, color.HighlightCode(fmt.Sprintf("copilot env deploy --name %s", envName)))
397396
log.Errorln(logMsg)
398-
return fmt.Errorf("environment %q is not on a version that supports the %q feature", envName, template.FriendlyEnvFeatureName(f))
397+
return &errManifestIncompatibleWithEnvironment{
398+
missingFeature: f,
399+
envName: envName,
400+
}
399401
}
400402
}
401403
return nil
@@ -467,6 +469,21 @@ func (o *deploySvcOpts) getTargetApp() (*config.Application, error) {
467469
return o.targetApp, nil
468470
}
469471

472+
type errManifestIncompatibleWithEnvironment struct {
473+
missingFeature string
474+
envName string
475+
}
476+
477+
func (e *errManifestIncompatibleWithEnvironment) Error() string {
478+
return fmt.Sprintf("environment %q is not on a version that supports the %q feature", e.envName, template.FriendlyEnvFeatureName(e.missingFeature))
479+
}
480+
481+
// RecommendActions returns recommended actions to be taken after the error.
482+
// Implements main.actionRecommender interface.
483+
func (e *errManifestIncompatibleWithEnvironment) RecommendActions() string {
484+
return fmt.Sprintf("You can upgrade your environment template by running %s.\n", color.HighlightCode(fmt.Sprintf("copilot env deploy --name %s", e.envName)))
485+
}
486+
470487
// buildSvcDeployCmd builds the `svc deploy` subcommand.
471488
func buildSvcDeployCmd() *cobra.Command {
472489
vars := deployWkldVars{}

0 commit comments

Comments
 (0)