diff --git a/agent/acs/model/api/api-2.json b/agent/acs/model/api/api-2.json index c2aebf2a622..ad6d03ab24e 100644 --- a/agent/acs/model/api/api-2.json +++ b/agent/acs/model/api/api-2.json @@ -211,6 +211,7 @@ "cpu":{"shape":"Integer"}, "entryPoint":{"shape":"StringList"}, "environment":{"shape":"EnvironmentVariables"}, + "environmentFiles":{"shape":"EnvironmentFiles"}, "essential":{"shape":"Boolean"}, "image":{"shape":"String"}, "links":{"shape":"StringList"}, @@ -364,6 +365,23 @@ "base64" ] }, + "EnvironmentFile":{ + "type":"structure", + "members":{ + "value":{"shape":"String"}, + "type":{"shape":"EnvironmentFileType"} + } + }, + "EnvironmentFiles":{ + "type":"list", + "member":{"shape":"EnvironmentFile"} + }, + "EnvironmentFileType": { + "type":"string", + "enum":[ + "s3" + ] + }, "EnvironmentVariables":{ "type":"map", "key":{"shape":"String"}, diff --git a/agent/acs/model/ecsacs/api.go b/agent/acs/model/ecsacs/api.go index 35560ba74fb..7f813e6e1e2 100644 --- a/agent/acs/model/ecsacs/api.go +++ b/agent/acs/model/ecsacs/api.go @@ -288,6 +288,8 @@ type Container struct { Environment map[string]*string `locationName:"environment" type:"map"` + EnvironmentFiles []*EnvironmentFile `locationName:"environmentFiles" type:"list"` + Essential *bool `locationName:"essential" type:"boolean"` FirelensConfiguration *FirelensConfiguration `locationName:"firelensConfiguration" type:"structure"` @@ -511,6 +513,24 @@ func (s EncodedString) GoString() string { return s.String() } +type EnvironmentFile struct { + _ struct{} `type:"structure"` + + Type *string `locationName:"type" type:"string" enum:"EnvironmentFileType"` + + Value *string `locationName:"value" type:"string"` +} + +// String returns the string representation +func (s EnvironmentFile) String() string { + return awsutil.Prettify(s) +} + +// GoString returns the string representation +func (s EnvironmentFile) GoString() string { + return s.String() +} + type ErrorInput struct { _ struct{} `type:"structure"` diff --git a/agent/api/container/container.go b/agent/api/container/container.go index 25383d2e74d..eda464063b5 100644 --- a/agent/api/container/container.go +++ b/agent/api/container/container.go @@ -146,6 +146,8 @@ type Container struct { EntryPoint *[]string // Environment is the environment variable set in the container Environment map[string]string `json:"environment"` + // EnvironmentFiles is the list of environmentFile used to populate environment variables + EnvironmentFiles []EnvironmentFile `json:"environmentFiles"` // Overrides contains the configuration to override of a container Overrides ContainerOverrides `json:"overrides"` // DockerConfig is the configuration used to create the container @@ -281,6 +283,11 @@ type DockerContainer struct { Container *Container } +type EnvironmentFile struct { + Value string `json:"value"` + Type string `json:"type"` +} + // MountPoint describes the in-container location of a Volume and references // that Volume by name. type MountPoint struct { @@ -917,6 +924,19 @@ func (c *Container) ShouldCreateWithASMSecret() bool { return false } +// ShouldCreateWithEnvFiles returns true if this container needs to +// retrieve environment variable files +func (c *Container) ShouldCreateWithEnvFiles() bool { + c.lock.RLock() + defer c.lock.RUnlock() + + if c.EnvironmentFiles == nil { + return false + } + + return len(c.EnvironmentFiles) != 0 +} + // MergeEnvironmentVariables appends additional envVarName:envVarValue pairs to // the the container's environment values structure func (c *Container) MergeEnvironmentVariables(envVars map[string]string) { @@ -932,6 +952,33 @@ func (c *Container) MergeEnvironmentVariables(envVars map[string]string) { } } +// MergeEnvironmentVariablesFromEnvfiles appends environment variable pairs from +// the retrieved envfiles to the container's environment values list +// envvars from envfiles will have lower precedence than existing envvars +func (c *Container) MergeEnvironmentVariablesFromEnvfiles(envVarsList []map[string]string) error { + c.lock.Lock() + defer c.lock.Unlock() + + // create map if does not exist + if c.Environment == nil { + c.Environment = make(map[string]string) + } + + // envVarsList is a list of map, where each map is from an envfile + // iterate over this sequentially because the original order of the + // environment files give precedence to the environment variables + for _, envVars := range envVarsList { + for k, v := range envVars { + // existing environment variables have precedence over variables from envfile + // only set the env var if key does not already exist + if _, ok := c.Environment[k]; !ok { + c.Environment[k] = v + } + } + } + return nil +} + // HasSecret returns whether a container has secret based on a certain condition. func (c *Container) HasSecret(f func(s Secret) bool) bool { c.lock.RLock() @@ -1065,3 +1112,11 @@ func (c *Container) GetFirelensConfig() *FirelensConfig { return c.FirelensConfig } + +// GetEnvironmentFiles returns the container's environment files. +func (c *Container) GetEnvironmentFiles() []EnvironmentFile { + c.lock.RLock() + defer c.lock.RUnlock() + + return c.EnvironmentFiles +} diff --git a/agent/api/container/container_test.go b/agent/api/container/container_test.go index ffcd6148db2..e98374cf685 100644 --- a/agent/api/container/container_test.go +++ b/agent/api/container/container_test.go @@ -666,3 +666,87 @@ func TestGetNetworkModeFromHostConfig(t *testing.T) { }) } } + +func TestShouldCreateWithEnvfiles(t *testing.T) { + cases := []struct { + in Container + out bool + }{ + { + Container{ + Name: "containerName", + Image: "image:tag", + EnvironmentFiles: []EnvironmentFile{ + EnvironmentFile{ + Value: "s3://bucket/envfile", + Type: "s3", + }, + }, + }, true}, + { + Container{ + Name: "containerName", + Image: "image:tag", + EnvironmentFiles: nil, + }, false}, + } + + for _, test := range cases { + container := test.in + assert.Equal(t, test.out, container.ShouldCreateWithEnvFiles()) + } +} + +func TestMergeEnvironmentVariablesFromEnvfiles(t *testing.T) { + cases := []struct { + Name string + InContainerEnvironment map[string]string + InEnvVarList []map[string]string + OutEnvVarMap map[string]string + }{ + { + Name: "merge one item", + InContainerEnvironment: map[string]string{"key1": "value1"}, + InEnvVarList: []map[string]string{{"key2": "value2"}}, + OutEnvVarMap: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + { + Name: "merge single item to nil env var map", + InContainerEnvironment: nil, + InEnvVarList: []map[string]string{{"key": "value"}}, + OutEnvVarMap: map[string]string{"key": "value"}, + }, + { + Name: "merge one item key already exists", + InContainerEnvironment: map[string]string{"key1": "value1"}, + InEnvVarList: []map[string]string{{"key1": "value2"}}, + OutEnvVarMap: map[string]string{"key1": "value1"}, + }, + { + Name: "merge two items with same key", + InContainerEnvironment: map[string]string{"key1": "value1"}, + InEnvVarList: []map[string]string{ + {"key2": "value2"}, + {"key2": "value3"}, + }, + OutEnvVarMap: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + } + + for _, test := range cases { + t.Run(test.Name, func(t *testing.T) { + container := Container{ + Environment: test.InContainerEnvironment, + } + + container.MergeEnvironmentVariablesFromEnvfiles(test.InEnvVarList) + assert.True(t, reflect.DeepEqual(test.OutEnvVarMap, container.Environment)) + }) + } +} diff --git a/agent/api/task/task.go b/agent/api/task/task.go index ec7e4279ea7..9efdb242fda 100644 --- a/agent/api/task/task.go +++ b/agent/api/task/task.go @@ -45,6 +45,7 @@ import ( "github.com/aws/amazon-ecs-agent/agent/taskresource" "github.com/aws/amazon-ecs-agent/agent/taskresource/asmauth" "github.com/aws/amazon-ecs-agent/agent/taskresource/asmsecret" + "github.com/aws/amazon-ecs-agent/agent/taskresource/envFiles" "github.com/aws/amazon-ecs-agent/agent/taskresource/firelens" "github.com/aws/amazon-ecs-agent/agent/taskresource/ssmsecret" resourcestatus "github.com/aws/amazon-ecs-agent/agent/taskresource/status" @@ -371,6 +372,11 @@ func (task *Task) PostUnmarshalTask(cfg *config.Config, } } + if err := task.initializeEnvfilesResource(cfg, credentialsManager); err != nil { + seelog.Errorf("Task [%s]: could not initialize environment files resource: %v", task.Arn, err) + return apierrors.NewResourceInitError(task.Arn, err) + } + return nil } @@ -2576,3 +2582,74 @@ func (task *Task) GetContainerIndex(containerName string) int { } return -1 } + +func (task *Task) requireEnvfiles() bool { + for _, container := range task.Containers { + if container.ShouldCreateWithEnvFiles() { + return true + } + } + return false +} + +func (task *Task) initializeEnvfilesResource(config *config.Config, credentialsManager credentials.Manager) error { + + for _, container := range task.Containers { + if container.ShouldCreateWithEnvFiles() { + envfileResource, err := envFiles.NewEnvironmentFileResource(config.Cluster, task.Arn, config.AWSRegion, config.DataDir, + container.Name, container.EnvironmentFiles, credentialsManager, task.ExecutionCredentialsID) + if err != nil { + return errors.Wrapf(err, "unable to initialize envfiles resource for container %s", container.Name) + } + task.AddResource(envFiles.ResourceName, envfileResource) + container.BuildResourceDependency(envfileResource.GetName(), resourcestatus.ResourceCreated, apicontainerstatus.ContainerCreated) + } + } + + return nil +} + +func (task *Task) getEnvfilesResource(containerName string) (taskresource.TaskResource, bool) { + task.lock.RLock() + defer task.lock.RUnlock() + + resources, ok := task.ResourcesMapUnsafe[envFiles.ResourceName] + if !ok { + return nil, false + } + + for _, resource := range resources { + envfileResource := resource.(*envFiles.EnvironmentFileResource) + if envfileResource.GetContainerName() == containerName { + return envfileResource, true + } + } + + // was not able to retrieve envfile resource for specified container name + return nil, false +} + +// MergeEnvVarsFromEnvfiles should be called when creating a container - +// this method reads the environment variables specified in the environment files +// that was downloaded to disk and merges it with existing environment variables +func (task *Task) MergeEnvVarsFromEnvfiles(container *apicontainer.Container) *apierrors.ResourceInitError { + var envfileResource *envFiles.EnvironmentFileResource + resource, ok := task.getEnvfilesResource(container.Name) + if !ok { + err := errors.New(fmt.Sprintf("task environment files: unable to retrieve environment files resource for container %s", container.Name)) + return apierrors.NewResourceInitError(task.Arn, err) + } + envfileResource = resource.(*envFiles.EnvironmentFileResource) + + envVarsList, err := envfileResource.ReadEnvVarsFromEnvfiles() + if err != nil { + return apierrors.NewResourceInitError(task.Arn, err) + } + + err = container.MergeEnvironmentVariablesFromEnvfiles(envVarsList) + if err != nil { + return apierrors.NewResourceInitError(task.Arn, err) + } + + return nil +} diff --git a/agent/api/task/task_test.go b/agent/api/task/task_test.go index 135532eab0c..0183d0b3228 100644 --- a/agent/api/task/task_test.go +++ b/agent/api/task/task_test.go @@ -48,6 +48,7 @@ import ( "github.com/aws/aws-sdk-go/service/secretsmanager" "github.com/aws/amazon-ecs-agent/agent/taskresource/asmsecret" + "github.com/aws/amazon-ecs-agent/agent/taskresource/envFiles" "github.com/aws/amazon-ecs-agent/agent/taskresource/ssmsecret" "github.com/aws/aws-sdk-go/aws" "github.com/docker/docker/api/types" @@ -1152,6 +1153,12 @@ func TestTaskFromACS(t *testing.T) { Region: strptr("us-west-2"), }, }, + EnvironmentFiles: []*ecsacs.EnvironmentFile{ + { + Value: strptr("s3://bucketName/envFile"), + Type: strptr("s3"), + }, + }, }, }, Volumes: []*ecsacs.Volume{ @@ -1243,6 +1250,12 @@ func TestTaskFromACS(t *testing.T) { Region: "us-west-2", }, }, + EnvironmentFiles: []apicontainer.EnvironmentFile{ + { + Value: "s3://bucketName/envFile", + Type: "s3", + }, + }, TransitionDependenciesMap: make(map[apicontainerstatus.ContainerStatus]apicontainer.TransitionDependencySet), }, }, @@ -3317,3 +3330,109 @@ func TestBuildCNIConfigTrunkBranchENI(t *testing.T) { }) } } + +func TestPostUnmarshalTaskEnvfiles(t *testing.T) { + envfile := apicontainer.EnvironmentFile{ + Value: "s3://bucket/envfile", + Type: "s3", + } + + container := &apicontainer.Container{ + Name: "containerName", + Image: "image:tag", + EnvironmentFiles: []apicontainer.EnvironmentFile{envfile}, + TransitionDependenciesMap: make(map[apicontainerstatus.ContainerStatus]apicontainer.TransitionDependencySet), + } + + task := &Task{ + Arn: "testArn", + ResourcesMapUnsafe: make(map[string][]taskresource.TaskResource), + Containers: []*apicontainer.Container{container}, + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cfg := &config.Config{} + credentialsManager := mock_credentials.NewMockManager(ctrl) + resFields := &taskresource.ResourceFields{ + ResourceFieldsCommon: &taskresource.ResourceFieldsCommon{ + CredentialsManager: credentialsManager, + }, + } + + resourceDep := apicontainer.ResourceDependency{ + Name: envFiles.ResourceName, + RequiredStatus: resourcestatus.ResourceStatus(envFiles.EnvFileCreated), + } + + err := task.PostUnmarshalTask(cfg, credentialsManager, resFields, nil, nil) + assert.NoError(t, err) + + assert.Equal(t, 1, len(task.ResourcesMapUnsafe)) + assert.Equal(t, resourceDep, + task.Containers[0].TransitionDependenciesMap[apicontainerstatus.ContainerCreated].ResourceDependencies[0]) +} + +func TestInitializeAndGetEnvfilesResource(t *testing.T) { + envfile := apicontainer.EnvironmentFile{ + Value: "s3://bucket/envfile", + Type: "s3", + } + + container := &apicontainer.Container{ + Name: "containerName", + Image: "image:tag", + EnvironmentFiles: []apicontainer.EnvironmentFile{envfile}, + TransitionDependenciesMap: make(map[apicontainerstatus.ContainerStatus]apicontainer.TransitionDependencySet), + } + + task := &Task{ + Arn: "testArn", + ResourcesMapUnsafe: make(map[string][]taskresource.TaskResource), + Containers: []*apicontainer.Container{container}, + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cfg := &config.Config{ + DataDir: "/ecs/data", + } + credentialsManager := mock_credentials.NewMockManager(ctrl) + + task.initializeEnvfilesResource(cfg, credentialsManager) + + resourceDep := apicontainer.ResourceDependency{ + Name: envFiles.ResourceName, + RequiredStatus: resourcestatus.ResourceStatus(envFiles.EnvFileCreated), + } + + assert.Equal(t, resourceDep, + task.Containers[0].TransitionDependenciesMap[apicontainerstatus.ContainerCreated].ResourceDependencies[0]) + + _, ok := task.getEnvfilesResource("containerName") + assert.True(t, ok) +} + +func TestRequiresEnvfiles(t *testing.T) { + envfile := apicontainer.EnvironmentFile{ + Value: "s3://bucket/envfile", + Type: "s3", + } + + container := &apicontainer.Container{ + Name: "containerName", + Image: "image:tag", + EnvironmentFiles: []apicontainer.EnvironmentFile{envfile}, + TransitionDependenciesMap: make(map[apicontainerstatus.ContainerStatus]apicontainer.TransitionDependencySet), + } + + task := &Task{ + Arn: "testArn", + ResourcesMapUnsafe: make(map[string][]taskresource.TaskResource), + Containers: []*apicontainer.Container{container}, + } + + assert.Equal(t, true, task.requireEnvfiles()) +} diff --git a/agent/app/agent_capability.go b/agent/app/agent_capability.go index f33fd267ca0..6ce7f03baa3 100644 --- a/agent/app/agent_capability.go +++ b/agent/app/agent_capability.go @@ -59,6 +59,7 @@ const ( capabilityGMSA = "gmsa" capabilityEFS = "efs" capabilityEFSAuth = "efsAuth" + capabilityEnvFilesS3 = "env-files.s3" ) // capabilities returns the supported capabilities of this agent / docker-client pair. @@ -106,6 +107,7 @@ const ( // ecs.capability.full-sync // ecs.capability.gmsa // ecs.capability.efsAuth +// ecs.capability.env-files.s3 func (agent *ecsAgent) capabilities() ([]*ecs.Attribute, error) { var capabilities []*ecs.Attribute @@ -177,6 +179,9 @@ func (agent *ecsAgent) capabilities() ([]*ecs.Attribute, error) { // support full task sync capabilities = appendNameOnlyAttribute(capabilities, attributePrefix+capabilityFullTaskSync) + // ecs agent version 1.39.0 supports bulk loading env vars through environmentFiles in S3 + capabilities = appendNameOnlyAttribute(capabilities, attributePrefix+capabilityEnvFilesS3) + // ecs agent version 1.22.0 supports sharing PID namespaces and IPC resource namespaces // with host EC2 instance and among containers within the task capabilities = agent.appendPIDAndIPCNamespaceSharingCapabilities(capabilities) diff --git a/agent/app/agent_capability_test.go b/agent/app/agent_capability_test.go index 36393ab92c8..5cfd424f824 100644 --- a/agent/app/agent_capability_test.go +++ b/agent/app/agent_capability_test.go @@ -137,6 +137,9 @@ func TestCapabilities(t *testing.T) { { Name: aws.String(attributePrefix + capabilityFullTaskSync), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, }...) ctx, cancel := context.WithCancel(context.TODO()) diff --git a/agent/app/agent_capability_unix_test.go b/agent/app/agent_capability_unix_test.go index aa549a43720..91d16594638 100644 --- a/agent/app/agent_capability_unix_test.go +++ b/agent/app/agent_capability_unix_test.go @@ -541,6 +541,9 @@ func TestPIDAndIPCNamespaceSharingCapabilitiesUnix(t *testing.T) { { Name: aws.String(attributePrefix + capabilityFullTaskSync), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, { Name: aws.String(attributePrefix + capabiltyPIDAndIPCNamespaceSharing), }, @@ -629,6 +632,9 @@ func TestPIDAndIPCNamespaceSharingCapabilitiesNoPauseContainer(t *testing.T) { { Name: aws.String(attributePrefix + capabilityFullTaskSync), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, }...) ctx, cancel := context.WithCancel(context.TODO()) // Cancel the context to cancel async routines @@ -714,6 +720,9 @@ func TestAppMeshCapabilitiesUnix(t *testing.T) { { Name: aws.String(attributePrefix + capabilityFullTaskSync), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, { Name: aws.String(attributePrefix + capabiltyPIDAndIPCNamespaceSharing), }, @@ -924,6 +933,9 @@ func TestCapabilitiesUnix(t *testing.T) { { Name: aws.String(capabilityPrefix + capabilityFirelensLoggingDriver), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, }...) ctx, cancel := context.WithCancel(context.TODO()) // Cancel the context to cancel async routines diff --git a/agent/app/agent_capability_windows_test.go b/agent/app/agent_capability_windows_test.go index b2aecc79c2b..cc7f8a3b71a 100644 --- a/agent/app/agent_capability_windows_test.go +++ b/agent/app/agent_capability_windows_test.go @@ -213,6 +213,9 @@ func TestSupportedCapabilitiesWindows(t *testing.T) { { Name: aws.String(attributePrefix + capabilityFullTaskSync), }, + { + Name: aws.String(attributePrefix + capabilityEnvFilesS3), + }, }...) ctx, cancel := context.WithCancel(context.TODO()) diff --git a/agent/engine/docker_task_engine.go b/agent/engine/docker_task_engine.go index 96c390bba48..dfa6b4ffcab 100644 --- a/agent/engine/docker_task_engine.go +++ b/agent/engine/docker_task_engine.go @@ -1018,6 +1018,14 @@ func (engine *DockerTaskEngine) createContainer(task *apitask.Task, container *a } } + if container.ShouldCreateWithEnvFiles() { + err := task.MergeEnvVarsFromEnvfiles(container) + if err != nil { + seelog.Errorf("Error populating environment variables from specified files into container %s", container.Name) + return dockerapi.DockerContainerMetadata{Error: apierrors.NamedError(err)} + } + } + config, err := task.DockerConfig(container, dockerClientVersion) if err != nil { return dockerapi.DockerContainerMetadata{Error: apierrors.NamedError(err)} diff --git a/agent/statemanager/state_manager.go b/agent/statemanager/state_manager.go index 46d9e9eda93..582ea74fb3e 100644 --- a/agent/statemanager/state_manager.go +++ b/agent/statemanager/state_manager.go @@ -101,8 +101,9 @@ const ( // 27) // a) Add 'authorizationConfig', 'transitEncryption' and 'transitEncryptionPort' to 'taskresource.volume.EFSVolumeConfig' // b) Add 'pauseContainerPID' field to 'taskresource.volume.VolumeResource' + // 28) Add 'envfile' field to 'resources' - ECSDataVersion = 27 + ECSDataVersion = 28 // ecsDataFile specifies the filename in the ECS_DATADIR ecsDataFile = "ecs_agent_data.json" diff --git a/agent/statemanager/state_manager_test.go b/agent/statemanager/state_manager_test.go index 0a740f9ef9a..3cb96fe1a37 100644 --- a/agent/statemanager/state_manager_test.go +++ b/agent/statemanager/state_manager_test.go @@ -486,3 +486,31 @@ func TestLoadsDataSeqTaskManifest(t *testing.T) { assert.EqualValues(t, 0, sequenceNumber) assert.EqualValues(t, 7, seqNumTaskManifest) } + +func TestLoadsDataForEnvFiles(t *testing.T) { + cleanup, err := setupWindowsTest(filepath.Join(".", "testdata", "v28", "environmentFiles", "ecs_agent_data.json")) + require.Nil(t, err, "Failed to set up test") + defer cleanup() + cfg := &config.Config{DataDir: filepath.Join(".", "testdata", "v28", "environmentFiles")} + taskEngine := engine.NewTaskEngine(&config.Config{}, nil, nil, nil, nil, dockerstate.NewTaskEngineState(), nil, nil) + var containerInstanceArn, cluster, savedInstanceID string + var sequenceNumber int64 + stateManager, err := statemanager.NewStateManager(cfg, + statemanager.AddSaveable("TaskEngine", taskEngine), + statemanager.AddSaveable("ContainerInstanceArn", &containerInstanceArn), + statemanager.AddSaveable("Cluster", &cluster), + statemanager.AddSaveable("EC2InstanceID", &savedInstanceID), + statemanager.AddSaveable("SeqNum", &sequenceNumber), + ) + assert.NoError(t, err) + err = stateManager.Load() + assert.NoError(t, err) + tasks, err := taskEngine.ListTasks() + assert.NoError(t, err) + assert.Equal(t, 1, len(tasks)) + assert.Equal(t, "state-file", cluster) + assert.EqualValues(t, 0, sequenceNumber) + task := tasks[0] + assert.Equal(t, "arn:aws:ecs:us-west-2:123456789011:task/70947c96-f64e-483a-a612-3fd4303546e7", task.Arn) + assert.Equal(t, "sleep360", task.Family) +} diff --git a/agent/statemanager/testdata/v28/environmentFiles/ecs_agent_data.json b/agent/statemanager/testdata/v28/environmentFiles/ecs_agent_data.json new file mode 100644 index 00000000000..ebd2c534460 --- /dev/null +++ b/agent/statemanager/testdata/v28/environmentFiles/ecs_agent_data.json @@ -0,0 +1,168 @@ +{ + "Data": { + "Cluster": "state-file", + "ContainerInstanceArn": "arn:aws:ecs:us-west-2:123456789011:container-instance/ea27e41b-c6e4-45a9-a7a0-484c95abece7", + "EC2InstanceID": "i-0e38e94fed89f598f", + "TaskEngine": { + "Tasks": [ + { + "Arn": "arn:aws:ecs:us-west-2:123456789011:task/70947c96-f64e-483a-a612-3fd4303546e7", + "Family": "sleep360", + "Version": "6", + "Containers": [ + { + "Name": "sleep", + "RuntimeID": "8a0a36b66de778bdc4c00bc15085ef1902c6b1ea16b4b259f7dfc199a7f004c6", + "V3EndpointID": "7e7f5a6d-42ef-452e-bafc-6d4b6283ac99", + "Image": "busybox", + "ImageID": "sha256:e2722fa29573abec09dcebcc1a19186cc6bdc74663f4992a1855db8ee88ad75f", + "Command": [ + "sleep", + "360" + ], + "Cpu": 10, + "GPUIDs": null, + "Memory": 100, + "Links": null, + "volumesFrom": [], + "mountPoints": [], + "portMappings": [], + "secrets": null, + "Essential": true, + "EntryPoint": null, + "environment": { + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/7411f309-41a2-51c7-ad8a-ba16105516a0", + "AWS_EXECUTION_ENV": "AWS_ECS_EC2", + "ECS_CONTAINER_METADATA_URI": "http://169.254.170.2/v3/7e7f5a6d-42ef-452e-bafc-6d4b6283ac99" + }, + "overrides": { + "command": null + }, + "dockerConfig": { + "config": "{}", + "hostConfig": "{\"CapAdd\":[],\"CapDrop\":[]}", + "version": "1.17" + }, + "registryAuthentication": null, + "LogsAuthStrategy": "", + "StartTimeout": 0, + "StopTimeout": 0, + "desiredStatus": "RUNNING", + "KnownStatus": "RUNNING", + "RunDependencies": null, + "IsInternal": "NORMAL", + "ApplyingError": null, + "SentStatus": "RUNNING", + "metadataFileUpdated": false, + "KnownExitCode": null, + "KnownPortBindings": null + } + ], + "associations": [], + "resources": { + "envfile": [ + { + "environmentFilesSource": [ + {"type": "s3", "value": "arn:aws:s3:::bucket/key1.env"}, + {"type": "s3", "value": "arn:aws:s3:::bucket/key2.env"} + ], + "taskARN": "arn:aws:ecs:us-west-2:123456789011:task/70947c96-f64e-483a-a612-3fd4303546e7", + "desiredStatus": "CREATED", + "knownStatus": "CREATED", + "executionCredentialsID": "" + } + ] + }, + "volumes": [], + "DesiredStatus": "RUNNING", + "KnownStatus": "RUNNING", + "KnownTime": "2019-08-06T21:59:04.217198554Z", + "PullStartedAt": "2019-08-06T21:59:01.88671907Z", + "PullStoppedAt": "2019-08-06T21:59:03.799514307Z", + "ExecutionStoppedAt": "0001-01-01T00:00:00Z", + "SentStatus": "RUNNING", + "StartSequenceNumber": 2, + "StopSequenceNumber": 0, + "executionCredentialsID": "", + "ENI": null, + "AppMesh": null, + "MemoryCPULimitsEnabled": true, + "PlatformFields": {} + } + ], + "IdToContainer": { + "8a0a36b66de778bdc4c00bc15085ef1902c6b1ea16b4b259f7dfc199a7f004c6": { + "DockerId": "8a0a36b66de778bdc4c00bc15085ef1902c6b1ea16b4b259f7dfc199a7f004c6", + "DockerName": "ecs-sleep360-6-sleep-a2b4d9d6ef938afc6f00", + "Container": { + "Name": "sleep", + "RuntimeID": "8a0a36b66de778bdc4c00bc15085ef1902c6b1ea16b4b259f7dfc199a7f004c6", + "V3EndpointID": "7e7f5a6d-42ef-452e-bafc-6d4b6283ac99", + "Image": "busybox", + "ImageID": "sha256:e2722fa29573abec09dcebcc1a19186cc6bdc74663f4992a1855db8ee88ad75f", + "Command": [ + "sleep", + "360" + ], + "Cpu": 10, + "GPUIDs": null, + "Memory": 100, + "Links": null, + "volumesFrom": [], + "mountPoints": [], + "portMappings": [], + "secrets": null, + "Essential": true, + "EntryPoint": null, + "environment": { + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/7411f309-41a2-51c7-ad8a-ba16105516a0", + "AWS_EXECUTION_ENV": "AWS_ECS_EC2", + "ECS_CONTAINER_METADATA_URI": "http://169.254.170.2/v3/7e7f5a6d-42ef-452e-bafc-6d4b6283ac99" + }, + "overrides": { + "command": null + }, + "dockerConfig": { + "config": "{}", + "hostConfig": "{\"CapAdd\":[],\"CapDrop\":[]}", + "version": "1.17" + }, + "registryAuthentication": null, + "LogsAuthStrategy": "", + "StartTimeout": 0, + "StopTimeout": 0, + "desiredStatus": "RUNNING", + "KnownStatus": "RUNNING", + "RunDependencies": null, + "IsInternal": "NORMAL", + "ApplyingError": null, + "SentStatus": "RUNNING", + "metadataFileUpdated": false, + "KnownExitCode": null, + "KnownPortBindings": null + } + } + }, + "IdToTask": { + "8a0a36b66de778bdc4c00bc15085ef1902c6b1ea16b4b259f7dfc199a7f004c6": "arn:aws:ecs:us-west-2:123456789011:task/70947c96-f64e-483a-a612-3fd4303546e7" + }, + "ImageStates": [ + { + "Image": { + "ImageID": "sha256:e2722fa29573abec09dcebcc1a19186cc6bdc74663f4992a1855db8ee88ad75f", + "Names": [ + "busybox" + ], + "Size": 1223894 + }, + "PulledAt": "2019-08-06T21:59:03.797764725Z", + "LastUsedAt": "2019-08-06T21:59:03.797764824Z", + "PullSucceeded": true + } + ], + "ENIAttachments": null, + "IPToTask": {} + } + }, + "Version": 27 +} diff --git a/agent/taskresource/envFiles/envfile.go b/agent/taskresource/envFiles/envfile.go new file mode 100644 index 00000000000..36d7db5179c --- /dev/null +++ b/agent/taskresource/envFiles/envfile.go @@ -0,0 +1,594 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package envFiles + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + apicontainer "github.com/aws/amazon-ecs-agent/agent/api/container" + apicontainerstatus "github.com/aws/amazon-ecs-agent/agent/api/container/status" + "github.com/aws/amazon-ecs-agent/agent/credentials" + "github.com/aws/amazon-ecs-agent/agent/s3" + "github.com/aws/amazon-ecs-agent/agent/s3/factory" + resourcestatus "github.com/aws/amazon-ecs-agent/agent/taskresource/status" + "github.com/aws/amazon-ecs-agent/agent/utils/oswrapper" + "github.com/cihub/seelog" + "github.com/pkg/errors" + + "github.com/aws/amazon-ecs-agent/agent/api/task/status" + "github.com/aws/amazon-ecs-agent/agent/taskresource" + "github.com/aws/amazon-ecs-agent/agent/utils/bufiowrapper" + "github.com/aws/amazon-ecs-agent/agent/utils/ioutilwrapper" +) + +const ( + ResourceName = "envfile" + envFileDirPath = "envfiles" + envTempFilePrefix = "tmp_env" + envFileExtension = ".env" + commentIndicator = "#" + envVariableDelimiter = "=" + + s3DownloadTimeout = 30 * time.Second +) + +// EnvironmentFileResource represents envfile as a task resource +// these environment files are retrieved from s3 +type EnvironmentFileResource struct { + cluster string + taskARN string + region string + resourceDir string // path to store env var files + containerName string + + // env file related attributes + environmentFilesSource []apicontainer.EnvironmentFile // list of env file objects + + executionCredentialsID string + credentialsManager credentials.Manager + s3ClientCreator factory.S3ClientCreator + os oswrapper.OS + ioutil ioutilwrapper.IOUtil + bufio bufiowrapper.Bufio + + // Fields for the common functionality of task resource. Access to these fields are protected by lock. + createdAtUnsafe time.Time + desiredStatusUnsafe resourcestatus.ResourceStatus + knownStatusUnsafe resourcestatus.ResourceStatus + appliedStatusUnsafe resourcestatus.ResourceStatus + statusToTransitions map[resourcestatus.ResourceStatus]func() error + terminalReasonUnsafe string + terminalReasonOnce sync.Once + lock sync.RWMutex +} + +// NewEnvironmentFileResource creates a new EnvironmentFileResource object +func NewEnvironmentFileResource(cluster, taskARN, region, dataDir, containerName string, envfiles []apicontainer.EnvironmentFile, + credentialsManager credentials.Manager, executionCredentialsID string) (*EnvironmentFileResource, error) { + envfileResource := &EnvironmentFileResource{ + cluster: cluster, + taskARN: taskARN, + region: region, + containerName: containerName, + environmentFilesSource: envfiles, + os: oswrapper.NewOS(), + ioutil: ioutilwrapper.NewIOUtil(), + bufio: bufiowrapper.NewBufio(), + s3ClientCreator: factory.NewS3ClientCreator(), + executionCredentialsID: executionCredentialsID, + credentialsManager: credentialsManager, + } + + taskARNFields := strings.Split(taskARN, "/") + taskID := taskARNFields[len(taskARNFields)-1] + // we save envfiles for a task to path: /var/lib/ecs/data/envfiles/cluster_name/task_id/ + envfileResource.resourceDir = filepath.Join(dataDir, envFileDirPath, cluster, taskID) + + envfileResource.initStatusToTransition() + return envfileResource, nil +} + +// Initialize initializes the EnvironmentFileResource +func (envfile *EnvironmentFileResource) Initialize(resourceFields *taskresource.ResourceFields, + taskKnownStatus status.TaskStatus, + taskDesiredStatus status.TaskStatus) { + envfile.lock.Lock() + defer envfile.lock.Unlock() + + envfile.initStatusToTransition() + envfile.credentialsManager = resourceFields.CredentialsManager + envfile.s3ClientCreator = factory.NewS3ClientCreator() + envfile.os = oswrapper.NewOS() + envfile.ioutil = ioutilwrapper.NewIOUtil() + envfile.bufio = bufiowrapper.NewBufio() + + // if task isn't in 'created' status and desired status is 'running', + // reset the resource status to 'NONE' so we always retrieve the data + // this is in case agent crashes + if taskKnownStatus < status.TaskCreated && taskDesiredStatus <= status.TaskRunning { + envfile.SetKnownStatus(resourcestatus.ResourceStatusNone) + } +} + +func (envfile *EnvironmentFileResource) initStatusToTransition() { + resourceStatusToTransitionFunc := map[resourcestatus.ResourceStatus]func() error{ + resourcestatus.ResourceStatus(EnvFileCreated): envfile.Create, + } + envfile.statusToTransitions = resourceStatusToTransitionFunc +} + +// SetDesiredStatus safely sets the desired status of the resource +func (envfile *EnvironmentFileResource) SetDesiredStatus(status resourcestatus.ResourceStatus) { + envfile.lock.Lock() + defer envfile.lock.Unlock() + + envfile.desiredStatusUnsafe = status +} + +// GetDesiredStatus safely returns the desired status of the resource +func (envfile *EnvironmentFileResource) GetDesiredStatus() resourcestatus.ResourceStatus { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.desiredStatusUnsafe +} + +func (envfile *EnvironmentFileResource) updateAppliedStatusUnsafe(knownStatus resourcestatus.ResourceStatus) { + if envfile.appliedStatusUnsafe == resourcestatus.ResourceStatus(EnvFileStatusNone) { + return + } + + // only apply if resource transition has already finished + if envfile.appliedStatusUnsafe <= knownStatus { + envfile.appliedStatusUnsafe = resourcestatus.ResourceStatus(EnvFileStatusNone) + } +} + +// SetKnownStatus safely sets the currently known status of the resource +func (envfile *EnvironmentFileResource) SetKnownStatus(status resourcestatus.ResourceStatus) { + envfile.lock.Lock() + defer envfile.lock.Unlock() + + envfile.knownStatusUnsafe = status + envfile.updateAppliedStatusUnsafe(status) +} + +// GetKnownStatus safely returns the currently known status of the resource +func (envfile *EnvironmentFileResource) GetKnownStatus() resourcestatus.ResourceStatus { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.knownStatusUnsafe +} + +// SetCreatedAt safely sets the timestamp for the resource's creation time +func (envfile *EnvironmentFileResource) SetCreatedAt(createdAt time.Time) { + if createdAt.IsZero() { + return + } + + envfile.lock.Lock() + defer envfile.lock.Unlock() + + envfile.createdAtUnsafe = createdAt +} + +// GetCreatedAt safely returns the timestamp for the resource's creation time +func (envfile *EnvironmentFileResource) GetCreatedAt() time.Time { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.createdAtUnsafe +} + +// GetName returns the name fo the resource +func (envfile *EnvironmentFileResource) GetName() string { + return ResourceName +} + +// DesiredTerminal returns true if the resource's desired status is REMOVED +func (envfile *EnvironmentFileResource) DesiredTerminal() bool { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.desiredStatusUnsafe == resourcestatus.ResourceStatus(EnvironmentFileStatus(EnvFileRemoved)) +} + +// KnownCreated returns true if the resource's known status is CREATED +func (envfile *EnvironmentFileResource) KnownCreated() bool { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.knownStatusUnsafe == resourcestatus.ResourceStatus(EnvFileCreated) +} + +// TerminalStatus returns the last transition state of the resource +func (envfile *EnvironmentFileResource) TerminalStatus() resourcestatus.ResourceStatus { + return resourcestatus.ResourceStatus(EnvFileRemoved) +} + +// NextKnownState returns the state that the resource should +// progress to based on its `KnownState` +func (envfile *EnvironmentFileResource) NextKnownState() resourcestatus.ResourceStatus { + return envfile.GetKnownStatus() + 1 +} + +// ApplyTransition calls the function required to move to the specified status +func (envfile *EnvironmentFileResource) ApplyTransition(nextState resourcestatus.ResourceStatus) error { + transitionFunc, ok := envfile.statusToTransitions[nextState] + if !ok { + return errors.Errorf("resource [%s]: transition to %s impossible", envfile.GetName(), + envfile.StatusString(nextState)) + } + return transitionFunc() +} + +// SteadyState returns the transition state of the resource defined as "ready" +func (envfile *EnvironmentFileResource) SteadyState() resourcestatus.ResourceStatus { + return resourcestatus.ResourceStatus(EnvFileCreated) +} + +// SetAppliedStatus sets the applied status of the resource and returns whether +// the resource is already in a transition +func (envfile *EnvironmentFileResource) SetAppliedStatus(status resourcestatus.ResourceStatus) bool { + envfile.lock.Lock() + defer envfile.lock.Unlock() + + if envfile.appliedStatusUnsafe != resourcestatus.ResourceStatus(EnvFileStatusNone) { + // set operation failed, return false + return false + } + + envfile.appliedStatusUnsafe = status + return true +} + +// StatusString returns the string representation of the resource status +func (envfile *EnvironmentFileResource) StatusString(status resourcestatus.ResourceStatus) string { + return EnvironmentFileStatus(status).String() +} + +// GetTerminalReason returns an error string to propagate up through to to +// state change messages +func (envfile *EnvironmentFileResource) GetTerminalReason() string { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.terminalReasonUnsafe +} + +func (envfile *EnvironmentFileResource) setTerminalReason(reason string) { + envfile.lock.Lock() + defer envfile.lock.Unlock() + + envfile.terminalReasonOnce.Do(func() { + seelog.Infof("envfile resource: setting terminal reason for task: [%s]", envfile.taskARN) + envfile.terminalReasonUnsafe = reason + }) +} + +// Create performs resource creation. This retrieves env file contents concurrently +// from s3 and writes them to disk +func (envfile *EnvironmentFileResource) Create() error { + seelog.Debugf("Creating envfile resource.") + // make sure it has the task execution role + executionCredentials, ok := envfile.credentialsManager.GetTaskCredentials(envfile.executionCredentialsID) + if !ok { + err := errors.New("environment file resource: unable to find execution role credentials") + envfile.setTerminalReason(err.Error()) + return err + } + + var wg sync.WaitGroup + errorEvents := make(chan error, len(envfile.environmentFilesSource)) + + iamCredentials := executionCredentials.GetIAMRoleCredentials() + for _, envfileSource := range envfile.environmentFilesSource { + wg.Add(1) + // if we support types besides S3 ARN, we will need to add filtering before the below method is called + // call an additional go routine per env file + go envfile.downloadEnvfileFromS3(envfileSource.Value, iamCredentials, &wg, errorEvents) + } + + wg.Wait() + close(errorEvents) + + if len(errorEvents) > 0 { + var terminalReasons []string + for err := range errorEvents { + terminalReasons = append(terminalReasons, err.Error()) + } + + errorString := strings.Join(terminalReasons, ";") + envfile.setTerminalReason(errorString) + return errors.New(errorString) + } + + return nil +} + +// createEnvfileDirectory creates the directory that we will be writing the +// envfile to - needs to be called for each different envfile +func (envfile *EnvironmentFileResource) createEnvfileDirectory(bucket, key string) error { + // create directories to include bucket and key but not the actual resulting file + keyDir := filepath.Dir(key) + envfileDir := filepath.Join(envfile.resourceDir, bucket, keyDir) + err := envfile.os.MkdirAll(envfileDir, os.ModePerm) + if err != nil { + return errors.Wrapf(err, "unable to create envfiles directory with bucket %s", bucket) + } + + return nil +} + +func (envfile *EnvironmentFileResource) downloadEnvfileFromS3(envFilePath string, iamCredentials credentials.IAMRoleCredentials, + wg *sync.WaitGroup, errorEvents chan error) { + defer wg.Done() + + bucket, key, err := s3.ParseS3ARN(envFilePath) + if err != nil { + errorEvents <- fmt.Errorf("unable to parse bucket and key from s3 ARN specified in environmentFile %s, error: %v", envFilePath, err) + return + } + + s3Client, err := envfile.s3ClientCreator.NewS3ClientForBucket(bucket, envfile.region, iamCredentials) + if err != nil { + errorEvents <- fmt.Errorf("unable to initialize s3 client for bucket %s, error: %v", bucket, err) + return + } + + err = envfile.createEnvfileDirectory(bucket, key) + if err != nil { + errorEvents <- fmt.Errorf("unable to initialize envfile resource directory, error: %v", err) + return + } + + seelog.Debugf("Downloading envfile with bucket name %v and key name %v", bucket, key) + // we save envfiles to path: /var/lib/ecs/data/envfiles/cluster_name/task_id/${s3bucketname}/${s3filename.env} + downloadPath := filepath.Join(envfile.resourceDir, bucket, key) + err = envfile.writeEnvFile(func(file oswrapper.File) error { + return s3.DownloadFile(bucket, key, s3DownloadTimeout, file, s3Client) + }, downloadPath) + + if err != nil { + errorEvents <- fmt.Errorf("unable to download env file with key %s from bucket %s, error: %v", key, bucket, err) + return + } + + seelog.Debugf("Downloaded envfile from s3 and saved to %s", downloadPath) +} + +func (envfile *EnvironmentFileResource) writeEnvFile(writeFunc func(file oswrapper.File) error, fullPathName string) error { + // File moves (renaming) are atomic while file writes are not + // so we write to a temp file before renaming to actual file + // multiple programs calling TempFile will not reference the same file + // so this should be ok to be called by multiple go routines + tmpFile, err := envfile.ioutil.TempFile(envfile.resourceDir, envTempFilePrefix) + if err != nil { + seelog.Errorf("Something went wrong trying to create a temp file with prefix %s", envTempFilePrefix) + return err + } + // defer tmpFile.Close() in case something goes wrong and we don't actually hit the manual Close call + // the source for golang *os.File.Close() shows that subsequent calls to Close() after the first + // will do nothing except return syscall.EINVAL, so it is ok to make potentially multiple .Close calls + defer tmpFile.Close() + + if err = writeFunc(tmpFile); err != nil { + seelog.Errorf("Something went wrong trying to write to tmpFile %s", tmpFile.Name()) + return err + } + + err = tmpFile.Close() + if err != nil { + seelog.Errorf("Error while closing temporary file %s created for envfile resource", tmpFile.Name(), err) + return err + } + + err = envfile.os.Rename(tmpFile.Name(), fullPathName) + if err != nil { + seelog.Errorf("Something went wrong when trying to rename envfile from %s to %s", tmpFile.Name(), fullPathName) + return err + } + + return nil +} + +// Cleanup removes env file directory for the task +func (envfile *EnvironmentFileResource) Cleanup() error { + err := envfile.os.RemoveAll(envfile.resourceDir) + if err != nil { + return fmt.Errorf("unable to remove envfile resource directory %s: %v", envfile.resourceDir, err) + } + + seelog.Infof("Removed envfile resource directory at %s", envfile.resourceDir) + return nil +} + +type environmentFileResourceJSON struct { + TaskARN string `json:"taskARN"` + ContainerName string `json:"containerName"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + DesiredStatus *EnvironmentFileStatus `json:"desiredStatus"` + KnownStatus *EnvironmentFileStatus `json:"knownStatus"` + EnvironmentFilesSource []apicontainer.EnvironmentFile `json:"environmentFilesSource"` + ExecutionCredentialsID string `json:"executionCredentialsID"` +} + +// MarshalJSON serializes the EnvironmentFileResource struct to JSON +func (envfile *EnvironmentFileResource) MarshalJSON() ([]byte, error) { + if envfile == nil { + return nil, errors.New("envfile resource is nil") + } + createdAt := envfile.GetCreatedAt() + return json.Marshal(environmentFileResourceJSON{ + TaskARN: envfile.taskARN, + ContainerName: envfile.containerName, + CreatedAt: &createdAt, + DesiredStatus: func() *EnvironmentFileStatus { + desiredState := envfile.GetDesiredStatus() + envfileStatus := EnvironmentFileStatus(desiredState) + return &envfileStatus + }(), + KnownStatus: func() *EnvironmentFileStatus { + knownState := envfile.GetKnownStatus() + envfileStatus := EnvironmentFileStatus(knownState) + return &envfileStatus + }(), + EnvironmentFilesSource: envfile.environmentFilesSource, + ExecutionCredentialsID: envfile.executionCredentialsID, + }) + +} + +// UnmarshalJSON deserializes the raw JSON to an EnvironmentFileResource struct +func (envfile *EnvironmentFileResource) UnmarshalJSON(b []byte) error { + envfileJson := environmentFileResourceJSON{} + + if err := json.Unmarshal(b, &envfileJson); err != nil { + return err + } + + if envfileJson.DesiredStatus != nil { + envfile.SetDesiredStatus(resourcestatus.ResourceStatus(*envfileJson.DesiredStatus)) + } + + if envfileJson.KnownStatus != nil { + envfile.SetKnownStatus(resourcestatus.ResourceStatus(*envfileJson.KnownStatus)) + } + + if envfileJson.CreatedAt != nil && !envfileJson.CreatedAt.IsZero() { + envfile.SetCreatedAt(*envfileJson.CreatedAt) + } + + if envfileJson.EnvironmentFilesSource != nil { + envfile.environmentFilesSource = envfileJson.EnvironmentFilesSource + } + + envfile.taskARN = envfileJson.TaskARN + envfile.containerName = envfileJson.ContainerName + envfile.executionCredentialsID = envfileJson.ExecutionCredentialsID + + return nil +} + +// GetContainerName returns the container that this resource is created for +func (envfile *EnvironmentFileResource) GetContainerName() string { + return envfile.containerName +} + +// this method converts EnvironmentFile objects into the path that it would've been downloaded at +// and returns the list +func (envfile *EnvironmentFileResource) convertEnvfileToPath() ([]string, error) { + var envfileLocations []string + + for _, envfileObj := range envfile.environmentFilesSource { + bucket, key, err := s3.ParseS3ARN(envfileObj.Value) + if err != nil { + seelog.Errorf("unable to parse bucket and key from s3 ARN specified in environmentFile %s", envfileObj.Value) + return nil, err + } + + downloadPath := filepath.Join(envfile.resourceDir, bucket, key) + envfileLocations = append(envfileLocations, downloadPath) + } + + return envfileLocations, nil +} + +// ReadEnvVarsFromEnvFiles reads the environment files that have been downloaded +// and puts them into a list of maps +func (envfile *EnvironmentFileResource) ReadEnvVarsFromEnvfiles() ([]map[string]string, error) { + var envVarsPerEnvfile []map[string]string + envfileLocations, err := envfile.convertEnvfileToPath() + if err != nil { + return nil, err + } + + for _, envfilePath := range envfileLocations { + envVars, err := envfile.readEnvVarsFromFile(envfilePath) + if err != nil { + return nil, err + } + envVarsPerEnvfile = append(envVarsPerEnvfile, envVars) + } + + return envVarsPerEnvfile, nil +} + +func (envfile *EnvironmentFileResource) readEnvVarsFromFile(envfilePath string) (map[string]string, error) { + file, err := envfile.os.Open(envfilePath) + if err != nil { + seelog.Errorf("Unable to open environment file at %s to read the variables", envfilePath) + return nil, err + } + defer file.Close() + + scanner := envfile.bufio.NewScanner(file) + envVars := make(map[string]string) + lineNum := 0 + for scanner.Scan() { + lineNum += 1 + line := scanner.Text() + // if line starts with a #, ignore + if strings.HasPrefix(line, commentIndicator) { + continue + } + // only read the line that has "=" + if strings.Contains(line, envVariableDelimiter) { + variables := strings.Split(line, "=") + // verify that there is at least a character on each side + if len(variables[0]) > 0 && len(variables[1]) > 0 { + envVars[variables[0]] = variables[1] + } else { + seelog.Infof("Not applying line %d of environment file %s, key or value is empty.", lineNum, envfilePath) + } + } + } + + if err := scanner.Err(); err != nil { + seelog.Errorf("Something went wrong trying to read environment file at %s", envfilePath) + return nil, err + } + + return envVars, nil +} + +// GetAppliedStatus safely returns the currently applied status of the resource +func (envfile *EnvironmentFileResource) GetAppliedStatus() resourcestatus.ResourceStatus { + envfile.lock.RLock() + defer envfile.lock.RUnlock() + + return envfile.appliedStatusUnsafe +} + +// DependOnTaskNetwork shows whether the resource creation needs task network setup beforehand +func (envfile *EnvironmentFileResource) DependOnTaskNetwork() bool { + return false +} + +// BuildContainerDependency adds a new dependency container and its satisfied status +func (envfile *EnvironmentFileResource) BuildContainerDependency(containerName string, satisfied apicontainerstatus.ContainerStatus, + dependent resourcestatus.ResourceStatus) { +} + +// GetContainerDependencies returns dependent containers for a status +func (envfile *EnvironmentFileResource) GetContainerDependencies(dependent resourcestatus.ResourceStatus) []apicontainer.ContainerDependency { + return nil +} diff --git a/agent/taskresource/envFiles/envfile_test.go b/agent/taskresource/envFiles/envfile_test.go new file mode 100644 index 00000000000..cd062f7f6b0 --- /dev/null +++ b/agent/taskresource/envFiles/envfile_test.go @@ -0,0 +1,422 @@ +// +build unit + +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package envFiles + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/aws/amazon-ecs-agent/agent/api/container" + "github.com/aws/amazon-ecs-agent/agent/api/task/status" + "github.com/aws/amazon-ecs-agent/agent/credentials" + mock_credentials "github.com/aws/amazon-ecs-agent/agent/credentials/mocks" + mock_factory "github.com/aws/amazon-ecs-agent/agent/s3/factory/mocks" + mock_s3 "github.com/aws/amazon-ecs-agent/agent/s3/mocks" + "github.com/aws/amazon-ecs-agent/agent/taskresource" + mock_bufio "github.com/aws/amazon-ecs-agent/agent/utils/bufiowrapper/mocks" + mock_ioutilwrapper "github.com/aws/amazon-ecs-agent/agent/utils/ioutilwrapper/mocks" + mock_oswrapper "github.com/aws/amazon-ecs-agent/agent/utils/oswrapper/mocks" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +const ( + executionCredentialsID = "exec-creds-id" + region = "us-west-2" + cluster = "testCluster" + taskARN = "arn:aws:ecs:us-east-2:01234567891011:task/testCluster/abcdef12-34gh-idkl-mno5-pqrst6789" + resourceDir = "resourceDir" + iamRoleARN = "iamRoleARN" + accessKeyId = "accessKey" + secretAccessKey = "secret" + s3Bucket = "s3Bucket" + s3Path = "path" + string(filepath.Separator) + "to" + string(filepath.Separator) + "envfile" + s3File = "s3key.env" + s3Key = s3Path + string(filepath.Separator) + s3File + tempFile = "tmp_file" +) + +func setup(t *testing.T) (*mock_oswrapper.MockOS, *mock_oswrapper.MockFile, *mock_ioutilwrapper.MockIOUtil, + *mock_credentials.MockManager, *mock_factory.MockS3ClientCreator, *mock_s3.MockS3Client, func()) { + ctrl := gomock.NewController(t) + + mockOS := mock_oswrapper.NewMockOS(ctrl) + mockFile := mock_oswrapper.NewMockFile(ctrl) + mockIOUtil := mock_ioutilwrapper.NewMockIOUtil(ctrl) + mockCredentialsManager := mock_credentials.NewMockManager(ctrl) + mockS3ClientCreator := mock_factory.NewMockS3ClientCreator(ctrl) + mockS3Client := mock_s3.NewMockS3Client(ctrl) + + return mockOS, mockFile, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, mockS3Client, ctrl.Finish +} + +func newMockEnvfileResource(envfileLocations []container.EnvironmentFile, mockCredentialsManager *mock_credentials.MockManager, + mockS3ClientCreator *mock_factory.MockS3ClientCreator, + mockOs *mock_oswrapper.MockOS, mockIOUtil *mock_ioutilwrapper.MockIOUtil) *EnvironmentFileResource { + return &EnvironmentFileResource{ + cluster: cluster, + taskARN: taskARN, + region: region, + resourceDir: resourceDir, + environmentFilesSource: envfileLocations, + executionCredentialsID: executionCredentialsID, + credentialsManager: mockCredentialsManager, + s3ClientCreator: mockS3ClientCreator, + os: mockOs, + ioutil: mockIOUtil, + } +} + +func sampleEnvironmentFile(value, envfileType string) container.EnvironmentFile { + return container.EnvironmentFile{ + Value: value, + Type: envfileType, + } +} + +func TestInitializeFileEnvResource(t *testing.T) { + _, _, _, mockCredentialsManager, _, _, done := setup(t) + defer done() + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, nil, nil, nil) + envfileResource.Initialize(&taskresource.ResourceFields{ + ResourceFieldsCommon: &taskresource.ResourceFieldsCommon{ + CredentialsManager: mockCredentialsManager, + }, + }, status.TaskRunning, status.TaskRunning) + + assert.NotNil(t, envfileResource.statusToTransitions) + assert.Equal(t, 1, len(envfileResource.statusToTransitions)) + assert.NotNil(t, envfileResource.credentialsManager) + assert.NotNil(t, envfileResource.s3ClientCreator) + assert.NotNil(t, envfileResource.os) + assert.NotNil(t, envfileResource.ioutil) +} + +func TestCreateWithEnvVarFile(t *testing.T) { + mockOS, mockFile, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, mockS3Client, done := setup(t) + defer done() + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + creds := credentials.TaskIAMRoleCredentials{ + ARN: iamRoleARN, + IAMRoleCredentials: credentials.IAMRoleCredentials{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + }, + } + + envDirectories := filepath.Join(resourceDir, s3Bucket, s3Path) + gomock.InOrder( + mockCredentialsManager.EXPECT().GetTaskCredentials(executionCredentialsID).Return(creds, true), + mockS3ClientCreator.EXPECT().NewS3ClientForBucket(s3Bucket, region, creds.IAMRoleCredentials).Return(mockS3Client, nil), + // write the env file downloaded from s3 + mockOS.EXPECT().MkdirAll(envDirectories, os.ModePerm), + mockIOUtil.EXPECT().TempFile(resourceDir, gomock.Any()).Return(mockFile, nil), + mockS3Client.EXPECT().DownloadWithContext(gomock.Any(), mockFile, gomock.Any()).Do( + func(ctx aws.Context, w io.WriterAt, input *s3.GetObjectInput) { + assert.Equal(t, s3Bucket, aws.StringValue(input.Bucket)) + assert.Equal(t, s3Key, aws.StringValue(input.Key)) + }).Return(int64(0), nil), + mockFile.EXPECT().Close(), + mockFile.EXPECT().Name().Return(tempFile), + mockOS.EXPECT().Rename(tempFile, filepath.Join(resourceDir, s3Bucket, s3Key)), + mockFile.EXPECT().Close(), + ) + + assert.NoError(t, envfileResource.Create()) +} + +func TestCreateWithInvalidS3ARN(t *testing.T) { + mockOS, _, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, _, done := setup(t) + defer done() + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s", s3File), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + creds := credentials.TaskIAMRoleCredentials{ + ARN: iamRoleARN, + IAMRoleCredentials: credentials.IAMRoleCredentials{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + }, + } + + mockCredentialsManager.EXPECT().GetTaskCredentials(executionCredentialsID).Return(creds, true) + + assert.Error(t, envfileResource.Create()) + assert.NotEmpty(t, envfileResource.terminalReasonUnsafe) + assert.Contains(t, envfileResource.GetTerminalReason(), "unable to parse bucket and key from s3 ARN specified in environmentFile") +} + +func TestCreateUnableToRetrieveDataFromS3(t *testing.T) { + mockOS, mockFile, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, mockS3Client, done := setup(t) + defer done() + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + creds := credentials.TaskIAMRoleCredentials{ + ARN: iamRoleARN, + IAMRoleCredentials: credentials.IAMRoleCredentials{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + }, + } + + envDirectories := filepath.Join(resourceDir, s3Bucket, s3Path) + gomock.InOrder( + mockCredentialsManager.EXPECT().GetTaskCredentials(executionCredentialsID).Return(creds, true), + mockS3ClientCreator.EXPECT().NewS3ClientForBucket(s3Bucket, region, creds.IAMRoleCredentials).Return(mockS3Client, nil), + mockOS.EXPECT().MkdirAll(envDirectories, os.ModePerm), + mockIOUtil.EXPECT().TempFile(resourceDir, gomock.Any()).Return(mockFile, nil), + mockS3Client.EXPECT().DownloadWithContext(gomock.Any(), mockFile, gomock.Any()).Return(int64(0), errors.New("error response")), + mockFile.EXPECT().Name().Return(tempFile), + mockFile.EXPECT().Close(), + ) + + assert.Error(t, envfileResource.Create()) + assert.NotEmpty(t, envfileResource.terminalReasonUnsafe) + assert.Contains(t, envfileResource.GetTerminalReason(), "error response") +} + +func TestCreateUnableToCreateTmpFile(t *testing.T) { + mockOS, _, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, mockS3Client, done := setup(t) + defer done() + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + creds := credentials.TaskIAMRoleCredentials{ + ARN: iamRoleARN, + IAMRoleCredentials: credentials.IAMRoleCredentials{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + }, + } + + envDirectories := filepath.Join(resourceDir, s3Bucket, s3Path) + gomock.InOrder( + mockCredentialsManager.EXPECT().GetTaskCredentials(executionCredentialsID).Return(creds, true), + mockS3ClientCreator.EXPECT().NewS3ClientForBucket(s3Bucket, region, creds.IAMRoleCredentials).Return(mockS3Client, nil), + mockOS.EXPECT().MkdirAll(envDirectories, os.ModePerm), + mockIOUtil.EXPECT().TempFile(resourceDir, gomock.Any()).Return(nil, errors.New("error response")), + ) + + assert.Error(t, envfileResource.Create()) + assert.NotEmpty(t, envfileResource.terminalReasonUnsafe) + assert.Contains(t, envfileResource.GetTerminalReason(), "error response") +} + +func TestCreateRenameFileError(t *testing.T) { + mockOS, mockFile, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, mockS3Client, done := setup(t) + defer done() + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + creds := credentials.TaskIAMRoleCredentials{ + ARN: iamRoleARN, + IAMRoleCredentials: credentials.IAMRoleCredentials{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + }, + } + + envDirectories := filepath.Join(resourceDir, s3Bucket, s3Path) + gomock.InOrder( + mockCredentialsManager.EXPECT().GetTaskCredentials(executionCredentialsID).Return(creds, true), + mockS3ClientCreator.EXPECT().NewS3ClientForBucket(s3Bucket, region, creds.IAMRoleCredentials).Return(mockS3Client, nil), + mockOS.EXPECT().MkdirAll(envDirectories, os.ModePerm), + mockIOUtil.EXPECT().TempFile(resourceDir, gomock.Any()).Return(mockFile, nil), + mockS3Client.EXPECT().DownloadWithContext(gomock.Any(), mockFile, gomock.Any()).Return(int64(0), nil), + mockFile.EXPECT().Close(), + mockFile.EXPECT().Name().Return(tempFile), + mockOS.EXPECT().Rename(tempFile, filepath.Join(resourceDir, s3Bucket, s3Key)).Return(errors.New("error response")), + mockFile.EXPECT().Name().Return(tempFile), // this is for the call made in the logging statement + mockFile.EXPECT().Close(), + ) + + assert.Error(t, envfileResource.Create()) + assert.NotEmpty(t, envfileResource.terminalReasonUnsafe) + assert.Contains(t, envfileResource.GetTerminalReason(), "error response") +} + +func TestEnvFileCleanupSuccess(t *testing.T) { + mockOS, _, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, _, done := setup(t) + defer done() + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + + mockOS.EXPECT().RemoveAll(resourceDir).Return(nil) + + assert.NoError(t, envfileResource.Cleanup()) +} + +func TestEnvFileCleanupResourceDirRemoveFail(t *testing.T) { + mockOS, _, mockIOUtil, mockCredentialsManager, mockS3ClientCreator, _, done := setup(t) + defer done() + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + envfileResource := newMockEnvfileResource(envfiles, mockCredentialsManager, mockS3ClientCreator, mockOS, mockIOUtil) + + mockOS.EXPECT().RemoveAll(resourceDir).Return(errors.New("error response")) + + assert.Error(t, envfileResource.Cleanup()) +} + +func TestReadEnvVarsFromEnvfiles(t *testing.T) { + mockOS, mockFile, mockIOUtil, _, _, _, done := setup(t) + defer done() + + ctrl := gomock.NewController(t) + mockBufio := mock_bufio.NewMockBufio(ctrl) + mockScanner := mock_bufio.NewMockScanner(ctrl) + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + downloadedEnvfilePath := filepath.Join(resourceDir, s3Bucket, s3Key) + envfileResource := newMockEnvfileResource(envfiles, nil, nil, mockOS, mockIOUtil) + envfileResource.bufio = mockBufio + + envfileContent := "key=value" + gomock.InOrder( + mockOS.EXPECT().Open(downloadedEnvfilePath).Return(mockFile, nil), + mockBufio.EXPECT().NewScanner(mockFile).Return(mockScanner), + mockScanner.EXPECT().Scan().Return(true), + mockScanner.EXPECT().Text().Return(envfileContent), + mockScanner.EXPECT().Scan().Return(false), + mockScanner.EXPECT().Err().Return(nil), + mockFile.EXPECT().Close(), + ) + + envVarsList, err := envfileResource.ReadEnvVarsFromEnvfiles() + + assert.Nil(t, err) + assert.Equal(t, 1, len(envVarsList)) + assert.Equal(t, "value", envVarsList[0]["key"]) +} + +func TestReadEnvVarsCommentFromEnvfiles(t *testing.T) { + mockOS, mockFile, mockIOUtil, _, _, _, done := setup(t) + defer done() + + ctrl := gomock.NewController(t) + mockBufio := mock_bufio.NewMockBufio(ctrl) + mockScanner := mock_bufio.NewMockScanner(ctrl) + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + downloadedEnvfilePath := filepath.Join(resourceDir, s3Bucket, s3Key) + envfileResource := newMockEnvfileResource(envfiles, nil, nil, mockOS, mockIOUtil) + envfileResource.bufio = mockBufio + + envfileContentComment := "# some comment here" + gomock.InOrder( + mockOS.EXPECT().Open(downloadedEnvfilePath).Return(mockFile, nil), + mockBufio.EXPECT().NewScanner(mockFile).Return(mockScanner), + mockScanner.EXPECT().Scan().Return(true), + mockScanner.EXPECT().Text().Return(envfileContentComment), + mockScanner.EXPECT().Scan().Return(false), + mockScanner.EXPECT().Err().Return(nil), + mockFile.EXPECT().Close(), + ) + + envVarsList, err := envfileResource.ReadEnvVarsFromEnvfiles() + + assert.Nil(t, err) + assert.Equal(t, 0, len(envVarsList[0])) +} + +func TestReadEnvVarsInvalidFromEnvfiles(t *testing.T) { + mockOS, mockFile, mockIOUtil, _, _, _, done := setup(t) + defer done() + + ctrl := gomock.NewController(t) + mockBufio := mock_bufio.NewMockBufio(ctrl) + mockScanner := mock_bufio.NewMockScanner(ctrl) + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + downloadedEnvfilePath := filepath.Join(resourceDir, s3Bucket, s3Key) + envfileResource := newMockEnvfileResource(envfiles, nil, nil, mockOS, mockIOUtil) + envfileResource.bufio = mockBufio + + envfileContentInvalid := "=value" + gomock.InOrder( + mockOS.EXPECT().Open(downloadedEnvfilePath).Return(mockFile, nil), + mockBufio.EXPECT().NewScanner(mockFile).Return(mockScanner), + mockScanner.EXPECT().Scan().Return(true), + mockScanner.EXPECT().Text().Return(envfileContentInvalid), + mockScanner.EXPECT().Scan().Return(false), + mockScanner.EXPECT().Err().Return(nil), + mockFile.EXPECT().Close(), + ) + + envVarsList, err := envfileResource.ReadEnvVarsFromEnvfiles() + + assert.Nil(t, err) + assert.Equal(t, 0, len(envVarsList[0])) +} + +func TestReadEnvVarsUnableToReadEnvfile(t *testing.T) { + mockOS, _, mockIOUtil, _, _, _, done := setup(t) + defer done() + + envfiles := []container.EnvironmentFile{ + sampleEnvironmentFile(fmt.Sprintf("arn:aws:s3:::%s/%s", s3Bucket, s3Key), "s3"), + } + + downloadedEnvfilePath := filepath.Join(resourceDir, s3Bucket, s3Key) + envfileResource := newMockEnvfileResource(envfiles, nil, nil, mockOS, mockIOUtil) + + mockOS.EXPECT().Open(downloadedEnvfilePath).Return(nil, errors.New("error response")) + + _, err := envfileResource.ReadEnvVarsFromEnvfiles() + + assert.NotNil(t, err) +} diff --git a/agent/taskresource/envFiles/envfilestatus.go b/agent/taskresource/envFiles/envfilestatus.go new file mode 100644 index 00000000000..b154fb85fa5 --- /dev/null +++ b/agent/taskresource/envFiles/envfilestatus.go @@ -0,0 +1,77 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package envFiles + +import ( + "errors" + "strings" + + resourcestatus "github.com/aws/amazon-ecs-agent/agent/taskresource/status" +) + +type EnvironmentFileStatus resourcestatus.ResourceStatus + +const ( + // EnvFileStatusNone is the zero state of a task resource + EnvFileStatusNone EnvironmentFileStatus = iota + // EnvFileCreated means the task resource is created + EnvFileCreated + // EnvFileRemoved means the task resource is cleaned up + EnvFileRemoved +) + +var envfileStatusMap = map[string]EnvironmentFileStatus{ + "NONE": EnvFileStatusNone, + "CREATED": EnvFileCreated, + "REMOVED": EnvFileRemoved, +} + +func (envfileStatus EnvironmentFileStatus) String() string { + for k, v := range envfileStatusMap { + if v == envfileStatus { + return k + } + } + return "NONE" +} + +// MarshalJSON overrides the logic for JSON-encoding the ResourceStatus type. +func (envfileStatus *EnvironmentFileStatus) MarshalJSON() ([]byte, error) { + if envfileStatus == nil { + return nil, errors.New("envfile resource status is nil") + } + return []byte(`"` + envfileStatus.String() + `"`), nil +} + +// UnmarshalJSON overrides the logic for parsing the JSON-encoded ResourceStatus data. +func (envfileStatus *EnvironmentFileStatus) UnmarshalJSON(b []byte) error { + if strings.ToLower(string(b)) == "null" { + *envfileStatus = EnvFileStatusNone + return nil + } + + if b[0] != '"' || b[len(b)-1] != '"' { + *envfileStatus = EnvFileStatusNone + return errors.New("resource status unmarshal: status must be a string or null; Got " + string(b)) + } + + strStatus := string(b[1 : len(b)-1]) + stat, ok := envfileStatusMap[strStatus] + if !ok { + *envfileStatus = EnvFileStatusNone + return errors.New("resource status unmarshal: unrecognized status") + } + *envfileStatus = stat + return nil +} diff --git a/agent/taskresource/envFiles/envfilestatus_test.go b/agent/taskresource/envFiles/envfilestatus_test.go new file mode 100644 index 00000000000..fcac4dbf285 --- /dev/null +++ b/agent/taskresource/envFiles/envfilestatus_test.go @@ -0,0 +1,155 @@ +// +build unit + +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package envFiles + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatusString(t *testing.T) { + cases := []struct { + Name string + InEnvFileStatus EnvironmentFileStatus + OutEnvFileStatus string + }{ + { + Name: "ToStringEnvFileStatusNone", + InEnvFileStatus: EnvFileStatusNone, + OutEnvFileStatus: "NONE", + }, + { + Name: "ToStringEnvFileCreated", + InEnvFileStatus: EnvFileCreated, + OutEnvFileStatus: "CREATED", + }, + { + Name: "ToStringEnvFileRemoved", + InEnvFileStatus: EnvFileRemoved, + OutEnvFileStatus: "REMOVED", + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + assert.Equal(t, c.OutEnvFileStatus, c.InEnvFileStatus.String()) + }) + } + +} + +func TestMarshalNilEnvFileStatus(t *testing.T) { + var status *EnvironmentFileStatus + bytes, err := status.MarshalJSON() + + assert.Nil(t, bytes) + assert.Error(t, err) +} + +func TestMarshalEnvFileStatus(t *testing.T) { + cases := []struct { + Name string + InEnvFileStatus EnvironmentFileStatus + OutEnvFileStatus string + }{ + { + Name: "MarshallEnvFileStatusNone", + InEnvFileStatus: EnvFileStatusNone, + OutEnvFileStatus: "\"NONE\"", + }, + { + Name: "MarshallEnvFileCreated", + InEnvFileStatus: EnvFileCreated, + OutEnvFileStatus: "\"CREATED\"", + }, + { + Name: "MarshallEnvFileRemoved", + InEnvFileStatus: EnvFileRemoved, + OutEnvFileStatus: "\"REMOVED\"", + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + bytes, err := c.InEnvFileStatus.MarshalJSON() + + assert.NoError(t, err) + assert.Equal(t, c.OutEnvFileStatus, string(bytes[:])) + }) + } +} + +func TestUnmarshalEnvFileStatus(t *testing.T) { + cases := []struct { + Name string + InEnvFileStatus string + OutEnvFileStatus EnvironmentFileStatus + ShouldError bool + }{ + { + Name: "UnmarshallEnvFileStatusNone", + InEnvFileStatus: "\"NONE\"", + OutEnvFileStatus: EnvFileStatusNone, + ShouldError: false, + }, + { + Name: "UnmarshallEnvFileCreated", + InEnvFileStatus: "\"CREATED\"", + OutEnvFileStatus: EnvFileCreated, + ShouldError: false, + }, + { + Name: "UnmarshallEnvFileRemoved", + InEnvFileStatus: "\"REMOVED\"", + OutEnvFileStatus: EnvFileRemoved, + ShouldError: false, + }, + { + Name: "UnmarshallEnvFileStatusNull", + InEnvFileStatus: "null", + OutEnvFileStatus: EnvFileStatusNone, + ShouldError: false, + }, + { + Name: "UnmarshallEnvFileStatusNonString", + InEnvFileStatus: "123", + OutEnvFileStatus: EnvFileStatusNone, + ShouldError: true, + }, + { + Name: "UnmarshallEnvFileStatusUnmappedStatus", + InEnvFileStatus: "\"WEEWOO\"", + OutEnvFileStatus: EnvFileStatusNone, + ShouldError: true, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + var status EnvironmentFileStatus + err := json.Unmarshal([]byte(c.InEnvFileStatus), &status) + + if c.ShouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, c.OutEnvFileStatus, status) + } + }) + } +} diff --git a/agent/taskresource/types/types.go b/agent/taskresource/types/types.go index 7cd523278c0..7b6c6223d41 100644 --- a/agent/taskresource/types/types.go +++ b/agent/taskresource/types/types.go @@ -22,6 +22,7 @@ import ( asmsecretres "github.com/aws/amazon-ecs-agent/agent/taskresource/asmsecret" cgroupres "github.com/aws/amazon-ecs-agent/agent/taskresource/cgroup" "github.com/aws/amazon-ecs-agent/agent/taskresource/credentialspec" + "github.com/aws/amazon-ecs-agent/agent/taskresource/envFiles" "github.com/aws/amazon-ecs-agent/agent/taskresource/firelens" ssmsecretres "github.com/aws/amazon-ecs-agent/agent/taskresource/ssmsecret" "github.com/aws/amazon-ecs-agent/agent/taskresource/volume" @@ -42,6 +43,8 @@ const ( FirelensKey = firelens.ResourceName // CredentialSpecKey is the string used in resources map to represent credentialspec resource CredentialSpecKey = credentialspec.ResourceName + //EnvironmentFilesKey is the string used in resources map to represent environmentfiles resource + EnvironmentFilesKey = envFiles.ResourceName ) // ResourcesMap represents the map of resource type to the corresponding resource @@ -81,6 +84,8 @@ func unmarshalResource(key string, value json.RawMessage, result map[string][]ta return unmarshalFirelensKey(key, value, result) case CredentialSpecKey: return unmarshalCredentialSpecKey(key, value, result) + case EnvironmentFilesKey: + return unmarshalEnvironmentFilesKey(key, value, result) default: return errors.New("Unsupported resource type") } @@ -210,3 +215,21 @@ func unmarshalCredentialSpecKey(key string, value json.RawMessage, result map[st } return nil } + +func unmarshalEnvironmentFilesKey(key string, value json.RawMessage, result map[string][]taskresource.TaskResource) error { + var environmentFiles []json.RawMessage + err := json.Unmarshal(value, &environmentFiles) + if err != nil { + return err + } + + for _, environmentFile := range environmentFiles { + res := &envFiles.EnvironmentFileResource{} + err := res.UnmarshalJSON(environmentFile) + if err != nil { + return err + } + result[key] = append(result[key], res) + } + return nil +} diff --git a/agent/utils/bufiowrapper/bufio.go b/agent/utils/bufiowrapper/bufio.go new file mode 100644 index 00000000000..4c42d61b041 --- /dev/null +++ b/agent/utils/bufiowrapper/bufio.go @@ -0,0 +1,43 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package bufiowrapper + +import ( + "bufio" + + "io" +) + +// Bufio wraps method from bufio package for testing +type Bufio interface { + NewScanner(io.Reader) Scanner +} + +// Scanner wraps methods for bufio.Scanner type +type Scanner interface { + Scan() bool + Text() string + Err() error +} + +type _bufio struct { +} + +func NewBufio() Bufio { + return &_bufio{} +} + +func (*_bufio) NewScanner(reader io.Reader) Scanner { + return bufio.NewScanner(reader) +} diff --git a/agent/utils/bufiowrapper/generate_mocks.go b/agent/utils/bufiowrapper/generate_mocks.go new file mode 100644 index 00000000000..85e8e74be33 --- /dev/null +++ b/agent/utils/bufiowrapper/generate_mocks.go @@ -0,0 +1,16 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package bufiowrapper + +//go:generate mockgen -copyright_file=../../../scripts/copyright_file -destination=mocks/bufiowrapper_mocks.go github.com/aws/amazon-ecs-agent/agent/utils/bufiowrapper Bufio,Scanner diff --git a/agent/utils/bufiowrapper/mocks/bufiowrapper_mocks.go b/agent/utils/bufiowrapper/mocks/bufiowrapper_mocks.go new file mode 100644 index 00000000000..cb042ba635c --- /dev/null +++ b/agent/utils/bufiowrapper/mocks/bufiowrapper_mocks.go @@ -0,0 +1,129 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/aws/amazon-ecs-agent/agent/utils/bufiowrapper (interfaces: Bufio,Scanner) + +// Package mock_bufiowrapper is a generated GoMock package. +package mock_bufiowrapper + +import ( + io "io" + reflect "reflect" + + bufiowrapper "github.com/aws/amazon-ecs-agent/agent/utils/bufiowrapper" + gomock "github.com/golang/mock/gomock" +) + +// MockBufio is a mock of Bufio interface +type MockBufio struct { + ctrl *gomock.Controller + recorder *MockBufioMockRecorder +} + +// MockBufioMockRecorder is the mock recorder for MockBufio +type MockBufioMockRecorder struct { + mock *MockBufio +} + +// NewMockBufio creates a new mock instance +func NewMockBufio(ctrl *gomock.Controller) *MockBufio { + mock := &MockBufio{ctrl: ctrl} + mock.recorder = &MockBufioMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockBufio) EXPECT() *MockBufioMockRecorder { + return m.recorder +} + +// NewScanner mocks base method +func (m *MockBufio) NewScanner(arg0 io.Reader) bufiowrapper.Scanner { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewScanner", arg0) + ret0, _ := ret[0].(bufiowrapper.Scanner) + return ret0 +} + +// NewScanner indicates an expected call of NewScanner +func (mr *MockBufioMockRecorder) NewScanner(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewScanner", reflect.TypeOf((*MockBufio)(nil).NewScanner), arg0) +} + +// MockScanner is a mock of Scanner interface +type MockScanner struct { + ctrl *gomock.Controller + recorder *MockScannerMockRecorder +} + +// MockScannerMockRecorder is the mock recorder for MockScanner +type MockScannerMockRecorder struct { + mock *MockScanner +} + +// NewMockScanner creates a new mock instance +func NewMockScanner(ctrl *gomock.Controller) *MockScanner { + mock := &MockScanner{ctrl: ctrl} + mock.recorder = &MockScannerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockScanner) EXPECT() *MockScannerMockRecorder { + return m.recorder +} + +// Err mocks base method +func (m *MockScanner) Err() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Err") + ret0, _ := ret[0].(error) + return ret0 +} + +// Err indicates an expected call of Err +func (mr *MockScannerMockRecorder) Err() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Err", reflect.TypeOf((*MockScanner)(nil).Err)) +} + +// Scan mocks base method +func (m *MockScanner) Scan() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Scan indicates an expected call of Scan +func (mr *MockScannerMockRecorder) Scan() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockScanner)(nil).Scan)) +} + +// Text mocks base method +func (m *MockScanner) Text() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Text") + ret0, _ := ret[0].(string) + return ret0 +} + +// Text indicates an expected call of Text +func (mr *MockScannerMockRecorder) Text() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Text", reflect.TypeOf((*MockScanner)(nil).Text)) +} diff --git a/agent/utils/oswrapper/mocks/oswrapper_mocks.go b/agent/utils/oswrapper/mocks/oswrapper_mocks.go index c6877cf6b63..4d2a270b2ac 100644 --- a/agent/utils/oswrapper/mocks/oswrapper_mocks.go +++ b/agent/utils/oswrapper/mocks/oswrapper_mocks.go @@ -91,6 +91,21 @@ func (mr *MockFileMockRecorder) Name() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockFile)(nil).Name)) } +// Read mocks base method +func (m *MockFile) Read(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read +func (mr *MockFileMockRecorder) Read(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockFile)(nil).Read), arg0) +} + // Sync mocks base method func (m *MockFile) Sync() error { m.ctrl.T.Helper() @@ -201,6 +216,21 @@ func (mr *MockOSMockRecorder) MkdirAll(arg0, arg1 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockOS)(nil).MkdirAll), arg0, arg1) } +// Open mocks base method +func (m *MockOS) Open(arg0 string) (oswrapper.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", arg0) + ret0, _ := ret[0].(oswrapper.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open +func (mr *MockOSMockRecorder) Open(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockOS)(nil).Open), arg0) +} + // OpenFile mocks base method func (m *MockOS) OpenFile(arg0 string, arg1 int, arg2 os.FileMode) (oswrapper.File, error) { m.ctrl.T.Helper() diff --git a/agent/utils/oswrapper/os.go b/agent/utils/oswrapper/os.go index 5dd87e6209c..3026d64d40b 100644 --- a/agent/utils/oswrapper/os.go +++ b/agent/utils/oswrapper/os.go @@ -24,6 +24,7 @@ type OS interface { Remove(string) error RemoveAll(string) error IsNotExist(error) bool + Open(string) (File, error) } // File wraps methods for os.File type @@ -34,6 +35,7 @@ type File interface { Write([]byte) (int, error) WriteAt(b []byte, off int64) (n int, err error) Sync() error + Read([]byte) (int, error) } type _os struct { @@ -70,3 +72,7 @@ func (*_os) RemoveAll(name string) error { func (*_os) IsNotExist(err error) bool { return os.IsNotExist(err) } + +func (*_os) Open(name string) (File, error) { + return os.Open(name) +}