Skip to content

Commit a2d83ad

Browse files
committed
Add support for custom user data with the ECS CLI:
- multiple user data files can be specified with --extra-user-data - the CLI supports all user data formats (shell scripts and cloud-init directives) - the passed in user data file can be a MIME Multipart archive - all extra user data is concatenated into a single MIME Multipart archive, which includes a CLI generated shell script to join the ECS Cluster
1 parent 91b0aab commit a2d83ad

File tree

9 files changed

+528
-23
lines changed

9 files changed

+528
-23
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,29 @@ ecs-cli up --cluster myCluster --empty
327327

328328
This is equivalent to the [create-cluster command](https://docs.aws.amazon.com/cli/latest/reference/ecs/create-cluster.html), and will not create a CloudFormation stack associated with your cluster.
329329

330+
#### User Data
331+
332+
For the EC2 launch type, the ECS CLI always creates EC2 instances that include the following User Data:
333+
334+
```
335+
#!/bin/bash
336+
echo ECS_CLUSTER={ clusterName } >> /etc/ecs/ecs.config
337+
```
338+
339+
This user data directs the EC2 instance to join your ECS Cluster. You can optionally include extra user data with `--extra-user-data`; this flag takes a file name as its argument.
340+
The flag can be used multiple times to specify multiple files. Extra user data can be shell scripts or cloud-init directives- see the [EC2 documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html) for more information.
341+
The ECS CLI takes all the User Data, and packs it into a MIME Multipart archive which can be used by cloud-init on the EC2 instance. The ECS CLI even allows existing MIME Multipart archives to be passed in with `--extra-user-data`.
342+
The CLI will unpack the existing archive, and then repack it into the final archive (preserving all header and content type information). Here is an example of specifying extra user data:
343+
344+
```
345+
ecs-cli up \
346+
--capability-iam \
347+
--extra-user-data my-shellscript \
348+
--extra-user-data my-cloud-boot-hook \
349+
--extra-user-data my-mime-multipart-archive \
350+
--launch-type EC2
351+
```
352+
330353
#### Creating a Fargate cluster
331354

332355
```

ecs-cli/modules/cli/cluster/cluster_app.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"strconv"
2121
"strings"
2222

23+
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/cluster/userdata"
2324
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/container"
2425
ecscontext "github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/context"
2526
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/entity/task"
@@ -34,9 +35,14 @@ import (
3435
"github.com/urfave/cli"
3536
)
3637

38+
// user data builder can be easily mocked in tests
39+
var newUserDataBuilder func(string) userdata.UserDataBuilder = userdata.NewBuilder
40+
3741
// displayTitle flag is used to print the title for the fields
3842
const displayTitle = true
3943

44+
// contains mapping for all string flags to their cloudformation parameter key names
45+
// does not contain UserdataFlag because that is a string slice
4046
var flagNamesToStackParameterKeys map[string]string
4147

4248
func init() {
@@ -150,7 +156,6 @@ func ClusterPS(c *cli.Context) {
150156
os.Stdout.WriteString(infoSet.String(container.ContainerInfoColumns, displayTitle))
151157
}
152158

153-
154159
///////////////////////
155160
// Helper functions //
156161
//////////////////////
@@ -204,7 +209,10 @@ func createCluster(context *cli.Context, awsClients *AWSClients, commandConfig *
204209
}
205210

206211
// Populate cfn params
207-
cfnParams := cliFlagsToCfnStackParams(context)
212+
cfnParams, err := cliFlagsToCfnStackParams(context, commandConfig.Cluster, launchType)
213+
if err != nil {
214+
return err
215+
}
208216
cfnParams.Add(cloudformation.ParameterKeyCluster, commandConfig.Cluster)
209217
if context.Bool(flags.NoAutoAssignPublicIPAddressFlag) {
210218
cfnParams.Add(cloudformation.ParameterKeyAssociatePublicIPAddress, "false")
@@ -219,6 +227,11 @@ func createCluster(context *cli.Context, awsClients *AWSClients, commandConfig *
219227
return fmt.Errorf("You can only specify '--%s' or '--%s'", flags.VpcIdFlag, flags.VpcAzFlag)
220228
}
221229

230+
// Check that user data is not specified with Fargate
231+
if validateMutuallyExclusiveParams(cfnParams, cloudformation.ParameterKeyIsFargate, cloudformation.ParameterKeyUserData) {
232+
return fmt.Errorf("You can only specify '--%s' with the EC2 launch type", flags.UserDataFlag)
233+
}
234+
222235
// Check if 2 AZs are specified
223236
if validateCommaSeparatedParam(cfnParams, cloudformation.ParameterKeyVPCAzs, 2, 2) {
224237
return fmt.Errorf("You must specify 2 comma-separated availability zones with the '--%s' flag", flags.VpcAzFlag)
@@ -415,7 +428,6 @@ func scaleCluster(context *cli.Context, awsClients *AWSClients, commandConfig *c
415428
return cfnClient.WaitUntilUpdateComplete(stackName)
416429
}
417430

418-
419431
// createPS executes the 'ps' command.
420432
func clusterPS(context *cli.Context, rdwr config.ReadWriter) (project.InfoSet, error) {
421433
commandConfig, err := newCommandConfig(context, rdwr)
@@ -466,7 +478,7 @@ func deleteClusterPrompt(reader *bufio.Reader) error {
466478
}
467479

468480
// cliFlagsToCfnStackParams converts values set for CLI flags to cloudformation stack parameters.
469-
func cliFlagsToCfnStackParams(context *cli.Context) *cloudformation.CfnStackParams {
481+
func cliFlagsToCfnStackParams(context *cli.Context, cluster, launchType string) (*cloudformation.CfnStackParams, error) {
470482
cfnParams := cloudformation.NewCfnStackParams()
471483
for cliFlag, cfnParamKeyName := range flagNamesToStackParameterKeys {
472484
cfnParamKeyValue := context.String(cliFlag)
@@ -475,7 +487,21 @@ func cliFlagsToCfnStackParams(context *cli.Context) *cloudformation.CfnStackPara
475487
}
476488
}
477489

478-
return cfnParams
490+
if launchType == config.LaunchTypeEC2 {
491+
builder := newUserDataBuilder(cluster)
492+
// handle extra user data, which is a string slice flag
493+
if userDataFiles := context.StringSlice(flags.UserDataFlag); len(userDataFiles) > 0 {
494+
for _, file := range userDataFiles {
495+
builder.AddFile(file)
496+
}
497+
}
498+
userData, err := builder.Build()
499+
if err != nil {
500+
return nil, err
501+
}
502+
cfnParams.Add(cloudformation.ParameterKeyUserData, userData)
503+
}
504+
return cfnParams, nil
479505
}
480506

481507
// isIAMAcknowledged returns true if the 'capability-iam' flag is set from CLI.

ecs-cli/modules/cli/cluster/cluster_app_test.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"os"
2222
"testing"
2323

24+
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/cluster/userdata"
2425
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/clients/aws/cloudformation"
2526
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/clients/aws/cloudformation/mock"
2627
"github.com/aws/amazon-ecs-cli/ecs-cli/modules/clients/aws/ecs/mock"
@@ -37,9 +38,10 @@ import (
3738
)
3839

3940
const (
40-
clusterName = "defaultCluster"
41-
stackName = "defaultCluster"
42-
amiID = "ami-deadb33f"
41+
clusterName = "defaultCluster"
42+
stackName = "defaultCluster"
43+
amiID = "ami-deadb33f"
44+
mockedUserData = "some user data"
4345
)
4446

4547
type mockReadWriter struct {
@@ -77,6 +79,20 @@ func newMockReadWriter() *mockReadWriter {
7779
}
7880
}
7981

82+
type mockUserDataBuilder struct {
83+
userdata string
84+
files []string
85+
}
86+
87+
func (b *mockUserDataBuilder) AddFile(fileName string) error {
88+
b.files = append(b.files, fileName)
89+
return nil
90+
}
91+
92+
func (b *mockUserDataBuilder) Build() (string, error) {
93+
return b.userdata, nil
94+
}
95+
8096
func setupTest(t *testing.T) (*mock_ecs.MockECSClient, *mock_cloudformation.MockCloudformationClient, *mock_ssm.MockClient) {
8197
ctrl := gomock.NewController(t)
8298
defer ctrl.Finish()
@@ -191,6 +207,61 @@ func TestClusterUpWithoutPublicIP(t *testing.T) {
191207
assert.NoError(t, err, "Unexpected error bringing up cluster")
192208
}
193209

210+
func TestClusterUpWithUserData(t *testing.T) {
211+
defer os.Clearenv()
212+
mockECS, mockCloudformation, mockSSM := setupTest(t)
213+
awsClients := &AWSClients{mockECS, mockCloudformation, mockSSM}
214+
215+
oldNewUserDataBuilder := newUserDataBuilder
216+
defer func() { newUserDataBuilder = oldNewUserDataBuilder }()
217+
userdataMock := &mockUserDataBuilder{
218+
userdata: mockedUserData,
219+
}
220+
newUserDataBuilder = func(clusterName string) userdata.UserDataBuilder {
221+
return userdataMock
222+
}
223+
224+
gomock.InOrder(
225+
mockECS.EXPECT().CreateCluster(clusterName).Return(clusterName, nil),
226+
)
227+
228+
gomock.InOrder(
229+
mockSSM.EXPECT().GetRecommendedECSLinuxAMI().Return(amiMetadata(amiID), nil),
230+
)
231+
232+
gomock.InOrder(
233+
mockCloudformation.EXPECT().ValidateStackExists(stackName).Return(errors.New("error")),
234+
mockCloudformation.EXPECT().CreateStack(gomock.Any(), stackName, gomock.Any()).Do(func(x, y, z interface{}) {
235+
cfnParams := z.(*cloudformation.CfnStackParams)
236+
param, err := cfnParams.GetParameter(cloudformation.ParameterKeyUserData)
237+
assert.NoError(t, err, "Expected User Data parameter to be set")
238+
assert.Equal(t, mockedUserData, aws.StringValue(param.ParameterValue), "Expected user data to match")
239+
}).Return("", nil),
240+
mockCloudformation.EXPECT().WaitUntilCreateComplete(stackName).Return(nil),
241+
)
242+
243+
globalSet := flag.NewFlagSet("ecs-cli", 0)
244+
globalContext := cli.NewContext(nil, globalSet, nil)
245+
246+
flagSet := flag.NewFlagSet("ecs-cli-up", 0)
247+
flagSet.Bool(flags.CapabilityIAMFlag, true, "")
248+
flagSet.String(flags.KeypairNameFlag, "default", "")
249+
userDataFiles := &cli.StringSlice{}
250+
userDataFiles.Set("some_file")
251+
userDataFiles.Set("some_file2")
252+
flagSet.Var(userDataFiles, flags.UserDataFlag, "")
253+
254+
context := cli.NewContext(nil, flagSet, globalContext)
255+
rdwr := newMockReadWriter()
256+
commandConfig, err := newCommandConfig(context, rdwr)
257+
assert.NoError(t, err, "Unexpected error creating CommandConfig")
258+
259+
err = createCluster(context, awsClients, commandConfig)
260+
assert.NoError(t, err, "Unexpected error bringing up cluster")
261+
262+
assert.ElementsMatch(t, []string{"some_file", "some_file2"}, userdataMock.files, "Expected userdata file list to match")
263+
}
264+
194265
func TestClusterUpWithVPC(t *testing.T) {
195266
defer os.Clearenv()
196267
mockECS, mockCloudformation, mockSSM := setupTest(t)
@@ -508,15 +579,17 @@ func TestCliFlagsToCfnStackParams(t *testing.T) {
508579
flagSet.String(flags.KeypairNameFlag, "default", "")
509580

510581
context := cli.NewContext(nil, flagSet, nil)
511-
params := cliFlagsToCfnStackParams(context)
582+
params, err := cliFlagsToCfnStackParams(context, clusterName, config.LaunchTypeEC2)
583+
assert.NoError(t, err, "Unexpected error from call to cliFlagsToCfnStackParams")
512584

513-
_, err := params.GetParameter(cloudformation.ParameterKeyAsgMaxSize)
585+
_, err = params.GetParameter(cloudformation.ParameterKeyAsgMaxSize)
514586
assert.Error(t, err, "Expected error for parameter ParameterKeyAsgMaxSize")
515587
assert.Equal(t, cloudformation.ParameterNotFoundError, err, "Expect error to be ParameterNotFoundError")
516588

517589
flagSet.String(flags.AsgMaxSizeFlag, "2", "")
518590
context = cli.NewContext(nil, flagSet, nil)
519-
params = cliFlagsToCfnStackParams(context)
591+
params, err = cliFlagsToCfnStackParams(context, clusterName, config.LaunchTypeEC2)
592+
assert.NoError(t, err, "Unexpected error from call to cliFlagsToCfnStackParams")
520593
_, err = params.GetParameter(cloudformation.ParameterKeyAsgMaxSize)
521594
assert.NoError(t, err, "Unexpected error getting parameter ParameterKeyAsgMaxSize")
522595
}

0 commit comments

Comments
 (0)