Repository: traefik/traefik (language: go)

Issue:
Support ECS Anywhere
### Welcome!

- [X] Yes, I've searched similar issues on [GitHub](https://github.com/traefik/traefik/issues) and didn't find any.
- [X] Yes, I've searched similar issues on the [Traefik community forum](https://community.containo.us) and didn't find any.

### What did you expect to see?

We tried to setup Traefik with ECS anywhere in the local datacenter to manage the load-balancing and service discovery but it is not working when configuring as a service from ecs anywhere but configuring local on ECS external nodes is working. 


Discussion / hints:
I am literally working on showcasing that using ECS Compose-X (version 0.18+ will enable that very use-case).
Right now about to open an error but unsure if related.


Fix patch:
diff --git a/docs/content/providers/ecs.md b/docs/content/providers/ecs.md
index 282da12aaa..31e5e324db 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 05cf9e89ae..aa0638d085 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 d808a5a909..b496ce97ae 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 5141798e95..3ad3d95c59 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 6a03c22f29..dbe316d4ad 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 02684a07fc..1a525f15c8 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 0cde145086..1958723dba 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)


Test patch:
diff --git a/pkg/redactor/redactor_config_test.go b/pkg/redactor/redactor_config_test.go
index 1009afba78..a3f7504aae 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 ec41d040d3..79ca15a67f 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
+}