diff --git a/docs/content/providers/ecs.md b/docs/content/providers/ecs.md index 282da12aa..31e5e324d 100644 --- a/docs/content/providers/ecs.md +++ b/docs/content/providers/ecs.md @@ -47,7 +47,8 @@ Traefik needs the following policy to read ECS information: "ecs:DescribeTasks", "ecs:DescribeContainerInstances", "ecs:DescribeTaskDefinition", - "ec2:DescribeInstances" + "ec2:DescribeInstances", + "ssm:DescribeInstanceInformation" ], "Resource": [ "*" @@ -57,6 +58,10 @@ Traefik needs the following policy to read ECS information: } ``` +!!! info "ECS Anywhere" + + Please note that the `ssm:DescribeInstanceInformation` action is required for ECS anywhere instances discovery. + ## Provider Configuration ### `autoDiscoverClusters` @@ -86,6 +91,33 @@ providers: # ... ``` +### `ecsAnywhere` + +_Optional, Default=false_ + +Enable ECS Anywhere support. + +- If set to `true` service discovery is enabled for ECS Anywhere instances. +- If set to `false` service discovery is disabled for ECS Anywhere instances. + +```yaml tab="File (YAML)" +providers: + ecs: + ecsAnywhere: true + # ... +``` + +```toml tab="File (TOML)" +[providers.ecs] + ecsAnywhere = true + # ... +``` + +```bash tab="CLI" +--providers.ecs.ecsAnywhere=true +# ... +``` + ### `clusters` _Optional, Default=["default"]_ diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 05cf9e89a..aa0638d08 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -573,6 +573,9 @@ Constraints is an expression that Traefik matches against the container's labels `--providers.ecs.defaultrule`: Default rule. (Default: ```Host(`{{ normalize .Name }}`)```) +`--providers.ecs.ecsanywhere`: +Enable ECS Anywhere support (Default: ```false```) + `--providers.ecs.exposedbydefault`: Expose services by default (Default: ```true```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index d808a5a90..b496ce97a 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -573,6 +573,9 @@ Constraints is an expression that Traefik matches against the container's labels `TRAEFIK_PROVIDERS_ECS_DEFAULTRULE`: Default rule. (Default: ```Host(`{{ normalize .Name }}`)```) +`TRAEFIK_PROVIDERS_ECS_ECSANYWHERE`: +Enable ECS Anywhere support (Default: ```false```) + `TRAEFIK_PROVIDERS_ECS_EXPOSEDBYDEFAULT`: Expose services by default (Default: ```true```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 5141798e9..3ad3d95c5 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -204,6 +204,7 @@ region = "foobar" accessKeyID = "foobar" secretAccessKey = "foobar" + ecsAnywhere = true [providers.consul] rootKey = "foobar" endpoints = ["foobar", "foobar"] diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index 6a03c22f2..dbe316d4a 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -220,6 +220,7 @@ providers: region: foobar accessKeyID: foobar secretAccessKey: foobar + ecsAnywhere: true consul: rootKey: foobar endpoints: diff --git a/pkg/provider/ecs/config.go b/pkg/provider/ecs/config.go index 02684a07f..1a525f15c 100644 --- a/pkg/provider/ecs/config.go +++ b/pkg/provider/ecs/config.go @@ -292,7 +292,7 @@ func (p *Provider) addServer(instance ecsInstance, loadBalancer *dynamic.Servers func (p *Provider) getIPPort(instance ecsInstance, serverPort string) (string, string, error) { var ip, port string - ip = p.getIPAddress(instance) + ip = instance.machine.privateIP port = getPort(instance, serverPort) if len(ip) == 0 { return "", "", fmt.Errorf("unable to find the IP address for the instance %q: the server is ignored", instance.Name) @@ -301,10 +301,6 @@ func (p *Provider) getIPPort(instance ecsInstance, serverPort string) (string, s return ip, port, nil } -func (p Provider) getIPAddress(instance ecsInstance) string { - return instance.machine.privateIP -} - func getPort(instance ecsInstance, serverPort string) string { if len(serverPort) > 0 { for _, port := range instance.machine.ports { diff --git a/pkg/provider/ecs/ecs.go b/pkg/provider/ecs/ecs.go index 0cde14508..1958723db 100644 --- a/pkg/provider/ecs/ecs.go +++ b/pkg/provider/ecs/ecs.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ssm" "github.com/cenkalti/backoff/v4" "github.com/patrickmn/go-cache" "github.com/traefik/traefik/v2/pkg/config/dynamic" @@ -33,6 +34,7 @@ type Provider struct { // Provider lookup parameters. Clusters []string `description:"ECS Clusters name" json:"clusters,omitempty" toml:"clusters,omitempty" yaml:"clusters,omitempty" export:"true"` AutoDiscoverClusters bool `description:"Auto discover cluster" json:"autoDiscoverClusters,omitempty" toml:"autoDiscoverClusters,omitempty" yaml:"autoDiscoverClusters,omitempty" export:"true"` + ECSAnywhere bool `description:"Enable ECS Anywhere support" json:"ecsAnywhere,omitempty" toml:"ecsAnywhere,omitempty" yaml:"ecsAnywhere,omitempty" export:"true"` Region string `description:"The AWS region to use for requests" json:"region,omitempty" toml:"region,omitempty" yaml:"region,omitempty" export:"true"` AccessKeyID string `description:"The AWS credentials access key to use for making requests" json:"accessKeyID,omitempty" toml:"accessKeyID,omitempty" yaml:"accessKeyID,omitempty" loggable:"false"` SecretAccessKey string `description:"The AWS credentials access key to use for making requests" json:"secretAccessKey,omitempty" toml:"secretAccessKey,omitempty" yaml:"secretAccessKey,omitempty" loggable:"false"` @@ -64,6 +66,7 @@ type machine struct { type awsClient struct { ecs *ecs.ECS ec2 *ec2.EC2 + ssm *ssm.SSM } // DefaultTemplateRule The default template for the default rule. @@ -139,11 +142,12 @@ func (p *Provider) createClient(logger log.Logger) (*awsClient, error) { return &awsClient{ ecs.New(sess, cfg), ec2.New(sess, cfg), + ssm.New(sess, cfg), }, nil } // Provide configuration to traefik from ECS. -func (p Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { +func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { pool.GoCtx(func(routineCtx context.Context) { ctxLog := log.With(routineCtx, log.Str(log.ProviderName, "ecs")) logger := log.FromContext(ctxLog) @@ -277,6 +281,15 @@ func (p *Provider) listInstances(ctx context.Context, client *awsClient) ([]ecsI return nil, err } + miInstances := make(map[string]*ssm.InstanceInformation) + if p.ECSAnywhere { + // Try looking up for instances on ECS Anywhere + miInstances, err = p.lookupMiInstances(ctx, client, &c, tasks) + if err != nil { + return nil, err + } + } + taskDefinitions, err := p.lookupTaskDefinitions(ctx, client, tasks) if err != nil { return nil, err @@ -324,7 +337,8 @@ func (p *Provider) listInstances(ctx context.Context, client *awsClient) ([]ecsI healthStatus: aws.StringValue(task.HealthStatus), } } else { - if containerInstance == nil { + miContainerInstance := miInstances[aws.StringValue(task.ContainerInstanceArn)] + if containerInstance == nil && miContainerInstance == nil { logger.Errorf("Unable to find container instance information for %s", aws.StringValue(container.Name)) continue } @@ -338,10 +352,19 @@ func (p *Provider) listInstances(ctx context.Context, client *awsClient) ([]ecsI }) } } + var privateIPAddress, stateName string + if containerInstance != nil { + privateIPAddress = aws.StringValue(containerInstance.PrivateIpAddress) + stateName = aws.StringValue(containerInstance.State.Name) + } else if miContainerInstance != nil { + privateIPAddress = aws.StringValue(miContainerInstance.IPAddress) + stateName = aws.StringValue(task.LastStatus) + } + mach = &machine{ - privateIP: aws.StringValue(containerInstance.PrivateIpAddress), + privateIP: privateIPAddress, ports: ports, - state: aws.StringValue(containerInstance.State.Name), + state: stateName, } } @@ -368,6 +391,72 @@ func (p *Provider) listInstances(ctx context.Context, client *awsClient) ([]ecsI return instances, nil } +func (p *Provider) lookupMiInstances(ctx context.Context, client *awsClient, clusterName *string, ecsDatas map[string]*ecs.Task) (map[string]*ssm.InstanceInformation, error) { + instanceIds := make(map[string]string) + miInstances := make(map[string]*ssm.InstanceInformation) + + var containerInstancesArns []*string + var instanceArns []*string + + for _, task := range ecsDatas { + if task.ContainerInstanceArn != nil { + containerInstancesArns = append(containerInstancesArns, task.ContainerInstanceArn) + } + } + + for _, arns := range p.chunkIDs(containerInstancesArns) { + resp, err := client.ecs.DescribeContainerInstancesWithContext(ctx, &ecs.DescribeContainerInstancesInput{ + ContainerInstances: arns, + Cluster: clusterName, + }) + if err != nil { + return nil, fmt.Errorf("describing container instances: %w", err) + } + + for _, container := range resp.ContainerInstances { + instanceIds[aws.StringValue(container.Ec2InstanceId)] = aws.StringValue(container.ContainerInstanceArn) + + // Disallow EC2 Instance IDs + // This prevents considering EC2 instances in ECS + // and getting InvalidInstanceID.Malformed error when calling the describe-instances endpoint. + if !strings.HasPrefix(aws.StringValue(container.Ec2InstanceId), "mi-") { + continue + } + + instanceArns = append(instanceArns, container.Ec2InstanceId) + } + } + + if len(instanceArns) > 0 { + for _, ids := range p.chunkIDs(instanceArns) { + input := &ssm.DescribeInstanceInformationInput{ + Filters: []*ssm.InstanceInformationStringFilter{ + { + Key: aws.String("InstanceIds"), + Values: ids, + }, + }, + } + + err := client.ssm.DescribeInstanceInformationPagesWithContext(ctx, input, func(page *ssm.DescribeInstanceInformationOutput, lastPage bool) bool { + if len(page.InstanceInformationList) > 0 { + for _, i := range page.InstanceInformationList { + if i.InstanceId != nil { + miInstances[instanceIds[aws.StringValue(i.InstanceId)]] = i + } + } + } + return !lastPage + }) + if err != nil { + return nil, fmt.Errorf("describing instances: %w", err) + } + } + } + + return miInstances, nil +} + func (p *Provider) lookupEc2Instances(ctx context.Context, client *awsClient, clusterName *string, ecsDatas map[string]*ecs.Task) (map[string]*ec2.Instance, error) { instanceIds := make(map[string]string) ec2Instances := make(map[string]*ec2.Instance) diff --git a/pkg/redactor/redactor_config_test.go b/pkg/redactor/redactor_config_test.go index 1009afba7..a3f7504aa 100644 --- a/pkg/redactor/redactor_config_test.go +++ b/pkg/redactor/redactor_config_test.go @@ -721,6 +721,7 @@ func TestDo_staticConfiguration(t *testing.T) { DefaultRule: "PathPrefix(`/`)", Clusters: []string{"Cluster1", "Cluster2"}, AutoDiscoverClusters: true, + ECSAnywhere: true, Region: "Awsregion", AccessKeyID: "AwsAccessKeyID", SecretAccessKey: "AwsSecretAccessKey", diff --git a/pkg/redactor/testdata/anonymized-static-config.json b/pkg/redactor/testdata/anonymized-static-config.json index ec41d040d..79ca15a67 100644 --- a/pkg/redactor/testdata/anonymized-static-config.json +++ b/pkg/redactor/testdata/anonymized-static-config.json @@ -223,6 +223,7 @@ "Cluster2" ], "autoDiscoverClusters": true, + "ecsAnywhere": true, "region": "Awsregion", "accessKeyID": "xxxx", "secretAccessKey": "xxxx" @@ -465,4 +466,4 @@ } } } -} \ No newline at end of file +}