diff --git a/ecs/Dockerfile b/ecs/Dockerfile new file mode 100644 index 000000000..e5b506b96 --- /dev/null +++ b/ecs/Dockerfile @@ -0,0 +1,57 @@ +# syntax = docker/dockerfile:experimental +ARG GO_VERSION=1.14.4-alpine +ARG ALPINE_PKG_DOCKER_VERSION=19.03.12-r0 +ARG GOLANGCI_LINT_VERSION=v1.27.0-alpine + +FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS base +WORKDIR /ecs-plugin +ENV GO111MODULE=on +ARG ALPINE_PKG_DOCKER_VERSION +RUN apk add --no-cache \ + docker=${ALPINE_PKG_DOCKER_VERSION} \ + make \ + build-base +COPY go.* . +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +FROM base AS make-plugin +ARG TARGETOS +ARG TARGETARCH +ARG COMMIT +ARG TAG +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + GOOS=${TARGETOS} \ + GOARCH=${TARGETARCH} \ + make -f builder.Makefile build + +FROM base AS make-cross +ARG COMMIT +ARG TAG +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + make -f builder.Makefile cross + +FROM scratch AS build +COPY --from=make-plugin /ecs-plugin/dist/docker-ecs . + +FROM scratch AS cross +COPY --from=make-cross /ecs-plugin/dist/* . + +FROM make-plugin AS test +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + make -f builder.Makefile test + +FROM golangci/golangci-lint:${GOLANGCI_LINT_VERSION} AS lint-base + +FROM base AS lint +COPY --from=lint-base /usr/bin/golangci-lint /usr/bin/golangci-lint +RUN --mount=target=. \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + make -f builder.Makefile lint diff --git a/ecs/LICENSE b/ecs/LICENSE new file mode 100644 index 000000000..6d8d58fb6 --- /dev/null +++ b/ecs/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2013-2018 Docker, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License 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. diff --git a/ecs/Makefile b/ecs/Makefile new file mode 100644 index 000000000..109325861 --- /dev/null +++ b/ecs/Makefile @@ -0,0 +1,48 @@ +PLATFORM?=local +PWD=$(shell pwd) + +export DOCKER_BUILDKIT=1 + +COMMIT := $(shell git rev-parse --short HEAD) +TAG := $(shell git describe --tags --dirty --match "v*") + +.DEFAULT_GOAL := build + +build: ## Build for the current + @docker build . \ + --output ./dist \ + --platform ${PLATFORM} \ + --build-arg COMMIT=${COMMIT} \ + --build-arg TAG=${TAG} \ + --target build + +cross: ## Cross build for linux, macos and windows + @docker build . \ + --output ./dist \ + --build-arg COMMIT=${COMMIT} \ + --build-arg TAG=${TAG} \ + --target cross + +test: build ## Run tests + @docker build . \ + --build-arg COMMIT=${COMMIT} \ + --build-arg TAG=${TAG} \ + --target test + +e2e: build ## Run tests + go test ./... -v -tags=e2e + +dev: build + @mkdir -p ~/.docker/cli-plugins/ + ln -f -s "${PWD}/dist/docker-ecs" "${HOME}/.docker/cli-plugins/docker-ecs" + +lint: ## Verify Go files + @docker build . --target lint + +fmt: ## Format go files + go fmt ./... + +clean: + rm -rf dist/ + +.PHONY: clean build test dev lint e2e cross fmt diff --git a/ecs/README.md b/ecs/README.md new file mode 100644 index 000000000..5faeb696a --- /dev/null +++ b/ecs/README.md @@ -0,0 +1,110 @@ +# Docker CLI plugin for Amazon ECS + +This was announced at AWS Cloud Containers Conference 2020, read the +[blog post](https://www.docker.com/blog/from-docker-straight-to-aws/). + +## Status + +:exclamation: The Docker ECS plugin is still in Beta. +Its design and UX will evolve until 1.0 Final release. + +## Get started + +If you're using macOS or Windows you just need to install +Docker Desktop **Edge** and you will have the ECS integration installed. +You can find links here: +- [macOS](https://hub.docker.com/editions/community/docker-ce-desktop-mac) +- [Windows](https://hub.docker.com/editions/community/docker-ce-desktop-windows) + +Linux install instructions are [here](./docs/get-started-linux.md). + +## Example and documentation + +You can find an application for testing this in [example](./example). + +You can find more documentation about using the Docker ECS integration +[here](https://docs.docker.com/engine/context/ecs-integration/). + +## Architecture + +The Docker ECS integration is a +[Docker CLI plugin](https://docs.docker.com/engine/extend/cli_plugins/) +with the root command of `ecs`. +It requires an AWS profile to select the AWS API credentials from +`~/.aws/credentials` as well as an AWS region. You can read more about CLI AWS +credential management +[here](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). +Once setup, the AWS profile and region are stored in a Docker context. + +A `compose.yaml` file is parsed and converted into a +[CloudFormation](https://aws.amazon.com/cloudformation/) template, +which is then used to create all application resources in dependent order. +Resources are cleaned up with the `down` command or in the case of a deployment +failure. + +The architecture of the ECS integration is shown below: + +``` + +--------------------------------------+ + | compose.yaml file | + +--------------------------------------+ +- Load + +--------------------------------------+ + | Compose Model | + +--------------------------------------+ +- Validate + +--------------------------------------+ + | Compose Model suitable for ECS | + +--------------------------------------+ +- Convert + +--------------------------------------+ + | CloudFormation Template | + +--------------------------------------+ +- Apply + +--------------+ +----------------+ + | AWS API | or | stack file | + +--------------+ +----------------+ +``` + +* The _Load_ phase relies on + [compose-go](https://github.com/compose-spec/compose-go). + Any generic code we write for this purpose should be proposed upstream. +* The _Validate_ phase is responsible for injecting sane ECS defaults into the + compose-go model, and validating the `compose.yaml` file does not include + unsupported features. +* The _Convert_ phase produces a CloudFormation template to define all + application resources required to implement the application model on AWS. +* The _Apply_ phase does the actual apply of the CloudFormation template, + either by exporting to a stack file or to deploy on AWS. + +## Application model + +### Services + +Compose services are mapped to ECS services. The Compose specification does not +have support for multi-container services (like Kubernetes pods) or sidecars. +When an ECS feature requires a sidecar, we use a custom Compose extension +(`x-aws-*`) to expose the ECS features as a service-level feature, +and keep the plumbing details from the user. + +### Networking + +We map the "network" abstraction from the Compose model to AWS security groups. +The whole application is created within a single VPC, +security groups are created per Compose network, including the implicit +`default` one. +Services are attached according to the networks declared in Compose model. +This approach means that services attached to a common security group can +communicate together, while services from distinct security groups cannot. +This matches the intent of the Compose network model with the limitation that we +cannot set service aliases per network. + +A [CloudMap](https://aws.amazon.com/cloud-map/) private namespace is created for +each application as `{project}.local`. Services get registered so that we +have service discovery and DNS round-robin +(equivalent to Compose's `endpoint_mode: dnsrr`). Docker images SHOULD include +an entrypoint script to replicate this feature: + +```shell script +if [ ! -z LOCALDOMAIN ]; then echo "search ${LOCALDOMAIN}" >> /etc/resolv.conf; fi +``` diff --git a/ecs/builder.Makefile b/ecs/builder.Makefile new file mode 100644 index 000000000..ae52173d5 --- /dev/null +++ b/ecs/builder.Makefile @@ -0,0 +1,39 @@ +GOOS?=$(shell go env GOOS) +GOARCH?=$(shell go env GOARCH) + +EXTENSION := +ifeq ($(GOOS),windows) + EXTENSION := .exe +endif + +STATIC_FLAGS=CGO_ENABLED=0 +LDFLAGS := "-s -w \ + -X github.com/docker/ecs-plugin/internal.GitCommit=$(COMMIT) \ + -X github.com/docker/ecs-plugin/internal.Version=$(TAG)" +GO_BUILD=$(STATIC_FLAGS) go build -trimpath -ldflags=$(LDFLAGS) + +BINARY=dist/docker-ecs +BINARY_WITH_EXTENSION=$(BINARY)$(EXTENSION) + +export DOCKER_BUILDKIT=1 + +all: build + +clean: + rm -rf dist/ + +build: + $(GO_BUILD) -v -o $(BINARY_WITH_EXTENSION) cmd/main/main.go + +cross: + @GOOS=linux GOARCH=amd64 $(GO_BUILD) -v -o $(BINARY)-linux-amd64 cmd/main/main.go + @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -v -o $(BINARY)-darwin-amd64 cmd/main/main.go + @GOOS=windows GOARCH=amd64 $(GO_BUILD) -v -o $(BINARY)-windows-amd64.exe cmd/main/main.go + +test: ## Run tests + @$(STATIC_FLAGS) go test -cover $(shell go list ./... | grep -vE 'e2e') + +lint: ## Verify Go files + $(STATIC_FLAGS) golangci-lint run --timeout 10m0s --config ./golangci.yaml ./... + +.PHONY: all clean build cross test dev lint diff --git a/ecs/cmd/commands/compose.go b/ecs/cmd/commands/compose.go new file mode 100644 index 000000000..7fea85e94 --- /dev/null +++ b/ecs/cmd/commands/compose.go @@ -0,0 +1,152 @@ +package commands + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/compose-spec/compose-go/cli" + "github.com/docker/cli/cli/command" + amazon "github.com/docker/ecs-plugin/pkg/amazon/backend" + "github.com/docker/ecs-plugin/pkg/amazon/cloudformation" + "github.com/docker/ecs-plugin/pkg/docker" + "github.com/docker/ecs-plugin/pkg/progress" + "github.com/spf13/cobra" +) + +func ComposeCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "compose", + } + opts := &composeOptions{} + AddFlags(opts, cmd.Flags()) + + cmd.AddCommand( + ConvertCommand(dockerCli, opts), + UpCommand(dockerCli, opts), + DownCommand(dockerCli, opts), + LogsCommand(dockerCli, opts), + PsCommand(dockerCli, opts), + ) + return cmd +} + +type upOptions struct { + loadBalancerArn string +} + +func (o upOptions) LoadBalancerArn() *string { + if o.loadBalancerArn == "" { + return nil + } + return &o.loadBalancerArn +} + +func ConvertCommand(dockerCli command.Cli, options *composeOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "convert", + RunE: WithAwsContext(dockerCli, func(ctx docker.AwsContext, backend *amazon.Backend, args []string) error { + opts, err := options.toProjectOptions() + if err != nil { + return err + } + project, err := cli.ProjectFromOptions(opts) + if err != nil { + return err + } + template, err := backend.Convert(project) + if err != nil { + return err + } + json, err := cloudformation.Marshall(template) + if err != nil { + return err + } + fmt.Printf("%s\n", string(json)) + return nil + }), + } + return cmd +} + +func UpCommand(dockerCli command.Cli, options *composeOptions) *cobra.Command { + opts := upOptions{} + cmd := &cobra.Command{ + Use: "up", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + opts, err := options.toProjectOptions() + if err != nil { + return err + } + + return progress.Run(context.Background(), func(ctx context.Context) error { + return backend.Up(ctx, opts) + }) + }), + } + cmd.Flags().StringVar(&opts.loadBalancerArn, "load-balancer", "", "") + return cmd +} + +func PsCommand(dockerCli command.Cli, options *composeOptions) *cobra.Command { + opts := upOptions{} + cmd := &cobra.Command{ + Use: "ps", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + opts, err := options.toProjectOptions() + if err != nil { + return err + } + status, err := backend.Ps(context.Background(), opts) + if err != nil { + return err + } + printSection(os.Stdout, len(status), func(w io.Writer) { + for _, service := range status { + fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\n", service.ID, service.Name, service.Replicas, service.Desired, strings.Join(service.Ports, ", ")) + } + }, "ID", "NAME", "REPLICAS", "PORTS") + return nil + }), + } + cmd.Flags().StringVar(&opts.loadBalancerArn, "load-balancer", "", "") + return cmd +} + +type downOptions struct { + DeleteCluster bool +} + +func DownCommand(dockerCli command.Cli, options *composeOptions) *cobra.Command { + opts := downOptions{} + cmd := &cobra.Command{ + Use: "down", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + opts, err := options.toProjectOptions() + if err != nil { + return err + } + return progress.Run(context.Background(), func(ctx context.Context) error { + return backend.Down(ctx, opts) + }) + }), + } + cmd.Flags().BoolVar(&opts.DeleteCluster, "delete-cluster", false, "Delete cluster") + return cmd +} + +func LogsCommand(dockerCli command.Cli, options *composeOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs [PROJECT NAME]", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + opts, err := options.toProjectOptions() + if err != nil { + return err + } + return backend.Logs(context.Background(), opts, os.Stdout) + }), + } + return cmd +} diff --git a/ecs/cmd/commands/context.go b/ecs/cmd/commands/context.go new file mode 100644 index 000000000..080d3b7d9 --- /dev/null +++ b/ecs/cmd/commands/context.go @@ -0,0 +1,31 @@ +package commands + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/docker/cli/cli/command" + amazon "github.com/docker/ecs-plugin/pkg/amazon/backend" + "github.com/docker/ecs-plugin/pkg/docker" + "github.com/spf13/cobra" +) + +type ContextFunc func(ctx docker.AwsContext, backend *amazon.Backend, args []string) error + +func WithAwsContext(dockerCli command.Cli, f ContextFunc) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx, err := docker.GetAwsContext(dockerCli) + if err != nil { + return err + } + backend, err := amazon.NewBackend(ctx.Profile, ctx.Region) + if err != nil { + return err + } + err = f(*ctx, backend, args) + if e, ok := err.(awserr.Error); ok { + return fmt.Errorf(e.Message()) + } + return err + } +} diff --git a/ecs/cmd/commands/opts.go b/ecs/cmd/commands/opts.go new file mode 100644 index 000000000..2124ca247 --- /dev/null +++ b/ecs/cmd/commands/opts.go @@ -0,0 +1,28 @@ +package commands + +import ( + "github.com/compose-spec/compose-go/cli" + "github.com/spf13/pflag" +) + +type composeOptions struct { + Name string + WorkingDir string + ConfigPaths []string + Environment []string +} + +func AddFlags(o *composeOptions, flags *pflag.FlagSet) { + flags.StringArrayVarP(&o.ConfigPaths, "file", "f", nil, "Specify an alternate compose file") + flags.StringVarP(&o.Name, "project-name", "n", "", "Specify an alternate project name (default: directory name)") + flags.StringVarP(&o.WorkingDir, "workdir", "w", "", "Working directory") + flags.StringSliceVarP(&o.Environment, "environment", "e", []string{}, "Environment variables") +} + +func (o *composeOptions) toProjectOptions() (*cli.ProjectOptions, error) { + return cli.NewProjectOptions(o.ConfigPaths, + cli.WithOsEnv, + cli.WithEnv(o.Environment), + cli.WithWorkingDirectory(o.WorkingDir), + cli.WithName(o.Name)) +} diff --git a/ecs/cmd/commands/root.go b/ecs/cmd/commands/root.go new file mode 100644 index 000000000..a9c229218 --- /dev/null +++ b/ecs/cmd/commands/root.go @@ -0,0 +1,32 @@ +package commands + +import ( + "fmt" + + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// NewRootCmd returns the base root command. +func NewRootCmd(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Short: "Docker ECS", + Long: `run multi-container applications on Amazon ECS.`, + Use: "ecs", + Annotations: map[string]string{"experimentalCLI": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return fmt.Errorf("%q is not a docker ecs command\nSee 'docker ecs --help'", args[0]) + } + cmd.Help() + return nil + }, + } + cmd.AddCommand( + VersionCommand(), + ComposeCommand(dockerCli), + SecretCommand(dockerCli), + SetupCommand(), + ) + return cmd +} diff --git a/ecs/cmd/commands/root_test.go b/ecs/cmd/commands/root_test.go new file mode 100644 index 000000000..80f2a72d5 --- /dev/null +++ b/ecs/cmd/commands/root_test.go @@ -0,0 +1,13 @@ +package commands + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestUnknownCommand(t *testing.T) { + root := NewRootCmd(nil) + _, _, err := root.Find([]string{"unknown_command"}) + assert.Error(t, err, "unknown command \"unknown_command\" for \"ecs\"") +} diff --git a/ecs/cmd/commands/secret.go b/ecs/cmd/commands/secret.go new file mode 100644 index 000000000..3246e1237 --- /dev/null +++ b/ecs/cmd/commands/secret.go @@ -0,0 +1,140 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/docker/cli/cli/command" + amazon "github.com/docker/ecs-plugin/pkg/amazon/backend" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/docker/ecs-plugin/pkg/docker" + "github.com/spf13/cobra" +) + +type createSecretOptions struct { + Label string + Username string + Password string + Description string +} + +type deleteSecretOptions struct { + recover bool +} + +func SecretCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "Manages secrets", + } + + cmd.AddCommand( + CreateSecret(dockerCli), + InspectSecret(dockerCli), + ListSecrets(dockerCli), + DeleteSecret(dockerCli), + ) + return cmd +} + +func CreateSecret(dockerCli command.Cli) *cobra.Command { + opts := createSecretOptions{} + cmd := &cobra.Command{ + Use: "create NAME", + Short: "Creates a secret.", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + if len(args) == 0 { + return errors.New("Missing mandatory parameter: NAME") + } + name := args[0] + + secret := compose.NewSecret(name, opts.Username, opts.Password, opts.Description) + id, err := backend.CreateSecret(context.Background(), secret) + fmt.Println(id) + return err + }), + } + cmd.Flags().StringVarP(&opts.Username, "username", "u", "", "username") + cmd.Flags().StringVarP(&opts.Password, "password", "p", "", "password") + cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Secret description") + return cmd +} + +func InspectSecret(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect ID", + Short: "Displays secret details", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + if len(args) == 0 { + return errors.New("Missing mandatory parameter: ID") + } + id := args[0] + secret, err := backend.InspectSecret(context.Background(), id) + if err != nil { + return err + } + out, err := secret.ToJSON() + if err != nil { + return err + } + fmt.Println(out) + return nil + }), + } + return cmd +} + +func ListSecrets(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List secrets stored for the existing account.", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + secrets, err := backend.ListSecrets(context.Background()) + if err != nil { + return err + } + + printList(os.Stdout, secrets) + return nil + }), + } + return cmd +} + +func DeleteSecret(dockerCli command.Cli) *cobra.Command { + opts := deleteSecretOptions{} + cmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm", "remove"}, + Short: "Removes a secret.", + RunE: WithAwsContext(dockerCli, func(clusteropts docker.AwsContext, backend *amazon.Backend, args []string) error { + if len(args) == 0 { + return errors.New("Missing mandatory parameter: [NAME]") + } + return backend.DeleteSecret(context.Background(), args[0], opts.recover) + }), + } + cmd.Flags().BoolVar(&opts.recover, "recover", false, "Enable recovery.") + return cmd +} + +func printList(out io.Writer, secrets []compose.Secret) { + printSection(out, len(secrets), func(w io.Writer) { + for _, secret := range secrets { + fmt.Fprintf(w, "%s\t%s\t%s\n", secret.ID, secret.Name, secret.Description) + } + }, "ID", "NAME", "DESCRIPTION") +} + +func printSection(out io.Writer, len int, printer func(io.Writer), headers ...string) { + w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, strings.Join(headers, "\t")) + printer(w) + w.Flush() +} diff --git a/ecs/cmd/commands/setup.go b/ecs/cmd/commands/setup.go new file mode 100644 index 000000000..da733f715 --- /dev/null +++ b/ecs/cmd/commands/setup.go @@ -0,0 +1,262 @@ +package commands + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/defaults" + amazon "github.com/docker/ecs-plugin/pkg/amazon/backend" + contextStore "github.com/docker/ecs-plugin/pkg/docker" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "gopkg.in/ini.v1" +) + +const enterLabelPrefix = "Enter " + +type setupOptions struct { + name string + profile string + region string + accessKeyID string + secretAccessKey string +} + +func (s setupOptions) unsetRequiredArgs() []string { + unset := []string{} + if s.profile == "" { + unset = append(unset, "profile") + } + if s.region == "" { + unset = append(unset, "region") + } + return unset +} + +func SetupCommand() *cobra.Command { + var opts setupOptions + + cmd := &cobra.Command{ + Use: "setup", + Short: "", + RunE: func(cmd *cobra.Command, args []string) error { + if requiredFlag := opts.unsetRequiredArgs(); len(requiredFlag) > 0 { + if err := interactiveCli(&opts); err != nil { + return err + } + } + if opts.accessKeyID != "" && opts.secretAccessKey != "" { + if err := saveCredentials(opts.profile, opts.accessKeyID, opts.secretAccessKey); err != nil { + return err + } + } + backend, err := amazon.NewBackend(opts.profile, opts.region) + if err != nil { + return err + } + + context, _, err := backend.CreateContextData(context.Background(), map[string]string{ + amazon.ContextParamProfile: opts.profile, + amazon.ContextParamRegion: opts.region, + }) + if err != nil { + return err + } + return contextStore.NewContext(opts.name, context) + }, + } + cmd.Flags().StringVarP(&opts.name, "name", "n", "ecs", "Context Name") + cmd.Flags().StringVarP(&opts.profile, "profile", "p", "", "AWS Profile") + cmd.Flags().StringVarP(&opts.region, "region", "r", "", "AWS region") + cmd.Flags().StringVarP(&opts.accessKeyID, "aws-key-id", "k", "", "AWS Access Key ID") + cmd.Flags().StringVarP(&opts.secretAccessKey, "aws-secret-key", "s", "", "AWS Secret Access Key") + + return cmd +} + +func interactiveCli(opts *setupOptions) error { + var section ini.Section + + if err := setContextName(opts); err != nil { + return err + } + + section, err := setProfile(opts, section) + if err != nil { + return err + } + + if err := setRegion(opts, section); err != nil { + return err + } + + if err := setCredentials(opts); err != nil { + return err + } + + return nil +} + +func saveCredentials(profile string, accessKeyID string, secretAccessKey string) error { + p := credentials.SharedCredentialsProvider{Profile: profile} + _, err := p.Retrieve() + if err == nil { + fmt.Println("credentials already exists!") + return nil + } + + if err.(awserr.Error).Code() == "SharedCredsLoad" && err.(awserr.Error).Message() == "failed to load shared credentials file" { + err = os.MkdirAll(filepath.Dir(p.Filename), 0700) + if err != nil { + return err + } + _, err = os.Create(p.Filename) + if err != nil { + return err + } + } + + credIni, err := ini.LooseLoad(p.Filename) + if err != nil { + return err + } + section, err := credIni.NewSection(profile) + if err != nil { + return err + } + section.NewKey("aws_access_key_id", accessKeyID) + section.NewKey("aws_secret_access_key", secretAccessKey) + return credIni.SaveTo(p.Filename) +} + +func awsProfiles(filename string) (map[string]ini.Section, error) { + profiles := map[string]ini.Section{"new profile": {}} + if filename == "" { + filename = defaults.SharedConfigFilename() + } + credIni, err := ini.LooseLoad(filename) + if err != nil { + return nil, err + } + if err != nil { + return nil, err + } + for _, section := range credIni.Sections() { + if strings.HasPrefix(section.Name(), "profile") { + profiles[section.Name()[len("profile "):]] = *section + } + } + return profiles, nil +} + +func setContextName(opts *setupOptions) error { + if opts.name == "ecs" { + result, err := promptString(opts.name, "context name", enterLabelPrefix, 2) + if err != nil { + return err + } + opts.name = result + } + return nil +} + +func setProfile(opts *setupOptions, section ini.Section) (ini.Section, error) { + profilesList, err := awsProfiles("") + if err != nil { + return ini.Section{}, err + } + section, ok := profilesList[opts.profile] + if !ok { + prompt := promptui.Select{ + Label: "Select AWS Profile", + Items: reflect.ValueOf(profilesList).MapKeys(), + } + _, result, err := prompt.Run() + if result == "new profile" { + result, err := promptString(opts.profile, "profile name", enterLabelPrefix, 2) + if err != nil { + return ini.Section{}, err + } + opts.profile = result + } else { + section = profilesList[result] + opts.profile = result + } + if err != nil { + return ini.Section{}, err + } + } + return section, nil +} + +func setRegion(opts *setupOptions, section ini.Section) error { + defaultRegion := opts.region + if defaultRegion == "" && section.Name() != "" { + region, err := section.GetKey("region") + if err == nil { + defaultRegion = region.Value() + } + } + result, err := promptString(defaultRegion, "region", enterLabelPrefix, 2) + if err != nil { + return err + } + opts.region = result + return nil +} + +func setCredentials(opts *setupOptions) error { + prompt := promptui.Prompt{ + Label: "Enter credentials", + IsConfirm: true, + } + _, err := prompt.Run() + if err == nil { + result, err := promptString(opts.accessKeyID, "AWS Access Key ID", enterLabelPrefix, 3) + if err != nil { + return err + } + opts.accessKeyID = result + + prompt = promptui.Prompt{ + Label: "Enter AWS Secret Access Key", + Validate: validateMinLen("AWS Secret Access Key", 3), + Mask: '*', + Default: opts.secretAccessKey, + } + result, err = prompt.Run() + if err != nil { + return err + } + opts.secretAccessKey = result + } + return nil +} + +func promptString(defaultValue string, label string, labelPrefix string, minLength int) (string, error) { + prompt := promptui.Prompt{ + Label: labelPrefix + label, + Validate: validateMinLen(label, minLength), + Default: defaultValue, + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return result, nil +} + +func validateMinLen(label string, minLength int) func(input string) error { + return func(input string) error { + if len(input) < minLength { + return fmt.Errorf("%s must have more than %d characters", label, minLength) + } + return nil + } +} diff --git a/ecs/cmd/commands/version.go b/ecs/cmd/commands/version.go new file mode 100644 index 000000000..ce0253898 --- /dev/null +++ b/ecs/cmd/commands/version.go @@ -0,0 +1,20 @@ +package commands + +import ( + "fmt" + + "github.com/docker/ecs-plugin/internal" + + "github.com/spf13/cobra" +) + +func VersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show version.", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Fprintf(cmd.OutOrStdout(), "Docker ECS plugin %s (%s)\n", internal.Version, internal.GitCommit) + return nil + }, + } +} diff --git a/ecs/cmd/commands/version_test.go b/ecs/cmd/commands/version_test.go new file mode 100644 index 000000000..43a9ab814 --- /dev/null +++ b/ecs/cmd/commands/version_test.go @@ -0,0 +1,20 @@ +package commands + +import ( + "bytes" + "strings" + "testing" + + "github.com/docker/ecs-plugin/internal" + + "gotest.tools/v3/assert" +) + +func TestVersion(t *testing.T) { + root := NewRootCmd(nil) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"version"}) + root.Execute() + assert.Check(t, strings.Contains(out.String(), internal.Version)) +} diff --git a/ecs/cmd/main/main.go b/ecs/cmd/main/main.go new file mode 100644 index 000000000..f4140394a --- /dev/null +++ b/ecs/cmd/main/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "github.com/docker/ecs-plugin/cmd/commands" + "github.com/docker/ecs-plugin/internal" + + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli-plugins/plugin" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +func main() { + plugin.Run(func(dockerCli command.Cli) *cobra.Command { + cmd := commands.NewRootCmd(dockerCli) + return cmd + }, manager.Metadata{ + SchemaVersion: "0.1.0", + Vendor: "Docker Inc.", + Version: internal.Version, + Experimental: true, + }) +} diff --git a/ecs/docs/get-started-linux.md b/ecs/docs/get-started-linux.md new file mode 100644 index 000000000..afada8906 --- /dev/null +++ b/ecs/docs/get-started-linux.md @@ -0,0 +1,82 @@ +Getting Started with Docker AWS ECS Plugin Beta on Linux +-------------------------------------------------------- + +The beta release of [AWS ECS](https://aws.amazon.com/ecs/) support for the +Docker CLI is shipped as a CLI plugin. Later releases will be included as part +of the Docker CLI. + +This plugin is included as part of Docker Desktop on Windows and macOS but on +Linux it needs to be installed manually. + +## Prerequisites + +* [Docker 19.03 or later](https://docs.docker.com/get-docker/) + +## Step by step install + +### Download + +You can download the Docker ECS plugin from this repository using the following +command: + +```console +$ curl -LO https://github.com/docker/ecs-plugin/releases/latest/download/docker-ecs-linux-amd64 +``` + +You will then need to make it executable: + +```console +$ chmod +x docker-ecs-linux-amd64 +``` + +### Plugin install + +In order for the Docker CLI to use the downloaded plugin, you will need to move +it to the right place: + +```console +$ mkdir -p /usr/local/lib/docker/cli-plugins + +$ mv docker-ecs-linux-amd64 /usr/local/lib/docker/cli-plugins/docker-ecs +``` + +You can put the CLI plugin into any of the following directories: + +* `/usr/local/lib/docker/cli-plugins` +* `/usr/local/libexec/docker/cli-plugins` +* `/usr/lib/docker/cli-plugins` +* `/usr/libexec/docker/cli-plugins` + +Finally you need to enable the experimental features on the CLI. This can be +done by setting the environment variable `DOCKER_CLI_EXPERIMENTAL=enabled` or by +setting `experimental` to `"enabled"` in your Docker config found at +`~/.docker/config.json`: + +```console +$ export DOCKER_CLI_EXPERIMENTAL=enabled + +$ DOCKER_CLI_EXPERIMENTAL=enabled docker help + +$ cat ~/.docker/config.json +{ + "experimental" : "enabled", + "auths" : { + "https://index.docker.io/v1/" : { + + } + } +} +``` + +To verify the CLI plugin installation, you can check that it appears in the CLI +help output or by outputting the plugin version: + +```console +$ docker help | grep ecs + ecs* Docker ECS (Docker Inc., 0.0.1) + +$ docker ecs version +Docker ECS plugin 0.0.1 +``` + +You are now ready to [start deploying to ECS](https://docs.docker.com/engine/context/ecs-integration/) diff --git a/ecs/docs/requirements.md b/ecs/docs/requirements.md new file mode 100644 index 000000000..d142de4f9 --- /dev/null +++ b/ecs/docs/requirements.md @@ -0,0 +1,31 @@ +## Requirements + +This plugin relies on AWS API credentials, using the same configuration files as +the AWS command line. + +Such credentials can be configured by the `docker ecs setup` command, either by +selecting an existing AWS CLI profile from existing config files, or by creating +one passing an AWS access key ID and secret access key. + +## Permissions + +AWS accounts (or IAM roles) used with the ECS plugin require following permissions: + +- ec2:DescribeSubnets +- ec2:DescribeVpcs +- iam:CreateServiceLinkedRole +- iam:AttachRolePolicy +- cloudformation:* +- ecs:* +- logs:* +- servicediscovery:* +- elasticloadbalancing:* + + +## Okta support + +For those relying on [aws-okta](https://github.com/segmentio/aws-okta) to access a managed AWS account +(as we do at Docker), you can populate your aws config files with temporary access tokens using: +```shell script +aws-okta write-to-credentials ~/.aws/credentials +``` diff --git a/ecs/example/Makefile b/ecs/example/Makefile new file mode 100644 index 000000000..deba2b83f --- /dev/null +++ b/ecs/example/Makefile @@ -0,0 +1,25 @@ +REPO_NAMESPACE ?= ${USER} +FRONTEND_IMG = ${REPO_NAMESPACE}/timestamper +REGISTRY_ID ?= PUT_ECR_REGISTRY_ID_HERE +DOCKER_PUSH_REPOSITORY=dkr.ecr.us-west-2.amazonaws.com + +all: build-image + +create-ecr: + aws ecr create-repository --repository-name ${FRONTEND_IMG} + +build-image: + docker build -t $(REGISTRY_ID).$(DOCKER_PUSH_REPOSITORY)/$(FRONTEND_IMG) ./app + docker build -t $(FRONTEND_IMG) ./app + +push-image-ecr: + aws ecr get-login-password --region us-west-2 | docker login -u AWS --password-stdin $(REGISTRY_ID).$(DOCKER_PUSH_REPOSITORY) + docker push $(REGISTRY_ID).$(DOCKER_PUSH_REPOSITORY)/$(FRONTEND_IMG) + +push-image-hub: + docker push $(FRONTEND_IMG) + +clean: + @docker context use default + @docker context rm aws || true + @docker-compose rm -f || true diff --git a/ecs/example/README.md b/ecs/example/README.md new file mode 100644 index 000000000..e8dbbd738 --- /dev/null +++ b/ecs/example/README.md @@ -0,0 +1,181 @@ +## Compose sample application + +This sample application was demoed as part of the AWS Cloud Containers +Conference on 2020-07-09. It has been tested on Linux and macOS. + +Note that `$` is used to denote commands in blocks where the command and its +output are included. + +### Python/Flask application + +``` ++--------------------+ +------------------+ +| | | | +| Python Flask | timestamps | Redis | +| Application |------------->| | +| | | | ++--------------------+ +------------------+ +``` + +### Things you'll need to do + +There are a number of places you'll need to fill in information. +You can find them with: + +```console +$ grep -r '<<<' ./* +./docker-compose.yml: x-aws-pull_credentials: <<>> +./docker-compose.yml: image: <<>>/timestamper +## Walk through +``` + +### Setup pull credentials for private Docker Hub repositories + +You should use a Personal Access Token (PAT) rather than your account password. +If you have 2FA enabled on your Hub account you will need to create a PAT. +You can read more about managing access tokens here: +https://docs.docker.com/docker-hub/access-tokens/ + + +You can then create `DockerHubToken` secret on [AWS Secret Manager](https://aws.amazon.com/secrets-manager/) using following command + +```console +docker ecs secret create -d MyKey -u myhubusername -p myhubpat DockerHubToken +``` + +### Create an AWS Docker context and list available contexts + +To initialize the Docker ECS integration, you will need to run the `setup` +command. This will create a Docker context that works with AWS ECS. + +```console +$ docker ecs setup +Enter context name: aws +✔ sandbox.devtools.developer +Enter cluster name: +Enter region: us-west-2 +✗ Enter credentials: +``` + +You can verify that the context was created by listing your Docker contexts: + +```console +$ docker context ls +NAME DESCRIPTION DOCKER ENDPOINT KUBERNETES ENDPOINT ORCHESTRATOR +aws +default * Current DOCKER_HOST based configuration unix:///var/run/docker.sock swarm +``` + +### Test locally + +The first step is to test your application works locally. To do this, you will +need to switch to using the default local context so that you are targeting your +local machine. + +```console +docker context use default +``` + +You can then run the application using `docker-compose`: + +```console +docker-compose up +``` + +Once the application has started, you can navigate to http://localhost:5000 +using your Web browser using the following command: + +```console +open http://localhost:5000 +``` + +### Push images to Docker Hub for ECS (ECS cannot see your local image cache) + +In order to run your application in the cloud, you will need your container +images to be in a registry. You can push them from your local machine using: + +```console +docker-compose push +``` + +You can verify that this command pushed to the Docker Hub by +[logging in](https://hub.docker.com) and looking for the `timestamper` +repository under your user name. + +### Switch to ECS context and launch the app + +Now that you've tested the application works locally and that you've pushed the +container images to the Docker Hub, you can switch to using the `aws` context +you created earlier. + +```console +docker context use aws +``` + +Running the application on ECS is then as simple as doing a `compose up`: + +```console +docker ecs compose up +``` + +### Check out the CLI + +Once the application is running in ECS, you can list the running containers with +the `ps` command. Note that you will need to run this from the directory where +you Compose file is. + +```console +docker ecs compose ps +``` + +You can also read the application logs using `compose logs`: + +```console +docker ecs compose logs +``` + +### Check out the AWS console + +You can see all the AWS components created for your running application in the +AWS console. There you will find: + +- CloudFormation being used to manage all the infrastructure +- CloudWatch for logs +- Security Groups for network policies +- Load balancers (ELB for this example / ALB if your app only uses 80/443) + +### Checkout CloudFormation + +The ECS Docker CLI integration has the ability to output the CloudFormation +template used to create the application in the `compose convert` command. You +can see this by running: + +```console +docker ecs compose convert +``` + +### Stop the meters + +To shut down your application, you simply need to run: + +```console +docker ecs compose down +``` + +## Using Amazon ECR instead of Docker Hub + +If you'd like to use AWS ECR instead of Docker Hub, the [Makefile](Makefile) has +an example setup for creating an ECR repository and pushing to it. +You'll need to have the AWS CLI installed and your AWS credentials available. + +```console +make create-ecr +REGISTRY_ID= make build-image +REGISTRY_ID= make push-image-ecr +``` + +Note that you will need to change the name of the image in the +[Compose file](docker-compose.yml). + +If you want to use this often, you'll likely want to replace +`PUT_ECR_REGISTRY_ID_HERE` with the value from above. diff --git a/ecs/example/app/Dockerfile b/ecs/example/app/Dockerfile new file mode 100644 index 000000000..64469d281 --- /dev/null +++ b/ecs/example/app/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.7-alpine +WORKDIR /app +COPY requirements.txt /app +RUN pip3 install -r requirements.txt +COPY . /app +ENTRYPOINT ["/app/scripts/entrypoint.sh"] +CMD ["python3", "app.py"] diff --git a/ecs/example/app/app.py b/ecs/example/app/app.py new file mode 100644 index 000000000..e54b19a28 --- /dev/null +++ b/ecs/example/app/app.py @@ -0,0 +1,19 @@ + +from flask import Flask +from flask import render_template +from redis import StrictRedis +from datetime import datetime + +app = Flask(__name__) +redis = StrictRedis(host='backend', port=6379) + + +@app.route('/') +def home(): + redis.lpush('times', datetime.now().strftime('%Y-%m-%dT%H:%M:%S%z')) + return render_template('index.html', title='Home', + times=redis.lrange('times', 0, -1)) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', debug=True) diff --git a/ecs/example/app/requirements.txt b/ecs/example/app/requirements.txt new file mode 100644 index 000000000..1a5dc97b1 --- /dev/null +++ b/ecs/example/app/requirements.txt @@ -0,0 +1,2 @@ +flask +redis diff --git a/ecs/example/app/scripts/entrypoint.sh b/ecs/example/app/scripts/entrypoint.sh new file mode 100755 index 000000000..198660244 --- /dev/null +++ b/ecs/example/app/scripts/entrypoint.sh @@ -0,0 +1,4 @@ +#! /bin/sh + +if [ "${LOCALDOMAIN}" != "" ]; then echo "search ${LOCALDOMAIN}" >> /etc/resolv.conf; fi +exec "$@" diff --git a/ecs/example/app/templates/index.html b/ecs/example/app/templates/index.html new file mode 100644 index 000000000..91efdaefb --- /dev/null +++ b/ecs/example/app/templates/index.html @@ -0,0 +1,125 @@ + + + + + + + + + + + Hello, Docker! + + +
+ + +
+

Hello, Docker Folks!

+
+ +
+ + +
+ +
+ + + + + + + + {% for t in times %} + + + + {% endfor %} + +
Timestamp
{{ t.decode('utf-8') }}
+
+ +
+ + + + + + + + diff --git a/ecs/example/docker-compose.yml b/ecs/example/docker-compose.yml new file mode 100644 index 000000000..9d08d45d7 --- /dev/null +++ b/ecs/example/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.8" +services: + frontend: + build: app + x-aws-pull_credentials: <<>> + image: <<>>/timestamper + ports: + - "5000:5000" + depends_on: + - backend + backend: + image: redis:alpine diff --git a/ecs/go.mod b/ecs/go.mod new file mode 100644 index 000000000..b6a1f1d28 --- /dev/null +++ b/ecs/go.mod @@ -0,0 +1,61 @@ +module github.com/docker/ecs-plugin + +require ( + github.com/Microsoft/hcsshim v0.8.7 // indirect + github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect + github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect + github.com/aws/aws-sdk-go v1.33.18 + github.com/awslabs/goformation/v4 v4.14.0 + github.com/bitly/go-hostpool v0.1.0 // indirect + github.com/bitly/go-simplejson v0.5.0 // indirect + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect + github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129 + github.com/bugsnag/bugsnag-go v1.5.3 // indirect + github.com/bugsnag/panicwrap v1.2.0 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/cloudflare/cfssl v1.4.1 // indirect + github.com/compose-spec/compose-go v0.0.0-20200811091145-837f8f4de457 + github.com/containerd/console v1.0.0 + github.com/containerd/containerd v1.3.2 // indirect + github.com/containerd/continuity v0.0.0-20200413184840-d3ef23f19fbb // indirect + github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v1.4.2-0.20200128034134-2ebaeef943cc // indirect + github.com/docker/docker-credential-helpers v0.6.3 // indirect + github.com/docker/go v1.5.1-1 // indirect + github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect + github.com/gofrs/uuid v3.2.0+incompatible // indirect + github.com/gogo/protobuf v1.3.1 // indirect + github.com/gorilla/mux v1.7.3 // indirect + github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect + github.com/jinzhu/gorm v1.9.12 // indirect + github.com/joho/godotenv v1.3.0 + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect + github.com/lib/pq v1.3.0 // indirect + github.com/manifoldco/promptui v0.7.0 + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect + github.com/miekg/pkcs11 v1.0.3 // indirect + github.com/mitchellh/mapstructure v1.3.3 + github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 + github.com/morikuni/aec v1.0.0 + github.com/onsi/ginkgo v1.11.0 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect + github.com/sirupsen/logrus v1.6.0 + github.com/smartystreets/goconvey v1.6.4 // indirect + github.com/spf13/cobra v0.0.5 + github.com/spf13/pflag v1.0.5 + github.com/theupdateframework/notary v0.6.1 // indirect + github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1 // indirect + golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect + google.golang.org/grpc v1.27.0 // indirect + gopkg.in/dancannon/gorethink.v3 v3.0.5 // indirect + gopkg.in/fatih/pool.v2 v2.0.0 // indirect + gopkg.in/gorethink/gorethink.v3 v3.0.5 // indirect + gopkg.in/ini.v1 v1.55.0 + gotest.tools/v3 v3.0.2 + vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787 // indirect +) + +go 1.14 diff --git a/ecs/go.sum b/ecs/go.sum new file mode 100644 index 000000000..168b5633e --- /dev/null +++ b/ecs/go.sum @@ -0,0 +1,496 @@ +bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= +bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= +github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA= +github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= +github.com/Microsoft/hcsshim v0.8.7 h1:ptnOoufxGSzauVTsdE+wMYnCWA301PdoN4xg5oRdZpg= +github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aws/aws-sdk-go v1.33.18 h1:Ccy1SV2SsgJU3rfrD+SOhQ0jvuzfrFuja/oKI86ruPw= +github.com/aws/aws-sdk-go v1.33.18/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/awslabs/goformation/v4 v4.14.0 h1:E2Pet9eIqA4qzt3dzzzE4YN83V4Kyfbcio0VokBC9TA= +github.com/awslabs/goformation/v4 v4.14.0/go.mod h1:GcJULxCJfloT+3pbqCluXftdEK2AD/UqpS3hkaaBntg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2ioR0= +github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129 h1:gfAMKE626QEuKG3si0pdTRcr/YEbBoxY+3GOH3gWvl4= +github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129/go.mod h1:u9UyCz2eTrSGy6fbupqJ54eY5c4IC8gREQ1053dK12U= +github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04= +github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABFQA= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY= +github.com/cloudflare/cfssl v1.4.1 h1:vScfU2DrIUI9VPHBVeeAQ0q5A+9yshO1Gz+3QoUQiKw= +github.com/cloudflare/cfssl v1.4.1/go.mod h1:KManx/OJPb5QY+y0+o/898AMcM128sF0bURvoVUSjTo= +github.com/cloudflare/go-metrics v0.0.0-20151117154305-6a9aea36fb41/go.mod h1:eaZPlJWD+G9wseg1BuRXlHnjntPMrywMsyxf+LTOdP4= +github.com/cloudflare/redoctober v0.0.0-20171127175943-746a508df14c/go.mod h1:6Se34jNoqrd8bTxrmJB2Bg2aoZ2CdSXonils9NsiNgo= +github.com/compose-spec/compose-go v0.0.0-20200811091145-837f8f4de457 h1:8ely1LF7H02sIWz6QjgU53YBCiRpYlM9F9u1MeE1ZPk= +github.com/compose-spec/compose-go v0.0.0-20200811091145-837f8f4de457/go.mod h1:cS0vAvM6u9yjJgKWIH2yiqYMWO7WGJb+c0Irw+RefqU= +github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= +github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1 h1:uict5mhHFTzKLUCufdSLym7z/J0CbBJT59lYbP9wtbg= +github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= +github.com/containerd/console v1.0.0 h1:fU3UuQapBs+zLJu82NhR11Rif1ny2zfMMAyPJzSN5tQ= +github.com/containerd/console v1.0.0/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE= +github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.2 h1:ForxmXkA6tPIvffbrDAcPUIB32QgXkt2XFj+F0UxetA= +github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20200413184840-d3ef23f19fbb h1:nXPkFq8X1a9ycY3GYQpFNxHh3j2JgY7zDZfq2EXMIzk= +github.com/containerd/continuity v0.0.0-20200413184840-d3ef23f19fbb/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY= +github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= +github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= +github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= +github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw= +github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v1.4.2-0.20200128034134-2ebaeef943cc h1:2xtQXEoAs2hjCs4Ez4/KT2mDaYrXwcUi7TCkfyT+n2k= +github.com/docker/docker v1.4.2-0.20200128034134-2ebaeef943cc/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= +github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k= +github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getsentry/raven-go v0.0.0-20180121060056-563b81fc02b7/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= +github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= +github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmhodges/clock v0.0.0-20160418191101-880ee4c33548/go.mod h1:hGT6jSUVzF6no3QaDSMLGLEHtHSBSefs+MgcDWnmhmo= +github.com/jmoiron/sqlx v0.0.0-20180124204410-05cef0741ade/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20150923205031-648daed35d49/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/kisom/goutils v1.1.0/go.mod h1:+UBTfd78habUYWFbNWTJNG+jNG/i/lGURakr4A/yNRw= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c= +github.com/lib/pq v0.0.0-20180201184707-88edab080323/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= +github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw= +github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/pkcs11 v1.0.3 h1:iMwmD7I5225wv84WxIG/bmxz9AXjWvTWIbM/TYHvWtw= +github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 h1:kvRIeqJNICemq2UFLx8q/Pj+1IRNZS0XPTaMFkuNsvg= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74/go.mod h1:pJ0Ot5YGdTcMdxnPMyGCfAr6fKXe0g9cDlz16MuFEBE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= +github.com/onsi/ginkgo v1.5.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.2.0/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0 h1:kRhiuYSXR3+uv2IbVbZhUxK5zVD/2pp3Gd2PpvPkpEo= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sanathkr/go-yaml v0.0.0-20170819195128-ed9d249f429b h1:jUK33OXuZP/l6babJtnLo1qsGvq6G9so9KMflGAm4YA= +github.com/sanathkr/go-yaml v0.0.0-20170819195128-ed9d249f429b/go.mod h1:8458kAagoME2+LN5//WxE71ysZ3B7r22fdgb7qVmXSY= +github.com/sanathkr/yaml v0.0.0-20170819201035-0056894fa522 h1:fOCp11H0yuyAt2wqlbJtbyPzSgaxHTv8uN1pMpkG1t8= +github.com/sanathkr/yaml v0.0.0-20170819201035-0056894fa522/go.mod h1:tQTYKOQgxoH3v6dEmdHiz4JG+nbxWwM5fgPQUpSZqVQ= +github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0= +github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.5.0 h1:rutRtjBJViU/YjcI5d80t4JAVvDltS6bciJg2K1HrLU= +github.com/weppos/publicsuffix-go v0.5.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xeipuuv/gojsonschema v0.0.0-20181112162635-ac52e6811b56 h1:yhqBHs09SmmUoNOHc9jgK4a60T3XFRtPAkYxVnqgY50= +github.com/xeipuuv/gojsonschema v0.0.0-20181112162635-ac52e6811b56/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1 h1:j2hhcujLRHAg872RWAV5yaUrEjHEObwDv3aImCaNLek= +github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= +github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e h1:mvOa4+/DXStR4ZXOks/UsjeFdn5O5JpLUtzqk9U8xXw= +github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e/go.mod h1:w7kd3qXHh8FNaczNjslXqvFQiv5mMWRXlL9klTUAHc8= +github.com/zmap/zlint v0.0.0-20190806154020-fd021b4cfbeb h1:vxqkjztXSaPVDc8FQCdHTaejm2x747f6yPbnu1h2xkg= +github.com/zmap/zlint v0.0.0-20190806154020-fd021b4cfbeb/go.mod h1:29UiAJNsiVdvTBFCJW8e3q6dcDbOoPkhMgttOSCIMMY= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09 h1:KaQtG+aDELoNmXYas3TVkGNYRuq8JQ1aa7LJt8EXVyo= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 h1:dfGZHvZk057jK2MCeWus/TowKpJ8y4AmooUzdBSR9GU= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3 h1:7TYNF4UdlohbFwpNH04CoPMp1cHUZgO1Ebq5r2hIjfo= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e h1:N7DeIrjYszNmSW409R3frPPwglRwMkXSBzwVbkOjLLA= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0 h1:rRYRFMVgRv6E0D70Skyfsr28tDXIuuPZyWGMPdMcnXg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/dancannon/gorethink.v3 v3.0.5 h1:/g7PWP7zUS6vSNmHSDbjCHQh1Rqn8Jy6zSMQxAsBSMQ= +gopkg.in/dancannon/gorethink.v3 v3.0.5/go.mod h1:GXsi1e3N2OcKhcP6nsYABTiUejbWMFO4GY5a4pEaeEc= +gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg= +gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/gorethink/gorethink.v3 v3.0.5 h1:e2Uc/Xe+hpcVQFsj6MuHlYog3r0JYpnTzwDj/y2O4MU= +gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I= +gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= +gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/kubernetes v1.13.0 h1:qTfB+u5M92k2fCCCVP2iuhgwwSOv1EkAkvQY1tQODD8= +k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787 h1:O69FD9pJA4WUZlEwYatBEEkRWKQ5cKodWpdKTrCS/iQ= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= diff --git a/ecs/golangci.yaml b/ecs/golangci.yaml new file mode 100644 index 000000000..396293367 --- /dev/null +++ b/ecs/golangci.yaml @@ -0,0 +1,12 @@ +run: + deadline: 2m + +linters: + disable-all: true + enable: + - gofmt + - goimports + - golint + - gosimple + - ineffassign + - misspell \ No newline at end of file diff --git a/ecs/internal/version.go b/ecs/internal/version.go new file mode 100644 index 000000000..b0fc1f6a6 --- /dev/null +++ b/ecs/internal/version.go @@ -0,0 +1,8 @@ +package internal + +var ( + // Version is the git tag that this was built from. + Version = "unknown" + // GitCommit is the commit that this was built from. + GitCommit = "unknown" +) diff --git a/ecs/pkg/amazon/amazon.go b/ecs/pkg/amazon/amazon.go new file mode 100644 index 000000000..f6c588d11 --- /dev/null +++ b/ecs/pkg/amazon/amazon.go @@ -0,0 +1,8 @@ +package amazon + +import ( + "github.com/docker/ecs-plugin/pkg/amazon/backend" + "github.com/docker/ecs-plugin/pkg/compose" +) + +var _ compose.API = &backend.Backend{} diff --git a/ecs/pkg/amazon/backend/backend.go b/ecs/pkg/amazon/backend/backend.go new file mode 100644 index 000000000..2b265f3e2 --- /dev/null +++ b/ecs/pkg/amazon/backend/backend.go @@ -0,0 +1,30 @@ +package backend + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/docker/ecs-plugin/pkg/amazon/sdk" +) + +func NewBackend(profile string, region string) (*Backend, error) { + sess, err := session.NewSessionWithOptions(session.Options{ + Profile: profile, + SharedConfigState: session.SharedConfigEnable, + Config: aws.Config{ + Region: aws.String(region), + }, + }) + if err != nil { + return nil, err + } + + return &Backend{ + Region: region, + api: sdk.NewAPI(sess), + }, nil +} + +type Backend struct { + Region string + api sdk.API +} diff --git a/ecs/pkg/amazon/backend/cloudformation.go b/ecs/pkg/amazon/backend/cloudformation.go new file mode 100644 index 000000000..c6c033db4 --- /dev/null +++ b/ecs/pkg/amazon/backend/cloudformation.go @@ -0,0 +1,590 @@ +package backend + +import ( + "fmt" + "io/ioutil" + "regexp" + "strings" + + ecsapi "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/elbv2" + cloudmapapi "github.com/aws/aws-sdk-go/service/servicediscovery" + "github.com/awslabs/goformation/v4/cloudformation" + "github.com/awslabs/goformation/v4/cloudformation/ec2" + "github.com/awslabs/goformation/v4/cloudformation/ecs" + "github.com/awslabs/goformation/v4/cloudformation/elasticloadbalancingv2" + "github.com/awslabs/goformation/v4/cloudformation/iam" + "github.com/awslabs/goformation/v4/cloudformation/logs" + "github.com/awslabs/goformation/v4/cloudformation/secretsmanager" + cloudmap "github.com/awslabs/goformation/v4/cloudformation/servicediscovery" + "github.com/awslabs/goformation/v4/cloudformation/tags" + "github.com/compose-spec/compose-go/compatibility" + "github.com/compose-spec/compose-go/errdefs" + "github.com/compose-spec/compose-go/types" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/sirupsen/logrus" +) + +const ( + ParameterClusterName = "ParameterClusterName" + ParameterVPCId = "ParameterVPCId" + ParameterSubnet1Id = "ParameterSubnet1Id" + ParameterSubnet2Id = "ParameterSubnet2Id" + ParameterLoadBalancerARN = "ParameterLoadBalancerARN" +) + +// Convert a compose project into a CloudFormation template +func (b Backend) Convert(project *types.Project) (*cloudformation.Template, error) { + var checker compatibility.Checker = &FargateCompatibilityChecker{ + compatibility.AllowList{ + Supported: compatibleComposeAttributes, + }, + } + compatibility.Check(project, checker) + for _, err := range checker.Errors() { + if errdefs.IsIncompatibleError(err) { + logrus.Error(err.Error()) + } else { + logrus.Warn(err.Error()) + } + } + if !compatibility.IsCompatible(checker) { + return nil, fmt.Errorf("compose file is incompatible with Amazon ECS") + } + + template := cloudformation.NewTemplate() + template.Description = "CloudFormation template created by Docker for deploying applications on Amazon ECS" + template.Parameters[ParameterClusterName] = cloudformation.Parameter{ + Type: "String", + Description: "Name of the ECS cluster to deploy to (optional)", + } + + template.Parameters[ParameterVPCId] = cloudformation.Parameter{ + Type: "AWS::EC2::VPC::Id", + Description: "ID of the VPC", + } + + /* + FIXME can't set subnets: Ref("SubnetIds") see https://github.com/awslabs/goformation/issues/282 + template.Parameters["SubnetIds"] = cloudformation.Parameter{ + Type: "List", + Description: "The list of SubnetIds, for at least two Availability Zones in the region in your VPC", + } + */ + template.Parameters[ParameterSubnet1Id] = cloudformation.Parameter{ + Type: "AWS::EC2::Subnet::Id", + Description: "SubnetId, for Availability Zone 1 in the region in your VPC", + } + template.Parameters[ParameterSubnet2Id] = cloudformation.Parameter{ + Type: "AWS::EC2::Subnet::Id", + Description: "SubnetId, for Availability Zone 2 in the region in your VPC", + } + + template.Parameters[ParameterLoadBalancerARN] = cloudformation.Parameter{ + Type: "String", + Description: "Name of the LoadBalancer to connect to (optional)", + } + + // Create Cluster is `ParameterClusterName` parameter is not set + template.Conditions["CreateCluster"] = cloudformation.Equals("", cloudformation.Ref(ParameterClusterName)) + + cluster := createCluster(project, template) + + networks := map[string]string{} + for _, net := range project.Networks { + networks[net.Name] = convertNetwork(project, net, cloudformation.Ref(ParameterVPCId), template) + } + + for i, s := range project.Secrets { + if s.External.External { + continue + } + secret, err := ioutil.ReadFile(s.File) + if err != nil { + return nil, err + } + + name := fmt.Sprintf("%sSecret", normalizeResourceName(s.Name)) + template.Resources[name] = &secretsmanager.Secret{ + Description: "", + SecretString: string(secret), + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + }, + } + s.Name = cloudformation.Ref(name) + project.Secrets[i] = s + } + + createLogGroup(project, template) + + // Private DNS namespace will allow DNS name for the services to be ..local + createCloudMap(project, template) + + loadBalancerARN := createLoadBalancer(project, template) + + for _, service := range project.Services { + + definition, err := Convert(project, service) + if err != nil { + return nil, err + } + + taskExecutionRole, err := createTaskExecutionRole(service, err, definition, template) + if err != nil { + return template, err + } + definition.ExecutionRoleArn = cloudformation.Ref(taskExecutionRole) + + taskDefinition := fmt.Sprintf("%sTaskDefinition", normalizeResourceName(service.Name)) + template.Resources[taskDefinition] = definition + + var healthCheck *cloudmap.Service_HealthCheckConfig + if service.HealthCheck != nil && !service.HealthCheck.Disable { + // FIXME ECS only support HTTP(s) health checks, while Docker only support CMD + } + + serviceRegistry := createServiceRegistry(service, template, healthCheck) + + serviceSecurityGroups := []string{} + for net := range service.Networks { + serviceSecurityGroups = append(serviceSecurityGroups, networks[net]) + } + + dependsOn := []string{} + serviceLB := []ecs.Service_LoadBalancer{} + if len(service.Ports) > 0 { + for _, port := range service.Ports { + protocol := strings.ToUpper(port.Protocol) + if getLoadBalancerType(project) == elbv2.LoadBalancerTypeEnumApplication { + protocol = elbv2.ProtocolEnumHttps + if port.Published == 80 { + protocol = elbv2.ProtocolEnumHttp + } + } + if loadBalancerARN != "" { + targetGroupName := createTargetGroup(project, service, port, template, protocol) + listenerName := createListener(service, port, template, targetGroupName, loadBalancerARN, protocol) + dependsOn = append(dependsOn, listenerName) + serviceLB = append(serviceLB, ecs.Service_LoadBalancer{ + ContainerName: service.Name, + ContainerPort: int(port.Target), + TargetGroupArn: cloudformation.Ref(targetGroupName), + }) + } + } + } + + desiredCount := 1 + if service.Deploy != nil && service.Deploy.Replicas != nil { + desiredCount = int(*service.Deploy.Replicas) + } + + for _, dependency := range service.DependsOn { + dependsOn = append(dependsOn, serviceResourceName(dependency)) + } + + minPercent, maxPercent, err := computeRollingUpdateLimits(service) + if err != nil { + return nil, err + } + + template.Resources[serviceResourceName(service.Name)] = &ecs.Service{ + AWSCloudFormationDependsOn: dependsOn, + Cluster: cluster, + DesiredCount: desiredCount, + DeploymentController: &ecs.Service_DeploymentController{ + Type: ecsapi.DeploymentControllerTypeEcs, + }, + DeploymentConfiguration: &ecs.Service_DeploymentConfiguration{ + MaximumPercent: maxPercent, + MinimumHealthyPercent: minPercent, + }, + LaunchType: ecsapi.LaunchTypeFargate, + LoadBalancers: serviceLB, + NetworkConfiguration: &ecs.Service_NetworkConfiguration{ + AwsvpcConfiguration: &ecs.Service_AwsVpcConfiguration{ + AssignPublicIp: ecsapi.AssignPublicIpEnabled, + SecurityGroups: serviceSecurityGroups, + Subnets: []string{ + cloudformation.Ref(ParameterSubnet1Id), + cloudformation.Ref(ParameterSubnet2Id), + }, + }, + }, + PropagateTags: ecsapi.PropagateTagsService, + SchedulingStrategy: ecsapi.SchedulingStrategyReplica, + ServiceRegistries: []ecs.Service_ServiceRegistry{serviceRegistry}, + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + { + Key: compose.ServiceTag, + Value: service.Name, + }, + }, + TaskDefinition: cloudformation.Ref(normalizeResourceName(taskDefinition)), + } + } + return template, nil +} + +func createLogGroup(project *types.Project, template *cloudformation.Template) { + retention := 0 + if v, ok := project.Extensions[compose.ExtensionRetention]; ok { + retention = v.(int) + } + logGroup := fmt.Sprintf("/docker-compose/%s", project.Name) + template.Resources["LogGroup"] = &logs.LogGroup{ + LogGroupName: logGroup, + RetentionInDays: retention, + } +} + +func computeRollingUpdateLimits(service types.ServiceConfig) (int, int, error) { + maxPercent := 200 + minPercent := 100 + if service.Deploy == nil || service.Deploy.UpdateConfig == nil { + return minPercent, maxPercent, nil + } + updateConfig := service.Deploy.UpdateConfig + min, okMin := updateConfig.Extensions[compose.ExtensionMinPercent] + if okMin { + minPercent = min.(int) + } + max, okMax := updateConfig.Extensions[compose.ExtensionMaxPercent] + if okMax { + maxPercent = max.(int) + } + if okMin && okMax { + return minPercent, maxPercent, nil + } + + if updateConfig.Parallelism != nil { + parallelism := int(*updateConfig.Parallelism) + if service.Deploy.Replicas == nil { + return minPercent, maxPercent, + fmt.Errorf("rolling update configuration require deploy.replicas to be set") + } + replicas := int(*service.Deploy.Replicas) + if replicas < parallelism { + return minPercent, maxPercent, + fmt.Errorf("deploy.replicas (%d) must be greater than deploy.update_config.parallelism (%d)", replicas, parallelism) + } + if !okMin { + minPercent = (replicas - parallelism) * 100 / replicas + } + if !okMax { + maxPercent = (replicas + parallelism) * 100 / replicas + } + } + return minPercent, maxPercent, nil +} + +func getLoadBalancerType(project *types.Project) string { + for _, service := range project.Services { + for _, port := range service.Ports { + if port.Published != 80 && port.Published != 443 { + return elbv2.LoadBalancerTypeEnumNetwork + } + } + } + return elbv2.LoadBalancerTypeEnumApplication +} + +func getLoadBalancerSecurityGroups(project *types.Project, template *cloudformation.Template) []string { + securityGroups := []string{} + for _, network := range project.Networks { + if !network.Internal { + net := convertNetwork(project, network, cloudformation.Ref(ParameterVPCId), template) + securityGroups = append(securityGroups, net) + } + } + return uniqueStrings(securityGroups) +} + +func createLoadBalancer(project *types.Project, template *cloudformation.Template) string { + ports := 0 + for _, service := range project.Services { + ports += len(service.Ports) + } + if ports == 0 { + // Project do not expose any port (batch jobs?) + // So no need to create a LoadBalancer + return "" + } + + // load balancer names are limited to 32 characters total + loadBalancerName := fmt.Sprintf("%.32s", fmt.Sprintf("%sLoadBalancer", strings.Title(project.Name))) + // Create LoadBalancer if `ParameterLoadBalancerName` is not set + template.Conditions["CreateLoadBalancer"] = cloudformation.Equals("", cloudformation.Ref(ParameterLoadBalancerARN)) + + loadBalancerType := getLoadBalancerType(project) + securityGroups := []string{} + if loadBalancerType == elbv2.LoadBalancerTypeEnumApplication { + securityGroups = getLoadBalancerSecurityGroups(project, template) + } + + template.Resources[loadBalancerName] = &elasticloadbalancingv2.LoadBalancer{ + Name: loadBalancerName, + Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing, + SecurityGroups: securityGroups, + Subnets: []string{ + cloudformation.Ref(ParameterSubnet1Id), + cloudformation.Ref(ParameterSubnet2Id), + }, + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + }, + Type: loadBalancerType, + AWSCloudFormationCondition: "CreateLoadBalancer", + } + return cloudformation.If("CreateLoadBalancer", cloudformation.Ref(loadBalancerName), cloudformation.Ref(ParameterLoadBalancerARN)) +} + +func createListener(service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, targetGroupName string, loadBalancerARN string, protocol string) string { + listenerName := fmt.Sprintf( + "%s%s%dListener", + normalizeResourceName(service.Name), + strings.ToUpper(port.Protocol), + port.Target, + ) + //add listener to dependsOn + //https://stackoverflow.com/questions/53971873/the-target-group-does-not-have-an-associated-load-balancer + template.Resources[listenerName] = &elasticloadbalancingv2.Listener{ + DefaultActions: []elasticloadbalancingv2.Listener_Action{ + { + ForwardConfig: &elasticloadbalancingv2.Listener_ForwardConfig{ + TargetGroups: []elasticloadbalancingv2.Listener_TargetGroupTuple{ + { + TargetGroupArn: cloudformation.Ref(targetGroupName), + }, + }, + }, + Type: elbv2.ActionTypeEnumForward, + }, + }, + LoadBalancerArn: loadBalancerARN, + Protocol: protocol, + Port: int(port.Target), + } + return listenerName +} + +func createTargetGroup(project *types.Project, service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, protocol string) string { + targetGroupName := fmt.Sprintf( + "%s%s%dTargetGroup", + normalizeResourceName(service.Name), + strings.ToUpper(port.Protocol), + port.Published, + ) + template.Resources[targetGroupName] = &elasticloadbalancingv2.TargetGroup{ + Port: int(port.Target), + Protocol: protocol, + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + }, + VpcId: cloudformation.Ref(ParameterVPCId), + TargetType: elbv2.TargetTypeEnumIp, + } + return targetGroupName +} + +func createServiceRegistry(service types.ServiceConfig, template *cloudformation.Template, healthCheck *cloudmap.Service_HealthCheckConfig) ecs.Service_ServiceRegistry { + serviceRegistration := fmt.Sprintf("%sServiceDiscoveryEntry", normalizeResourceName(service.Name)) + serviceRegistry := ecs.Service_ServiceRegistry{ + RegistryArn: cloudformation.GetAtt(serviceRegistration, "Arn"), + } + + template.Resources[serviceRegistration] = &cloudmap.Service{ + Description: fmt.Sprintf("%q service discovery entry in Cloud Map", service.Name), + HealthCheckConfig: healthCheck, + HealthCheckCustomConfig: &cloudmap.Service_HealthCheckCustomConfig{ + FailureThreshold: 1, + }, + Name: service.Name, + NamespaceId: cloudformation.Ref("CloudMap"), + DnsConfig: &cloudmap.Service_DnsConfig{ + DnsRecords: []cloudmap.Service_DnsRecord{ + { + TTL: 60, + Type: cloudmapapi.RecordTypeA, + }, + }, + RoutingPolicy: cloudmapapi.RoutingPolicyMultivalue, + }, + } + return serviceRegistry +} + +func createTaskExecutionRole(service types.ServiceConfig, err error, definition *ecs.TaskDefinition, template *cloudformation.Template) (string, error) { + taskExecutionRole := fmt.Sprintf("%sTaskExecutionRole", normalizeResourceName(service.Name)) + policy, err := getPolicy(definition) + if err != nil { + return taskExecutionRole, err + } + rolePolicies := []iam.Role_Policy{} + if policy != nil { + rolePolicies = append(rolePolicies, iam.Role_Policy{ + PolicyDocument: policy, + PolicyName: fmt.Sprintf("%sGrantAccessToSecrets", service.Name), + }) + } + + if roles, ok := service.Extensions[compose.ExtensionRole]; ok { + rolePolicies = append(rolePolicies, iam.Role_Policy{ + PolicyDocument: roles, + }) + } + managedPolicies := []string{ + ECSTaskExecutionPolicy, + ECRReadOnlyPolicy, + } + if v, ok := service.Extensions[compose.ExtensionManagedPolicies]; ok { + for _, s := range v.([]interface{}) { + managedPolicies = append(managedPolicies, s.(string)) + } + } + template.Resources[taskExecutionRole] = &iam.Role{ + AssumeRolePolicyDocument: assumeRolePolicyDocument, + Policies: rolePolicies, + ManagedPolicyArns: managedPolicies, + } + return taskExecutionRole, nil +} + +func createCluster(project *types.Project, template *cloudformation.Template) string { + template.Resources["Cluster"] = &ecs.Cluster{ + ClusterName: project.Name, + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + }, + AWSCloudFormationCondition: "CreateCluster", + } + cluster := cloudformation.If("CreateCluster", cloudformation.Ref("Cluster"), cloudformation.Ref(ParameterClusterName)) + return cluster +} + +func createCloudMap(project *types.Project, template *cloudformation.Template) { + template.Resources["CloudMap"] = &cloudmap.PrivateDnsNamespace{ + Description: fmt.Sprintf("Service Map for Docker Compose project %s", project.Name), + Name: fmt.Sprintf("%s.local", project.Name), + Vpc: cloudformation.Ref(ParameterVPCId), + } +} + +func convertNetwork(project *types.Project, net types.NetworkConfig, vpc string, template *cloudformation.Template) string { + if sg, ok := net.Extensions[compose.ExtensionSecurityGroup]; ok { + logrus.Debugf("Security Group for network %q set by user to %q", net.Name, sg) + return sg.(string) + } + + var ingresses []ec2.SecurityGroup_Ingress + if !net.Internal { + for _, service := range project.Services { + if _, ok := service.Networks[net.Name]; ok { + for _, port := range service.Ports { + ingresses = append(ingresses, ec2.SecurityGroup_Ingress{ + CidrIp: "0.0.0.0/0", + Description: fmt.Sprintf("%s:%d/%s", service.Name, port.Target, port.Protocol), + FromPort: int(port.Target), + IpProtocol: strings.ToUpper(port.Protocol), + ToPort: int(port.Target), + }) + } + } + } + } + + securityGroup := networkResourceName(project, net.Name) + template.Resources[securityGroup] = &ec2.SecurityGroup{ + GroupDescription: fmt.Sprintf("%s %s Security Group", project.Name, net.Name), + GroupName: securityGroup, + SecurityGroupIngress: ingresses, + VpcId: vpc, + Tags: []tags.Tag{ + { + Key: compose.ProjectTag, + Value: project.Name, + }, + { + Key: compose.NetworkTag, + Value: net.Name, + }, + }, + } + + ingress := securityGroup + "Ingress" + template.Resources[ingress] = &ec2.SecurityGroupIngress{ + Description: fmt.Sprintf("Allow communication within network %s", net.Name), + IpProtocol: "-1", // all protocols + GroupId: cloudformation.Ref(securityGroup), + SourceSecurityGroupId: cloudformation.Ref(securityGroup), + } + + return cloudformation.Ref(securityGroup) +} + +func networkResourceName(project *types.Project, network string) string { + return fmt.Sprintf("%s%sNetwork", normalizeResourceName(project.Name), normalizeResourceName(network)) +} + +func serviceResourceName(dependency string) string { + return fmt.Sprintf("%sService", normalizeResourceName(dependency)) +} + +func normalizeResourceName(s string) string { + return strings.Title(regexp.MustCompile("[^a-zA-Z0-9]+").ReplaceAllString(s, "")) +} + +func getPolicy(taskDef *ecs.TaskDefinition) (*PolicyDocument, error) { + arns := []string{} + for _, container := range taskDef.ContainerDefinitions { + if container.RepositoryCredentials != nil { + arns = append(arns, container.RepositoryCredentials.CredentialsParameter) + } + if len(container.Secrets) > 0 { + for _, s := range container.Secrets { + arns = append(arns, s.ValueFrom) + } + } + + } + if len(arns) > 0 { + return &PolicyDocument{ + Statement: []PolicyStatement{ + { + Effect: "Allow", + Action: []string{ActionGetSecretValue, ActionGetParameters, ActionDecrypt}, + Resource: arns, + }}, + }, nil + } + return nil, nil +} + +func uniqueStrings(items []string) []string { + keys := make(map[string]bool) + unique := []string{} + for _, item := range items { + if _, val := keys[item]; !val { + keys[item] = true + unique = append(unique, item) + } + } + return unique +} diff --git a/ecs/pkg/amazon/backend/cloudformation_test.go b/ecs/pkg/amazon/backend/cloudformation_test.go new file mode 100644 index 000000000..307b60364 --- /dev/null +++ b/ecs/pkg/amazon/backend/cloudformation_test.go @@ -0,0 +1,373 @@ +package backend + +import ( + "fmt" + "reflect" + "testing" + + "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/awslabs/goformation/v4/cloudformation" + "github.com/awslabs/goformation/v4/cloudformation/ec2" + "github.com/awslabs/goformation/v4/cloudformation/ecs" + "github.com/awslabs/goformation/v4/cloudformation/elasticloadbalancingv2" + "github.com/awslabs/goformation/v4/cloudformation/iam" + "github.com/awslabs/goformation/v4/cloudformation/logs" + "github.com/compose-spec/compose-go/cli" + "github.com/compose-spec/compose-go/loader" + "github.com/compose-spec/compose-go/types" + "github.com/docker/ecs-plugin/pkg/compose" + "gotest.tools/v3/assert" + "gotest.tools/v3/golden" +) + +func TestSimpleConvert(t *testing.T) { + project := load(t, "testdata/input/simple-single-service.yaml") + result := convertResultAsString(t, project) + expected := "simple/simple-cloudformation-conversion.golden" + golden.Assert(t, result, expected) +} + +func TestLogging(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + logging: + options: + awslogs-datetime-pattern: "FOO" + +x-aws-logs_retention: 10 +`) + def := template.Resources["FooTaskDefinition"].(*ecs.TaskDefinition) + logging := def.ContainerDefinitions[0].LogConfiguration + assert.Equal(t, logging.Options["awslogs-datetime-pattern"], "FOO") + + logGroup := template.Resources["LogGroup"].(*logs.LogGroup) + assert.Equal(t, logGroup.RetentionInDays, 10) +} + +func TestEnvFile(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + env_file: + - testdata/input/envfile +`) + def := template.Resources["FooTaskDefinition"].(*ecs.TaskDefinition) + env := def.ContainerDefinitions[0].Environment + var found bool + for _, pair := range env { + if pair.Name == "FOO" { + assert.Equal(t, pair.Value, "BAR") + found = true + } + } + assert.Check(t, found, "environment variable FOO not set") +} + +func TestEnvFileAndEnv(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + env_file: + - testdata/input/envfile + environment: + - "FOO=ZOT" +`) + def := template.Resources["FooTaskDefinition"].(*ecs.TaskDefinition) + env := def.ContainerDefinitions[0].Environment + var found bool + for _, pair := range env { + if pair.Name == "FOO" { + assert.Equal(t, pair.Value, "ZOT") + found = true + } + } + assert.Check(t, found, "environment variable FOO not set") +} + +func TestRollingUpdateLimits(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + deploy: + replicas: 4 + update_config: + parallelism: 2 +`) + service := template.Resources["FooService"].(*ecs.Service) + assert.Check(t, service.DeploymentConfiguration.MaximumPercent == 150) + assert.Check(t, service.DeploymentConfiguration.MinimumHealthyPercent == 50) +} + +func TestRollingUpdateExtension(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + deploy: + update_config: + x-aws-min_percent: 25 + x-aws-max_percent: 125 +`) + service := template.Resources["FooService"].(*ecs.Service) + assert.Check(t, service.DeploymentConfiguration.MaximumPercent == 125) + assert.Check(t, service.DeploymentConfiguration.MinimumHealthyPercent == 25) +} + +func TestRolePolicy(t *testing.T) { + template := convertYaml(t, "test", ` +services: + foo: + image: hello_world + x-aws-pull_credentials: "secret" +`) + role := template.Resources["FooTaskExecutionRole"].(*iam.Role) + assert.Check(t, role != nil) + assert.Check(t, role.ManagedPolicyArns[0] == ECSTaskExecutionPolicy) + assert.Check(t, role.ManagedPolicyArns[1] == ECRReadOnlyPolicy) + // We expect an extra policy has been created for x-aws-pull_credentials + assert.Check(t, len(role.Policies) == 1) + policy := role.Policies[0].PolicyDocument.(*PolicyDocument) + expected := []string{"secretsmanager:GetSecretValue", "ssm:GetParameters", "kms:Decrypt"} + assert.DeepEqual(t, expected, policy.Statement[0].Action) + assert.DeepEqual(t, []string{"secret"}, policy.Statement[0].Resource) +} + +func TestMapNetworksToSecurityGroups(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: hello_world + networks: + - front-tier + - back-tier + +networks: + front-tier: + name: public + back-tier: + internal: true +`) + assert.Check(t, template.Resources["TestPublicNetwork"] != nil) + assert.Check(t, template.Resources["TestBacktierNetwork"] != nil) + assert.Check(t, template.Resources["TestBacktierNetworkIngress"] != nil) + ingress := template.Resources["TestPublicNetworkIngress"].(*ec2.SecurityGroupIngress) + assert.Check(t, ingress != nil) + assert.Check(t, ingress.SourceSecurityGroupId == cloudformation.Ref("TestPublicNetwork")) + +} + +func TestLoadBalancerTypeApplication(t *testing.T) { + template := convertYaml(t, "test123456789009876543211234567890", ` +services: + test: + image: nginx + ports: + - 80:80 +`) + lb := template.Resources["TestLoadBalancer"].(*elasticloadbalancingv2.LoadBalancer) + assert.Check(t, lb != nil) + assert.Check(t, len(lb.Name) <= 32) + assert.Check(t, lb.Type == elbv2.LoadBalancerTypeEnumApplication) + assert.Check(t, len(lb.SecurityGroups) > 0) +} + +func TestNoLoadBalancerIfNoPortExposed(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: nginx + foo: + image: bar +`) + for _, r := range template.Resources { + assert.Check(t, r.AWSCloudFormationType() != "AWS::ElasticLoadBalancingV2::TargetGroup") + assert.Check(t, r.AWSCloudFormationType() != "AWS::ElasticLoadBalancingV2::Listener") + assert.Check(t, r.AWSCloudFormationType() != "AWS::ElasticLoadBalancingV2::LoadBalancer") + } +} + +func TestServiceReplicas(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: nginx + deploy: + replicas: 10 +`) + s := template.Resources["TestService"].(*ecs.Service) + assert.Check(t, s != nil) + assert.Check(t, s.DesiredCount == 10) +} + +func TestTaskSizeConvert(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: nginx + deploy: + resources: + limits: + cpus: '0.5' + memory: 2048M + reservations: + cpus: '0.5' + memory: 2048M +`) + def := template.Resources["TestTaskDefinition"].(*ecs.TaskDefinition) + assert.Equal(t, def.Cpu, "512") + assert.Equal(t, def.Memory, "2048") + + template = convertYaml(t, "test", ` +services: + test: + image: nginx + deploy: + resources: + limits: + cpus: '4' + memory: 8192M + reservations: + cpus: '4' + memory: 8192M +`) + def = template.Resources["TestTaskDefinition"].(*ecs.TaskDefinition) + assert.Equal(t, def.Cpu, "4096") + assert.Equal(t, def.Memory, "8192") +} +func TestTaskSizeConvertFailure(t *testing.T) { + model := loadConfig(t, "test", ` +services: + test: + image: nginx + deploy: + resources: + limits: + cpus: '0.5' + memory: 2043248M +`) + _, err := Backend{}.Convert(model) + assert.ErrorContains(t, err, "the resources requested are not supported by ECS/Fargate") +} + +func TestLoadBalancerTypeNetwork(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: nginx + ports: + - 80:80 + - 88:88 +`) + lb := template.Resources["TestLoadBalancer"].(*elasticloadbalancingv2.LoadBalancer) + assert.Check(t, lb != nil) + assert.Check(t, lb.Type == elbv2.LoadBalancerTypeEnumNetwork) +} + +func TestServiceMapping(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: "image" + command: "command" + entrypoint: "entrypoint" + environment: + - "FOO=BAR" + cap_add: + - SYS_PTRACE + cap_drop: + - SYSLOG + init: true + user: "user" + working_dir: "working_dir" +`) + def := template.Resources["TestTaskDefinition"].(*ecs.TaskDefinition) + container := def.ContainerDefinitions[0] + assert.Equal(t, container.Image, "image") + assert.Equal(t, container.Command[0], "command") + assert.Equal(t, container.EntryPoint[0], "entrypoint") + assert.Equal(t, get(container.Environment, "FOO"), "BAR") + assert.Check(t, container.LinuxParameters.InitProcessEnabled) + assert.Equal(t, container.LinuxParameters.Capabilities.Add[0], "SYS_PTRACE") + assert.Equal(t, container.LinuxParameters.Capabilities.Drop[0], "SYSLOG") + assert.Equal(t, container.User, "user") + assert.Equal(t, container.WorkingDirectory, "working_dir") +} + +func get(l []ecs.TaskDefinition_KeyValuePair, name string) string { + for _, e := range l { + if e.Name == name { + return e.Value + } + } + return "" +} + +func TestResourcesHaveProjectTagSet(t *testing.T) { + template := convertYaml(t, "test", ` +services: + test: + image: nginx + ports: + - 80:80 + - 88:88 +`) + for _, r := range template.Resources { + tags := reflect.Indirect(reflect.ValueOf(r)).FieldByName("Tags") + if !tags.IsValid() { + continue + } + for i := 0; i < tags.Len(); i++ { + k := tags.Index(i).FieldByName("Key").String() + v := tags.Index(i).FieldByName("Value").String() + if k == compose.ProjectTag { + assert.Equal(t, v, "Test") + } + } + } +} + +func convertResultAsString(t *testing.T, project *types.Project) string { + backend, err := NewBackend("", "") + assert.NilError(t, err) + result, err := backend.Convert(project) + assert.NilError(t, err) + resultAsJSON, err := result.JSON() + assert.NilError(t, err) + return fmt.Sprintf("%s\n", string(resultAsJSON)) +} + +func load(t *testing.T, paths ...string) *types.Project { + options := cli.ProjectOptions{ + Name: t.Name(), + ConfigPaths: paths, + } + project, err := cli.ProjectFromOptions(&options) + assert.NilError(t, err) + return project +} + +func convertYaml(t *testing.T, name string, yaml string) *cloudformation.Template { + model := loadConfig(t, name, yaml) + template, err := Backend{}.Convert(model) + assert.NilError(t, err) + return template +} + +func loadConfig(t *testing.T, name string, yaml string) *types.Project { + dict, err := loader.ParseYAML([]byte(yaml)) + assert.NilError(t, err) + model, err := loader.Load(types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{ + {Config: dict}, + }, + }, func(options *loader.Options) { + options.Name = "Test" + }) + assert.NilError(t, err) + return model +} diff --git a/ecs/pkg/amazon/backend/compatibility.go b/ecs/pkg/amazon/backend/compatibility.go new file mode 100644 index 000000000..d217ca48f --- /dev/null +++ b/ecs/pkg/amazon/backend/compatibility.go @@ -0,0 +1,87 @@ +package backend + +import ( + "github.com/compose-spec/compose-go/compatibility" + "github.com/compose-spec/compose-go/types" +) + +type FargateCompatibilityChecker struct { + compatibility.AllowList +} + +var compatibleComposeAttributes = []string{ + "services.command", + "services.container_name", + "services.cap_drop", + "services.depends_on", + "services.deploy", + "services.deploy.replicas", + "services.deploy.resources.limits", + "services.deploy.resources.limits.cpus", + "services.deploy.resources.limits.memory", + "services.deploy.resources.reservations", + "services.deploy.resources.reservations.cpus", + "services.deploy.resources.reservations.memory", + "services.deploy.update_config", + "services.deploy.update_config.parallelism", + "services.entrypoint", + "services.environment", + "services.env_file", + "services.healthcheck", + "services.healthcheck.interval", + "services.healthcheck.retries", + "services.healthcheck.start_period", + "services.healthcheck.test", + "services.healthcheck.timeout", + "services.image", + "services.init", + "services.logging", + "services.logging.options", + "services.networks", + "services.ports", + "services.ports.mode", + "services.ports.target", + "services.ports.protocol", + "services.secrets", + "services.secrets.source", + "services.secrets.target", + "services.user", + "services.working_dir", + "secrets.external", + "secrets.name", + "secrets.file", +} + +func (c *FargateCompatibilityChecker) CheckImage(service *types.ServiceConfig) { + if service.Image == "" { + c.Incompatible("service %s doesn't define a Docker image to run", service.Name) + } +} + +func (c *FargateCompatibilityChecker) CheckPortsPublished(p *types.ServicePortConfig) { + if p.Published == 0 { + p.Published = p.Target + } + if p.Published != p.Target { + c.Incompatible("published port can't be set to a distinct value than container port") + } +} + +func (c *FargateCompatibilityChecker) CheckCapAdd(service *types.ServiceConfig) { + add := []string{} + for _, cap := range service.CapAdd { + switch cap { + case "SYS_PTRACE": + add = append(add, cap) + default: + c.Incompatible("ECS doesn't allow to add capability %s", cap) + } + } + service.CapAdd = add +} + +func (c *FargateCompatibilityChecker) CheckLoggingDriver(config *types.LoggingConfig) { + if config.Driver != "" && config.Driver != "awslogs" { + c.Unsupported("services.logging.driver %s is not supported", config.Driver) + } +} diff --git a/ecs/pkg/amazon/backend/context.go b/ecs/pkg/amazon/backend/context.go new file mode 100644 index 000000000..c1865117d --- /dev/null +++ b/ecs/pkg/amazon/backend/context.go @@ -0,0 +1,33 @@ +package backend + +import ( + "context" + "fmt" + + "github.com/docker/ecs-plugin/pkg/docker" +) + +const ( + ContextParamRegion = "region" + ContextParamProfile = "profile" +) + +func (b *Backend) CreateContextData(ctx context.Context, params map[string]string) (contextData interface{}, description string, err error) { + region, ok := params[ContextParamRegion] + if !ok { + return nil, "", fmt.Errorf("%q parameter is required", ContextParamRegion) + } + profile, ok := params[ContextParamProfile] + if !ok { + return nil, "", fmt.Errorf("%q parameter is required", ContextParamProfile) + } + err = b.api.CheckRequirements(ctx, region) + if err != nil { + return "", "", err + } + + return docker.AwsContext{ + Profile: profile, + Region: region, + }, "Amazon ECS context", nil +} diff --git a/ecs/pkg/amazon/backend/convert.go b/ecs/pkg/amazon/backend/convert.go new file mode 100644 index 000000000..2056dad4f --- /dev/null +++ b/ecs/pkg/amazon/backend/convert.go @@ -0,0 +1,464 @@ +package backend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + ecsapi "github.com/aws/aws-sdk-go/service/ecs" + "github.com/awslabs/goformation/v4/cloudformation" + "github.com/awslabs/goformation/v4/cloudformation/ecs" + "github.com/awslabs/goformation/v4/cloudformation/tags" + "github.com/compose-spec/compose-go/types" + "github.com/docker/cli/opts" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/docker/ecs-plugin/secrets" + "github.com/joho/godotenv" +) + +const secretsInitContainerImage = "docker/ecs-secrets-sidecar" + +func Convert(project *types.Project, service types.ServiceConfig) (*ecs.TaskDefinition, error) { + cpu, mem, err := toLimits(service) + if err != nil { + return nil, err + } + _, memReservation, err := toContainerReservation(service) + if err != nil { + return nil, err + } + credential := getRepoCredentials(service) + + // override resolve.conf search directive to also search .local + // TODO remove once ECS support hostname-only service discovery + service.Environment["LOCALDOMAIN"] = aws.String( + cloudformation.Join("", []string{ + cloudformation.Ref("AWS::Region"), + ".compute.internal", + fmt.Sprintf(" %s.local", project.Name), + })) + + logConfiguration := getLogConfiguration(service, project) + + var ( + containers []ecs.TaskDefinition_ContainerDefinition + volumes []ecs.TaskDefinition_Volume + mounts []ecs.TaskDefinition_MountPoint + initContainers []ecs.TaskDefinition_ContainerDependency + ) + if len(service.Secrets) > 0 { + initContainerName := fmt.Sprintf("%s_Secrets_InitContainer", normalizeResourceName(service.Name)) + volumes = append(volumes, ecs.TaskDefinition_Volume{ + Name: "secrets", + }) + mounts = append(mounts, ecs.TaskDefinition_MountPoint{ + ContainerPath: "/run/secrets/", + ReadOnly: true, + SourceVolume: "secrets", + }) + initContainers = append(initContainers, ecs.TaskDefinition_ContainerDependency{ + Condition: ecsapi.ContainerConditionSuccess, + ContainerName: initContainerName, + }) + + var ( + args []secrets.Secret + taskSecrets []ecs.TaskDefinition_Secret + ) + for _, s := range service.Secrets { + secretConfig := project.Secrets[s.Source] + if s.Target == "" { + s.Target = s.Source + } + taskSecrets = append(taskSecrets, ecs.TaskDefinition_Secret{ + Name: s.Target, + ValueFrom: secretConfig.Name, + }) + var keys []string + if ext, ok := secretConfig.Extensions[compose.ExtensionKeys]; ok { + if key, ok := ext.(string); ok { + keys = append(keys, key) + } else { + for _, k := range ext.([]interface{}) { + keys = append(keys, k.(string)) + } + } + } + args = append(args, secrets.Secret{ + Name: s.Target, + Keys: keys, + }) + } + command, err := json.Marshal(args) + if err != nil { + return nil, err + } + containers = append(containers, ecs.TaskDefinition_ContainerDefinition{ + Name: initContainerName, + Image: secretsInitContainerImage, + Command: []string{string(command)}, + Essential: false, // FIXME this will be ignored, see https://github.com/awslabs/goformation/issues/61#issuecomment-625139607 + LogConfiguration: logConfiguration, + MountPoints: []ecs.TaskDefinition_MountPoint{ + { + ContainerPath: "/run/secrets/", + ReadOnly: false, + SourceVolume: "secrets", + }, + }, + Secrets: taskSecrets, + }) + } + + pairs, err := createEnvironment(project, service) + if err != nil { + return nil, err + } + + containers = append(containers, ecs.TaskDefinition_ContainerDefinition{ + Command: service.Command, + DisableNetworking: service.NetworkMode == "none", + DependsOnProp: initContainers, + DnsSearchDomains: service.DNSSearch, + DnsServers: service.DNS, + DockerSecurityOptions: service.SecurityOpt, + EntryPoint: service.Entrypoint, + Environment: pairs, + Essential: true, + ExtraHosts: toHostEntryPtr(service.ExtraHosts), + FirelensConfiguration: nil, + HealthCheck: toHealthCheck(service.HealthCheck), + Hostname: service.Hostname, + Image: service.Image, + Interactive: false, + Links: nil, + LinuxParameters: toLinuxParameters(service), + LogConfiguration: logConfiguration, + MemoryReservation: memReservation, + MountPoints: mounts, + Name: service.Name, + PortMappings: toPortMappings(service.Ports), + Privileged: service.Privileged, + PseudoTerminal: service.Tty, + ReadonlyRootFilesystem: service.ReadOnly, + RepositoryCredentials: credential, + ResourceRequirements: nil, + StartTimeout: 0, + StopTimeout: durationToInt(service.StopGracePeriod), + SystemControls: toSystemControls(service.Sysctls), + Ulimits: toUlimits(service.Ulimits), + User: service.User, + VolumesFrom: nil, + WorkingDirectory: service.WorkingDir, + }) + + return &ecs.TaskDefinition{ + ContainerDefinitions: containers, + Cpu: cpu, + Family: fmt.Sprintf("%s-%s", project.Name, service.Name), + IpcMode: service.Ipc, + Memory: mem, + NetworkMode: ecsapi.NetworkModeAwsvpc, // FIXME could be set by service.NetworkMode, Fargate only supports network mode ‘awsvpc’. + PidMode: service.Pid, + PlacementConstraints: toPlacementConstraints(service.Deploy), + ProxyConfiguration: nil, + RequiresCompatibilities: []string{ecsapi.LaunchTypeFargate}, + Volumes: volumes, + }, nil +} + +func createEnvironment(project *types.Project, service types.ServiceConfig) ([]ecs.TaskDefinition_KeyValuePair, error) { + environment := map[string]*string{} + for _, f := range service.EnvFile { + if !filepath.IsAbs(f) { + f = filepath.Join(project.WorkingDir, f) + } + if _, err := os.Stat(f); os.IsNotExist(err) { + return nil, err + } + file, err := os.Open(f) + if err != nil { + return nil, err + } + defer file.Close() + + env, err := godotenv.Parse(file) + if err != nil { + return nil, err + } + for k, v := range env { + environment[k] = &v + } + } + for k, v := range service.Environment { + environment[k] = v + } + + var pairs []ecs.TaskDefinition_KeyValuePair + for k, v := range environment { + name := k + var value string + if v != nil { + value = *v + } + pairs = append(pairs, ecs.TaskDefinition_KeyValuePair{ + Name: name, + Value: value, + }) + } + return pairs, nil +} + +func getLogConfiguration(service types.ServiceConfig, project *types.Project) *ecs.TaskDefinition_LogConfiguration { + options := map[string]string{ + "awslogs-region": cloudformation.Ref("AWS::Region"), + "awslogs-group": cloudformation.Ref("LogGroup"), + "awslogs-stream-prefix": project.Name, + } + if service.Logging != nil { + for k, v := range service.Logging.Options { + if strings.HasPrefix(k, "awslogs-") { + options[k] = v + } + } + } + logConfiguration := &ecs.TaskDefinition_LogConfiguration{ + LogDriver: ecsapi.LogDriverAwslogs, + Options: options, + } + return logConfiguration +} + +func toTags(labels types.Labels) []tags.Tag { + t := []tags.Tag{} + for n, v := range labels { + t = append(t, tags.Tag{ + Key: n, + Value: v, + }) + } + return t +} + +func toSystemControls(sysctls types.Mapping) []ecs.TaskDefinition_SystemControl { + sys := []ecs.TaskDefinition_SystemControl{} + for k, v := range sysctls { + sys = append(sys, ecs.TaskDefinition_SystemControl{ + Namespace: k, + Value: v, + }) + } + return sys +} + +const MiB = 1024 * 1024 + +func toLimits(service types.ServiceConfig) (string, string, error) { + // All possible cpu/mem values for Fargate + cpuToMem := map[int64][]types.UnitBytes{ + 256: {512, 1024, 2048}, + 512: {1024, 2048, 3072, 4096}, + 1024: {2048, 3072, 4096, 5120, 6144, 7168, 8192}, + 2048: {4096, 5120, 6144, 7168, 8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384}, + 4096: {8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384, 17408, 18432, 19456, 20480, 21504, 22528, 23552, 24576, 25600, 26624, 27648, 28672, 29696, 30720}, + } + cpuLimit := "256" + memLimit := "512" + + if service.Deploy == nil { + return cpuLimit, memLimit, nil + } + + limits := service.Deploy.Resources.Limits + if limits == nil { + return cpuLimit, memLimit, nil + } + + if limits.NanoCPUs == "" { + return cpuLimit, memLimit, nil + } + + v, err := opts.ParseCPUs(limits.NanoCPUs) + if err != nil { + return "", "", err + } + + var cpus []int64 + for k := range cpuToMem { + cpus = append(cpus, k) + } + sort.Slice(cpus, func(i, j int) bool { return cpus[i] < cpus[j] }) + + for _, cpu := range cpus { + mem := cpuToMem[cpu] + if v <= cpu*MiB { + for _, m := range mem { + if limits.MemoryBytes <= m*MiB { + cpuLimit = strconv.FormatInt(cpu, 10) + memLimit = strconv.FormatInt(int64(m), 10) + return cpuLimit, memLimit, nil + } + } + } + } + return "", "", fmt.Errorf("the resources requested are not supported by ECS/Fargate") +} + +func toContainerReservation(service types.ServiceConfig) (string, int, error) { + cpuReservation := ".0" + memReservation := 0 + + if service.Deploy == nil { + return cpuReservation, memReservation, nil + } + + reservations := service.Deploy.Resources.Reservations + if reservations == nil { + return cpuReservation, memReservation, nil + } + return reservations.NanoCPUs, int(reservations.MemoryBytes / MiB), nil +} + +func toRequiresCompatibilities(isolation string) []*string { + if isolation == "" { + return nil + } + return []*string{&isolation} +} + +func toPlacementConstraints(deploy *types.DeployConfig) []ecs.TaskDefinition_TaskDefinitionPlacementConstraint { + if deploy == nil || deploy.Placement.Constraints == nil || len(deploy.Placement.Constraints) == 0 { + return nil + } + pl := []ecs.TaskDefinition_TaskDefinitionPlacementConstraint{} + for _, c := range deploy.Placement.Constraints { + pl = append(pl, ecs.TaskDefinition_TaskDefinitionPlacementConstraint{ + Expression: c, + Type: "", + }) + } + return pl +} + +func toPortMappings(ports []types.ServicePortConfig) []ecs.TaskDefinition_PortMapping { + if len(ports) == 0 { + return nil + } + m := []ecs.TaskDefinition_PortMapping{} + for _, p := range ports { + m = append(m, ecs.TaskDefinition_PortMapping{ + ContainerPort: int(p.Target), + HostPort: int(p.Published), + Protocol: p.Protocol, + }) + } + return m +} + +func toUlimits(ulimits map[string]*types.UlimitsConfig) []ecs.TaskDefinition_Ulimit { + if len(ulimits) == 0 { + return nil + } + u := []ecs.TaskDefinition_Ulimit{} + for k, v := range ulimits { + u = append(u, ecs.TaskDefinition_Ulimit{ + Name: k, + SoftLimit: v.Soft, + HardLimit: v.Hard, + }) + } + return u +} + +func toLinuxParameters(service types.ServiceConfig) *ecs.TaskDefinition_LinuxParameters { + return &ecs.TaskDefinition_LinuxParameters{ + Capabilities: toKernelCapabilities(service.CapAdd, service.CapDrop), + Devices: nil, + InitProcessEnabled: service.Init != nil && *service.Init, + MaxSwap: 0, + // FIXME SharedMemorySize: service.ShmSize, + Swappiness: 0, + Tmpfs: toTmpfs(service.Tmpfs), + } +} + +func toTmpfs(tmpfs types.StringList) []ecs.TaskDefinition_Tmpfs { + if tmpfs == nil || len(tmpfs) == 0 { + return nil + } + o := []ecs.TaskDefinition_Tmpfs{} + for _, path := range tmpfs { + o = append(o, ecs.TaskDefinition_Tmpfs{ + ContainerPath: path, + Size: 100, // size is required on ECS, unlimited by the compose spec + }) + } + return o +} + +func toKernelCapabilities(add []string, drop []string) *ecs.TaskDefinition_KernelCapabilities { + if len(add) == 0 && len(drop) == 0 { + return nil + } + return &ecs.TaskDefinition_KernelCapabilities{ + Add: add, + Drop: drop, + } + +} + +func toHealthCheck(check *types.HealthCheckConfig) *ecs.TaskDefinition_HealthCheck { + if check == nil { + return nil + } + retries := 0 + if check.Retries != nil { + retries = int(*check.Retries) + } + return &ecs.TaskDefinition_HealthCheck{ + Command: check.Test, + Interval: durationToInt(check.Interval), + Retries: retries, + StartPeriod: durationToInt(check.StartPeriod), + Timeout: durationToInt(check.Timeout), + } +} + +func durationToInt(interval *types.Duration) int { + if interval == nil { + return 0 + } + v := int(time.Duration(*interval).Seconds()) + return v +} + +func toHostEntryPtr(hosts types.HostsList) []ecs.TaskDefinition_HostEntry { + if hosts == nil || len(hosts) == 0 { + return nil + } + e := []ecs.TaskDefinition_HostEntry{} + for _, h := range hosts { + parts := strings.SplitN(h, ":", 2) // FIXME this should be handled by compose-go + e = append(e, ecs.TaskDefinition_HostEntry{ + Hostname: parts[0], + IpAddress: parts[1], + }) + } + return e +} + +func getRepoCredentials(service types.ServiceConfig) *ecs.TaskDefinition_RepositoryCredentials { + // extract registry and namespace string from image name + for key, value := range service.Extensions { + if key == compose.ExtensionPullCredentials { + return &ecs.TaskDefinition_RepositoryCredentials{CredentialsParameter: value.(string)} + } + } + return nil +} diff --git a/ecs/pkg/amazon/backend/down.go b/ecs/pkg/amazon/backend/down.go new file mode 100644 index 000000000..0d235ad73 --- /dev/null +++ b/ecs/pkg/amazon/backend/down.go @@ -0,0 +1,33 @@ +package backend + +import ( + "context" + + "github.com/compose-spec/compose-go/cli" + "github.com/docker/ecs-plugin/pkg/compose" +) + +func (b *Backend) Down(ctx context.Context, options *cli.ProjectOptions) error { + name, err := b.projectName(options) + if err != nil { + return err + } + + err = b.api.DeleteStack(ctx, name) + if err != nil { + return err + } + return b.WaitStackCompletion(ctx, name, compose.StackDelete) +} + +func (b *Backend) projectName(options *cli.ProjectOptions) (string, error) { + name := options.Name + if name == "" { + project, err := cli.ProjectFromOptions(options) + if err != nil { + return "", err + } + name = project.Name + } + return name, nil +} diff --git a/ecs/pkg/amazon/backend/iam.go b/ecs/pkg/amazon/backend/iam.go new file mode 100644 index 000000000..4b282020e --- /dev/null +++ b/ecs/pkg/amazon/backend/iam.go @@ -0,0 +1,40 @@ +package backend + +const ( + ECSTaskExecutionPolicy = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" + ECRReadOnlyPolicy = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + + ActionGetSecretValue = "secretsmanager:GetSecretValue" + ActionGetParameters = "ssm:GetParameters" + ActionDecrypt = "kms:Decrypt" +) + +var assumeRolePolicyDocument = PolicyDocument{ + Version: "2012-10-17", // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_version.html + Statement: []PolicyStatement{ + { + Effect: "Allow", + Principal: PolicyPrincipal{ + Service: "ecs-tasks.amazonaws.com", + }, + Action: []string{"sts:AssumeRole"}, + }, + }, +} + +// could alternatively depend on https://github.com/kubernetes-sigs/cluster-api-provider-aws/blob/master/cmd/clusterawsadm/api/iam/v1alpha1/types.go +type PolicyDocument struct { + Version string `json:",omitempty"` + Statement []PolicyStatement `json:",omitempty"` +} + +type PolicyStatement struct { + Effect string `json:",omitempty"` + Action []string `json:",omitempty"` + Principal PolicyPrincipal `json:",omitempty"` + Resource []string `json:",omitempty"` +} + +type PolicyPrincipal struct { + Service string `json:",omitempty"` +} diff --git a/ecs/pkg/amazon/backend/list.go b/ecs/pkg/amazon/backend/list.go new file mode 100644 index 000000000..59150314f --- /dev/null +++ b/ecs/pkg/amazon/backend/list.go @@ -0,0 +1,59 @@ +package backend + +import ( + "context" + "fmt" + "strings" + + "github.com/compose-spec/compose-go/cli" + "github.com/docker/ecs-plugin/pkg/compose" +) + +func (b *Backend) Ps(ctx context.Context, options *cli.ProjectOptions) ([]compose.ServiceStatus, error) { + projectName, err := b.projectName(options) + if err != nil { + return nil, err + } + parameters, err := b.api.ListStackParameters(ctx, projectName) + if err != nil { + return nil, err + } + cluster := parameters[ParameterClusterName] + + resources, err := b.api.ListStackResources(ctx, projectName) + if err != nil { + return nil, err + } + + servicesARN := []string{} + for _, r := range resources { + switch r.Type { + case "AWS::ECS::Service": + servicesARN = append(servicesARN, r.ARN) + case "AWS::ECS::Cluster": + cluster = r.ARN + } + } + if len(servicesARN) == 0 { + return nil, nil + } + status, err := b.api.DescribeServices(ctx, cluster, servicesARN) + if err != nil { + return nil, err + } + + for i, state := range status { + ports := []string{} + for _, lb := range state.LoadBalancers { + ports = append(ports, fmt.Sprintf( + "%s:%d->%d/%s", + lb.URL, + lb.PublishedPort, + lb.TargetPort, + strings.ToLower(lb.Protocol))) + } + state.Ports = ports + status[i] = state + } + return status, nil +} diff --git a/ecs/pkg/amazon/backend/logs.go b/ecs/pkg/amazon/backend/logs.go new file mode 100644 index 000000000..fac65ba94 --- /dev/null +++ b/ecs/pkg/amazon/backend/logs.go @@ -0,0 +1,72 @@ +package backend + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + + "github.com/compose-spec/compose-go/cli" + + "github.com/docker/ecs-plugin/pkg/console" +) + +func (b *Backend) Logs(ctx context.Context, options *cli.ProjectOptions, writer io.Writer) error { + name := options.Name + if name == "" { + project, err := cli.ProjectFromOptions(options) + if err != nil { + return err + } + name = project.Name + } + + err := b.api.GetLogs(ctx, name, &logConsumer{ + colors: map[string]console.ColorFunc{}, + width: 0, + writer: writer, + }) + if err != nil { + return err + } + + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + <-signalChan + return nil +} + +func (l *logConsumer) Log(service, container, message string) { + cf, ok := l.colors[service] + if !ok { + cf = <-console.Rainbow + l.colors[service] = cf + l.computeWidth() + } + prefix := fmt.Sprintf("%-"+strconv.Itoa(l.width)+"s |", service) + + for _, line := range strings.Split(message, "\n") { + buf := bytes.NewBufferString(fmt.Sprintf("%s %s\n", cf(prefix), line)) + l.writer.Write(buf.Bytes()) + } +} + +func (l *logConsumer) computeWidth() { + width := 0 + for n := range l.colors { + if len(n) > width { + width = len(n) + } + } + l.width = width + 3 +} + +type logConsumer struct { + colors map[string]console.ColorFunc + width int + writer io.Writer +} diff --git a/ecs/pkg/amazon/backend/secrets.go b/ecs/pkg/amazon/backend/secrets.go new file mode 100644 index 000000000..6c86e95d8 --- /dev/null +++ b/ecs/pkg/amazon/backend/secrets.go @@ -0,0 +1,23 @@ +package backend + +import ( + "context" + + "github.com/docker/ecs-plugin/pkg/compose" +) + +func (b Backend) CreateSecret(ctx context.Context, secret compose.Secret) (string, error) { + return b.api.CreateSecret(ctx, secret) +} + +func (b Backend) InspectSecret(ctx context.Context, id string) (compose.Secret, error) { + return b.api.InspectSecret(ctx, id) +} + +func (b Backend) ListSecrets(ctx context.Context) ([]compose.Secret, error) { + return b.api.ListSecrets(ctx) +} + +func (b Backend) DeleteSecret(ctx context.Context, id string, recover bool) error { + return b.api.DeleteSecret(ctx, id, recover) +} diff --git a/ecs/pkg/amazon/backend/testdata/input/envfile b/ecs/pkg/amazon/backend/testdata/input/envfile new file mode 100644 index 000000000..6ac867af7 --- /dev/null +++ b/ecs/pkg/amazon/backend/testdata/input/envfile @@ -0,0 +1 @@ +FOO=BAR diff --git a/ecs/pkg/amazon/backend/testdata/input/simple-single-service.yaml b/ecs/pkg/amazon/backend/testdata/input/simple-single-service.yaml new file mode 100644 index 000000000..448f21108 --- /dev/null +++ b/ecs/pkg/amazon/backend/testdata/input/simple-single-service.yaml @@ -0,0 +1,6 @@ +version: "3" +services: + simple: + image: nginx + ports: + - "80:80" \ No newline at end of file diff --git a/ecs/pkg/amazon/backend/testdata/invalid_network_mode.yaml b/ecs/pkg/amazon/backend/testdata/invalid_network_mode.yaml new file mode 100644 index 000000000..ce8ed8ad5 --- /dev/null +++ b/ecs/pkg/amazon/backend/testdata/invalid_network_mode.yaml @@ -0,0 +1,5 @@ +version: "3" +services: + simple: + image: nginx + network_mode: bridge \ No newline at end of file diff --git a/ecs/pkg/amazon/backend/testdata/simple/simple-cloudformation-conversion.golden b/ecs/pkg/amazon/backend/testdata/simple/simple-cloudformation-conversion.golden new file mode 100644 index 000000000..d42a2464d --- /dev/null +++ b/ecs/pkg/amazon/backend/testdata/simple/simple-cloudformation-conversion.golden @@ -0,0 +1,378 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Conditions": { + "CreateCluster": { + "Fn::Equals": [ + "", + { + "Ref": "ParameterClusterName" + } + ] + }, + "CreateLoadBalancer": { + "Fn::Equals": [ + "", + { + "Ref": "ParameterLoadBalancerARN" + } + ] + } + }, + "Description": "CloudFormation template created by Docker for deploying applications on Amazon ECS", + "Parameters": { + "ParameterClusterName": { + "Description": "Name of the ECS cluster to deploy to (optional)", + "Type": "String" + }, + "ParameterLoadBalancerARN": { + "Description": "Name of the LoadBalancer to connect to (optional)", + "Type": "String" + }, + "ParameterSubnet1Id": { + "Description": "SubnetId, for Availability Zone 1 in the region in your VPC", + "Type": "AWS::EC2::Subnet::Id" + }, + "ParameterSubnet2Id": { + "Description": "SubnetId, for Availability Zone 2 in the region in your VPC", + "Type": "AWS::EC2::Subnet::Id" + }, + "ParameterVPCId": { + "Description": "ID of the VPC", + "Type": "AWS::EC2::VPC::Id" + } + }, + "Resources": { + "CloudMap": { + "Properties": { + "Description": "Service Map for Docker Compose project TestSimpleConvert", + "Name": "TestSimpleConvert.local", + "Vpc": { + "Ref": "ParameterVPCId" + } + }, + "Type": "AWS::ServiceDiscovery::PrivateDnsNamespace" + }, + "Cluster": { + "Condition": "CreateCluster", + "Properties": { + "ClusterName": "TestSimpleConvert", + "Tags": [ + { + "Key": "com.docker.compose.project", + "Value": "TestSimpleConvert" + } + ] + }, + "Type": "AWS::ECS::Cluster" + }, + "LogGroup": { + "Properties": { + "LogGroupName": "/docker-compose/TestSimpleConvert" + }, + "Type": "AWS::Logs::LogGroup" + }, + "SimpleService": { + "DependsOn": [ + "SimpleTCP80Listener" + ], + "Properties": { + "Cluster": { + "Fn::If": [ + "CreateCluster", + { + "Ref": "Cluster" + }, + { + "Ref": "ParameterClusterName" + } + ] + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + }, + "DeploymentController": { + "Type": "ECS" + }, + "DesiredCount": 1, + "LaunchType": "FARGATE", + "LoadBalancers": [ + { + "ContainerName": "simple", + "ContainerPort": 80, + "TargetGroupArn": { + "Ref": "SimpleTCP80TargetGroup" + } + } + ], + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "ENABLED", + "SecurityGroups": [ + { + "Ref": "TestSimpleConvertDefaultNetwork" + } + ], + "Subnets": [ + { + "Ref": "ParameterSubnet1Id" + }, + { + "Ref": "ParameterSubnet2Id" + } + ] + } + }, + "PropagateTags": "SERVICE", + "SchedulingStrategy": "REPLICA", + "ServiceRegistries": [ + { + "RegistryArn": { + "Fn::GetAtt": [ + "SimpleServiceDiscoveryEntry", + "Arn" + ] + } + } + ], + "Tags": [ + { + "Key": "com.docker.compose.project", + "Value": "TestSimpleConvert" + }, + { + "Key": "com.docker.compose.service", + "Value": "simple" + } + ], + "TaskDefinition": { + "Ref": "SimpleTaskDefinition" + } + }, + "Type": "AWS::ECS::Service" + }, + "SimpleServiceDiscoveryEntry": { + "Properties": { + "Description": "\"simple\" service discovery entry in Cloud Map", + "DnsConfig": { + "DnsRecords": [ + { + "TTL": 60, + "Type": "A" + } + ], + "RoutingPolicy": "MULTIVALUE" + }, + "HealthCheckCustomConfig": { + "FailureThreshold": 1 + }, + "Name": "simple", + "NamespaceId": { + "Ref": "CloudMap" + } + }, + "Type": "AWS::ServiceDiscovery::Service" + }, + "SimpleTCP80Listener": { + "Properties": { + "DefaultActions": [ + { + "ForwardConfig": { + "TargetGroups": [ + { + "TargetGroupArn": { + "Ref": "SimpleTCP80TargetGroup" + } + } + ] + }, + "Type": "forward" + } + ], + "LoadBalancerArn": { + "Fn::If": [ + "CreateLoadBalancer", + { + "Ref": "TestSimpleConvertLoadBalancer" + }, + { + "Ref": "ParameterLoadBalancerARN" + } + ] + }, + "Port": 80, + "Protocol": "HTTP" + }, + "Type": "AWS::ElasticLoadBalancingV2::Listener" + }, + "SimpleTCP80TargetGroup": { + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "Tags": [ + { + "Key": "com.docker.compose.project", + "Value": "TestSimpleConvert" + } + ], + "TargetType": "ip", + "VpcId": { + "Ref": "ParameterVPCId" + } + }, + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup" + }, + "SimpleTaskDefinition": { + "Properties": { + "ContainerDefinitions": [ + { + "Environment": [ + { + "Name": "LOCALDOMAIN", + "Value": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::Region" + }, + ".compute.internal", + " TestSimpleConvert.local" + ] + ] + } + } + ], + "Essential": true, + "Image": "nginx", + "LinuxParameters": {}, + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "LogGroup" + }, + "awslogs-region": { + "Ref": "AWS::Region" + }, + "awslogs-stream-prefix": "TestSimpleConvert" + } + }, + "Name": "simple", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp" + } + ] + } + ], + "Cpu": "256", + "ExecutionRoleArn": { + "Ref": "SimpleTaskExecutionRole" + }, + "Family": "TestSimpleConvert-simple", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ] + }, + "Type": "AWS::ECS::TaskDefinition" + }, + "SimpleTaskExecutionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + ] + }, + "Type": "AWS::IAM::Role" + }, + "TestSimpleConvertDefaultNetwork": { + "Properties": { + "GroupDescription": "TestSimpleConvert default Security Group", + "GroupName": "TestSimpleConvertDefaultNetwork", + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "simple:80/tcp", + "FromPort": 80, + "IpProtocol": "TCP", + "ToPort": 80 + } + ], + "Tags": [ + { + "Key": "com.docker.compose.project", + "Value": "TestSimpleConvert" + }, + { + "Key": "com.docker.compose.network", + "Value": "default" + } + ], + "VpcId": { + "Ref": "ParameterVPCId" + } + }, + "Type": "AWS::EC2::SecurityGroup" + }, + "TestSimpleConvertDefaultNetworkIngress": { + "Properties": { + "Description": "Allow communication within network default", + "GroupId": { + "Ref": "TestSimpleConvertDefaultNetwork" + }, + "IpProtocol": "-1", + "SourceSecurityGroupId": { + "Ref": "TestSimpleConvertDefaultNetwork" + } + }, + "Type": "AWS::EC2::SecurityGroupIngress" + }, + "TestSimpleConvertLoadBalancer": { + "Condition": "CreateLoadBalancer", + "Properties": { + "Name": "TestSimpleConvertLoadBalancer", + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Ref": "TestSimpleConvertDefaultNetwork" + } + ], + "Subnets": [ + { + "Ref": "ParameterSubnet1Id" + }, + { + "Ref": "ParameterSubnet2Id" + } + ], + "Tags": [ + { + "Key": "com.docker.compose.project", + "Value": "TestSimpleConvert" + } + ], + "Type": "application" + }, + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer" + } + } +} diff --git a/ecs/pkg/amazon/backend/up.go b/ecs/pkg/amazon/backend/up.go new file mode 100644 index 000000000..098caaacf --- /dev/null +++ b/ecs/pkg/amazon/backend/up.go @@ -0,0 +1,146 @@ +package backend + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/compose-spec/compose-go/cli" + "github.com/compose-spec/compose-go/types" + "github.com/docker/ecs-plugin/pkg/compose" +) + +func (b *Backend) Up(ctx context.Context, options *cli.ProjectOptions) error { + project, err := cli.ProjectFromOptions(options) + if err != nil { + return err + } + + err = b.api.CheckRequirements(ctx, b.Region) + if err != nil { + return err + } + + cluster, err := b.GetCluster(ctx, project) + if err != nil { + return err + } + + template, err := b.Convert(project) + if err != nil { + return err + } + + vpc, err := b.GetVPC(ctx, project) + if err != nil { + return err + } + + subNets, err := b.api.GetSubNets(ctx, vpc) + if err != nil { + return err + } + if len(subNets) < 2 { + return fmt.Errorf("VPC %s should have at least 2 associated subnets in different availability zones", vpc) + } + + lb, err := b.GetLoadBalancer(ctx, project) + if err != nil { + return err + } + + parameters := map[string]string{ + ParameterClusterName: cluster, + ParameterVPCId: vpc, + ParameterSubnet1Id: subNets[0], + ParameterSubnet2Id: subNets[1], + ParameterLoadBalancerARN: lb, + } + + update, err := b.api.StackExists(ctx, project.Name) + if err != nil { + return err + } + operation := compose.StackCreate + if update { + operation = compose.StackUpdate + changeset, err := b.api.CreateChangeSet(ctx, project.Name, template, parameters) + if err != nil { + return err + } + err = b.api.UpdateStack(ctx, changeset) + if err != nil { + return err + } + } else { + err = b.api.CreateStack(ctx, project.Name, template, parameters) + if err != nil { + return err + } + } + + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-signalChan + fmt.Println("user interrupted deployment. Deleting stack...") + b.Down(ctx, options) + }() + + err = b.WaitStackCompletion(ctx, project.Name, operation) + return err +} + +func (b Backend) GetVPC(ctx context.Context, project *types.Project) (string, error) { + //check compose file for custom VPC selected + if vpc, ok := project.Extensions[compose.ExtensionVPC]; ok { + vpcID := vpc.(string) + ok, err := b.api.VpcExists(ctx, vpcID) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("VPC does not exist: %s", vpc) + } + return vpcID, nil + } + defaultVPC, err := b.api.GetDefaultVPC(ctx) + if err != nil { + return "", err + } + return defaultVPC, nil +} + +func (b Backend) GetLoadBalancer(ctx context.Context, project *types.Project) (string, error) { + //check compose file for custom VPC selected + if ext, ok := project.Extensions[compose.ExtensionLB]; ok { + lb := ext.(string) + ok, err := b.api.LoadBalancerExists(ctx, lb) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("load balancer does not exist: %s", lb) + } + return lb, nil + } + return "", nil +} + +func (b Backend) GetCluster(ctx context.Context, project *types.Project) (string, error) { + //check compose file for custom VPC selected + if ext, ok := project.Extensions[compose.ExtensionCluster]; ok { + cluster := ext.(string) + ok, err := b.api.ClusterExists(ctx, cluster) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("cluster does not exist: %s", cluster) + } + return cluster, nil + } + return "", nil +} diff --git a/ecs/pkg/amazon/backend/wait.go b/ecs/pkg/amazon/backend/wait.go new file mode 100644 index 000000000..dcc26772c --- /dev/null +++ b/ecs/pkg/amazon/backend/wait.go @@ -0,0 +1,93 @@ +package backend + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/docker/ecs-plugin/pkg/progress" +) + +func (b *Backend) WaitStackCompletion(ctx context.Context, name string, operation int) error { + knownEvents := map[string]struct{}{} + // progress writer + w := progress.ContextWriter(ctx) + // Get the unique Stack ID so we can collect events without getting some from previous deployments with same name + stackID, err := b.api.GetStackID(ctx, name) + if err != nil { + return err + } + + ticker := time.NewTicker(1 * time.Second) + done := make(chan bool) + go func() { + b.api.WaitStackComplete(ctx, stackID, operation) //nolint:errcheck + ticker.Stop() + done <- true + }() + + var completed bool + var stackErr error + for !completed { + select { + case <-done: + completed = true + case <-ticker.C: + } + events, err := b.api.DescribeStackEvents(ctx, stackID) + if err != nil { + return err + } + + sort.Slice(events, func(i, j int) bool { + return events[i].Timestamp.Before(*events[j].Timestamp) + }) + + for _, event := range events { + if _, ok := knownEvents[*event.EventId]; ok { + continue + } + knownEvents[*event.EventId] = struct{}{} + + resource := aws.StringValue(event.LogicalResourceId) + reason := aws.StringValue(event.ResourceStatusReason) + status := aws.StringValue(event.ResourceStatus) + progressStatus := progress.Working + + switch status { + case "CREATE_COMPLETE": + if operation == compose.StackCreate { + progressStatus = progress.Done + + } + case "UPDATE_COMPLETE": + if operation == compose.StackUpdate { + progressStatus = progress.Done + } + case "DELETE_COMPLETE": + if operation == compose.StackDelete { + progressStatus = progress.Done + } + default: + if strings.HasSuffix(status, "_FAILED") { + progressStatus = progress.Error + if stackErr == nil { + operation = compose.StackDelete + stackErr = fmt.Errorf(reason) + } + } + } + w.Event(progress.Event{ + ID: resource, + Status: progressStatus, + StatusText: status, + }) + } + } + + return stackErr +} diff --git a/ecs/pkg/amazon/cloudformation/marshall.go b/ecs/pkg/amazon/cloudformation/marshall.go new file mode 100644 index 000000000..034bf8097 --- /dev/null +++ b/ecs/pkg/amazon/cloudformation/marshall.go @@ -0,0 +1,45 @@ +package cloudformation + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/awslabs/goformation/v4/cloudformation" +) + +func Marshall(template *cloudformation.Template) ([]byte, error) { + raw, err := template.JSON() + if err != nil { + return nil, err + } + + var unmarshalled interface{} + if err := json.Unmarshal(raw, &unmarshalled); err != nil { + return nil, fmt.Errorf("invalid JSON: %s", err) + } + + if input, ok := unmarshalled.(map[string]interface{}); ok { + if resources, ok := input["Resources"]; ok { + for _, uresource := range resources.(map[string]interface{}) { + if resource, ok := uresource.(map[string]interface{}); ok { + if resource["Type"] == "AWS::ECS::TaskDefinition" { + properties := resource["Properties"].(map[string]interface{}) + for _, def := range properties["ContainerDefinitions"].([]interface{}) { + containerDefinition := def.(map[string]interface{}) + if strings.HasSuffix(containerDefinition["Name"].(string), "_InitContainer") { + containerDefinition["Essential"] = "false" + } + } + } + } + } + } + } + + raw, err = json.MarshalIndent(unmarshalled, "", " ") + if err != nil { + return nil, fmt.Errorf("invalid JSON: %s", err) + } + return raw, err +} diff --git a/ecs/pkg/amazon/sdk/api.go b/ecs/pkg/amazon/sdk/api.go new file mode 100644 index 000000000..af6dfed45 --- /dev/null +++ b/ecs/pkg/amazon/sdk/api.go @@ -0,0 +1,42 @@ +package sdk + +import ( + "context" + + cf "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/awslabs/goformation/v4/cloudformation" + "github.com/docker/ecs-plugin/pkg/compose" +) + +type API interface { + CheckRequirements(ctx context.Context, region string) error + + GetDefaultVPC(ctx context.Context) (string, error) + VpcExists(ctx context.Context, vpcID string) (bool, error) + GetSubNets(ctx context.Context, vpcID string) ([]string, error) + + StackExists(ctx context.Context, name string) (bool, error) + CreateStack(ctx context.Context, name string, template *cloudformation.Template, parameters map[string]string) error + DeleteStack(ctx context.Context, name string) error + ListStackParameters(ctx context.Context, name string) (map[string]string, error) + ListStackResources(ctx context.Context, name string) ([]compose.StackResource, error) + GetStackID(ctx context.Context, name string) (string, error) + WaitStackComplete(ctx context.Context, name string, operation int) error + DescribeStackEvents(ctx context.Context, stackID string) ([]*cf.StackEvent, error) + CreateChangeSet(ctx context.Context, name string, template *cloudformation.Template, parameters map[string]string) (string, error) + UpdateStack(ctx context.Context, changeset string) error + + DescribeServices(ctx context.Context, cluster string, arns []string) ([]compose.ServiceStatus, error) + + LoadBalancerExists(ctx context.Context, arn string) (bool, error) + GetLoadBalancerURL(ctx context.Context, arn string) (string, error) + + ClusterExists(ctx context.Context, name string) (bool, error) + + GetLogs(ctx context.Context, name string, consumer compose.LogConsumer) error + + CreateSecret(ctx context.Context, secret compose.Secret) (string, error) + InspectSecret(ctx context.Context, id string) (compose.Secret, error) + ListSecrets(ctx context.Context) ([]compose.Secret, error) + DeleteSecret(ctx context.Context, id string, recover bool) error +} diff --git a/ecs/pkg/amazon/sdk/sdk.go b/ecs/pkg/amazon/sdk/sdk.go new file mode 100644 index 000000000..057e9fffb --- /dev/null +++ b/ecs/pkg/amazon/sdk/sdk.go @@ -0,0 +1,592 @@ +package sdk + +import ( + "context" + "fmt" + "strings" + "time" + + cloudformation2 "github.com/docker/ecs-plugin/pkg/amazon/cloudformation" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/aws/aws-sdk-go/service/elbv2/elbv2iface" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/iam/iamiface" + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" + cf "github.com/awslabs/goformation/v4/cloudformation" + "github.com/docker/ecs-plugin/internal" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/sirupsen/logrus" +) + +type sdk struct { + sess *session.Session + ECS ecsiface.ECSAPI + EC2 ec2iface.EC2API + ELB elbv2iface.ELBV2API + CW cloudwatchlogsiface.CloudWatchLogsAPI + IAM iamiface.IAMAPI + CF cloudformationiface.CloudFormationAPI + SM secretsmanageriface.SecretsManagerAPI +} + +func NewAPI(sess *session.Session) API { + sess.Handlers.Build.PushBack(func(r *request.Request) { + request.AddToUserAgent(r, fmt.Sprintf("Docker CLI %s", internal.Version)) + }) + return sdk{ + ECS: ecs.New(sess), + EC2: ec2.New(sess), + ELB: elbv2.New(sess), + CW: cloudwatchlogs.New(sess), + IAM: iam.New(sess), + CF: cloudformation.New(sess), + SM: secretsmanager.New(sess), + } +} + +func (s sdk) CheckRequirements(ctx context.Context, region string) error { + settings, err := s.ECS.ListAccountSettingsWithContext(ctx, &ecs.ListAccountSettingsInput{ + EffectiveSettings: aws.Bool(true), + Name: aws.String("serviceLongArnFormat"), + }) + if err != nil { + return err + } + serviceLongArnFormat := settings.Settings[0].Value + if *serviceLongArnFormat != "enabled" { + return fmt.Errorf("this tool requires the \"new ARN resource ID format\".\n"+ + "Check https://%s.console.aws.amazon.com/ecs/home#/settings\n"+ + "Learn more: https://aws.amazon.com/blogs/compute/migrating-your-amazon-ecs-deployment-to-the-new-arn-and-resource-id-format-2", region) + } + return nil +} + +func (s sdk) ClusterExists(ctx context.Context, name string) (bool, error) { + logrus.Debug("CheckRequirements if cluster was already created: ", name) + clusters, err := s.ECS.DescribeClustersWithContext(ctx, &ecs.DescribeClustersInput{ + Clusters: []*string{aws.String(name)}, + }) + if err != nil { + return false, err + } + return len(clusters.Clusters) > 0, nil +} + +func (s sdk) CreateCluster(ctx context.Context, name string) (string, error) { + logrus.Debug("Create cluster ", name) + response, err := s.ECS.CreateClusterWithContext(ctx, &ecs.CreateClusterInput{ClusterName: aws.String(name)}) + if err != nil { + return "", err + } + return *response.Cluster.Status, nil +} + +func (s sdk) VpcExists(ctx context.Context, vpcID string) (bool, error) { + logrus.Debug("CheckRequirements if VPC exists: ", vpcID) + _, err := s.EC2.DescribeVpcsWithContext(ctx, &ec2.DescribeVpcsInput{VpcIds: []*string{&vpcID}}) + return err == nil, err +} + +func (s sdk) GetDefaultVPC(ctx context.Context) (string, error) { + logrus.Debug("Retrieve default VPC") + vpcs, err := s.EC2.DescribeVpcsWithContext(ctx, &ec2.DescribeVpcsInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String("isDefault"), + Values: []*string{aws.String("true")}, + }, + }, + }) + if err != nil { + return "", err + } + if len(vpcs.Vpcs) == 0 { + return "", fmt.Errorf("account has not default VPC") + } + return *vpcs.Vpcs[0].VpcId, nil +} + +func (s sdk) GetSubNets(ctx context.Context, vpcID string) ([]string, error) { + logrus.Debug("Retrieve SubNets") + subnets, err := s.EC2.DescribeSubnetsWithContext(ctx, &ec2.DescribeSubnetsInput{ + DryRun: nil, + Filters: []*ec2.Filter{ + { + Name: aws.String("vpc-id"), + Values: []*string{aws.String(vpcID)}, + }, + }, + }) + if err != nil { + return nil, err + } + + ids := []string{} + for _, subnet := range subnets.Subnets { + ids = append(ids, *subnet.SubnetId) + } + return ids, nil +} + +func (s sdk) GetRoleArn(ctx context.Context, name string) (string, error) { + role, err := s.IAM.GetRoleWithContext(ctx, &iam.GetRoleInput{ + RoleName: aws.String(name), + }) + if err != nil { + return "", err + } + return *role.Role.Arn, nil +} + +func (s sdk) StackExists(ctx context.Context, name string) (bool, error) { + stacks, err := s.CF.DescribeStacksWithContext(ctx, &cloudformation.DescribeStacksInput{ + StackName: aws.String(name), + }) + if err != nil { + if strings.HasPrefix(err.Error(), fmt.Sprintf("ValidationError: Stack with id %s does not exist", name)) { + return false, nil + } + return false, nil + } + return len(stacks.Stacks) > 0, nil +} + +func (s sdk) CreateStack(ctx context.Context, name string, template *cf.Template, parameters map[string]string) error { + logrus.Debug("Create CloudFormation stack") + json, err := cloudformation2.Marshall(template) + if err != nil { + return err + } + + param := []*cloudformation.Parameter{} + for name, value := range parameters { + param = append(param, &cloudformation.Parameter{ + ParameterKey: aws.String(name), + ParameterValue: aws.String(value), + }) + } + + _, err = s.CF.CreateStackWithContext(ctx, &cloudformation.CreateStackInput{ + OnFailure: aws.String("DELETE"), + StackName: aws.String(name), + TemplateBody: aws.String(string(json)), + Parameters: param, + TimeoutInMinutes: nil, + Capabilities: []*string{ + aws.String(cloudformation.CapabilityCapabilityIam), + }, + }) + return err +} + +func (s sdk) CreateChangeSet(ctx context.Context, name string, template *cf.Template, parameters map[string]string) (string, error) { + logrus.Debug("Create CloudFormation Changeset") + json, err := cloudformation2.Marshall(template) + if err != nil { + return "", err + } + + param := []*cloudformation.Parameter{} + for name := range parameters { + param = append(param, &cloudformation.Parameter{ + ParameterKey: aws.String(name), + UsePreviousValue: aws.Bool(true), + }) + } + + update := fmt.Sprintf("Update%s", time.Now().Format("2006-01-02-15-04-05")) + changeset, err := s.CF.CreateChangeSetWithContext(ctx, &cloudformation.CreateChangeSetInput{ + ChangeSetName: aws.String(update), + ChangeSetType: aws.String(cloudformation.ChangeSetTypeUpdate), + StackName: aws.String(name), + TemplateBody: aws.String(string(json)), + Parameters: param, + Capabilities: []*string{ + aws.String(cloudformation.CapabilityCapabilityIam), + }, + }) + if err != nil { + return "", err + } + + err = s.CF.WaitUntilChangeSetCreateCompleteWithContext(ctx, &cloudformation.DescribeChangeSetInput{ + ChangeSetName: changeset.Id, + }) + return *changeset.Id, err +} + +func (s sdk) UpdateStack(ctx context.Context, changeset string) error { + desc, err := s.CF.DescribeChangeSetWithContext(ctx, &cloudformation.DescribeChangeSetInput{ + ChangeSetName: aws.String(changeset), + }) + if err != nil { + return err + } + + if strings.HasPrefix(aws.StringValue(desc.StatusReason), "The submitted information didn't contain changes.") { + return nil + } + + _, err = s.CF.ExecuteChangeSet(&cloudformation.ExecuteChangeSetInput{ + ChangeSetName: aws.String(changeset), + }) + return err +} + +func (s sdk) WaitStackComplete(ctx context.Context, name string, operation int) error { + input := &cloudformation.DescribeStacksInput{ + StackName: aws.String(name), + } + switch operation { + case compose.StackCreate: + return s.CF.WaitUntilStackCreateCompleteWithContext(ctx, input) + case compose.StackDelete: + return s.CF.WaitUntilStackDeleteCompleteWithContext(ctx, input) + default: + return fmt.Errorf("internal error: unexpected stack operation %d", operation) + } +} + +func (s sdk) GetStackID(ctx context.Context, name string) (string, error) { + stacks, err := s.CF.DescribeStacksWithContext(ctx, &cloudformation.DescribeStacksInput{ + StackName: aws.String(name), + }) + if err != nil { + return "", err + } + return *stacks.Stacks[0].StackId, nil +} + +func (s sdk) DescribeStackEvents(ctx context.Context, stackID string) ([]*cloudformation.StackEvent, error) { + // Fixme implement Paginator on Events and return as a chan(events) + events := []*cloudformation.StackEvent{} + var nextToken *string + for { + resp, err := s.CF.DescribeStackEventsWithContext(ctx, &cloudformation.DescribeStackEventsInput{ + StackName: aws.String(stackID), + NextToken: nextToken, + }) + if err != nil { + return nil, err + } + events = append(events, resp.StackEvents...) + if resp.NextToken == nil { + return events, nil + } + nextToken = resp.NextToken + } +} + +func (s sdk) ListStackParameters(ctx context.Context, name string) (map[string]string, error) { + st, err := s.CF.DescribeStacksWithContext(ctx, &cloudformation.DescribeStacksInput{ + NextToken: nil, + StackName: aws.String(name), + }) + if err != nil { + return nil, err + } + parameters := map[string]string{} + for _, parameter := range st.Stacks[0].Parameters { + parameters[aws.StringValue(parameter.ParameterKey)] = aws.StringValue(parameter.ParameterValue) + } + return parameters, nil +} + +func (s sdk) ListStackResources(ctx context.Context, name string) ([]compose.StackResource, error) { + // FIXME handle pagination + res, err := s.CF.ListStackResourcesWithContext(ctx, &cloudformation.ListStackResourcesInput{ + StackName: aws.String(name), + }) + if err != nil { + return nil, err + } + + resources := []compose.StackResource{} + for _, r := range res.StackResourceSummaries { + resources = append(resources, compose.StackResource{ + LogicalID: aws.StringValue(r.LogicalResourceId), + Type: aws.StringValue(r.ResourceType), + ARN: aws.StringValue(r.PhysicalResourceId), + Status: aws.StringValue(r.ResourceStatus), + }) + } + return resources, nil +} + +func (s sdk) DeleteStack(ctx context.Context, name string) error { + logrus.Debug("Delete CloudFormation stack") + _, err := s.CF.DeleteStackWithContext(ctx, &cloudformation.DeleteStackInput{ + StackName: aws.String(name), + }) + return err +} + +func (s sdk) CreateSecret(ctx context.Context, secret compose.Secret) (string, error) { + logrus.Debug("Create secret " + secret.Name) + secretStr, err := secret.GetCredString() + if err != nil { + return "", err + } + + response, err := s.SM.CreateSecret(&secretsmanager.CreateSecretInput{ + Name: &secret.Name, + SecretString: &secretStr, + Description: &secret.Description, + }) + if err != nil { + return "", err + } + return aws.StringValue(response.ARN), nil +} + +func (s sdk) InspectSecret(ctx context.Context, id string) (compose.Secret, error) { + logrus.Debug("Inspect secret " + id) + response, err := s.SM.DescribeSecret(&secretsmanager.DescribeSecretInput{SecretId: &id}) + if err != nil { + return compose.Secret{}, err + } + labels := map[string]string{} + for _, tag := range response.Tags { + labels[aws.StringValue(tag.Key)] = aws.StringValue(tag.Value) + } + secret := compose.Secret{ + ID: aws.StringValue(response.ARN), + Name: aws.StringValue(response.Name), + Labels: labels, + } + if response.Description != nil { + secret.Description = *response.Description + } + return secret, nil +} + +func (s sdk) ListSecrets(ctx context.Context) ([]compose.Secret, error) { + logrus.Debug("List secrets ...") + response, err := s.SM.ListSecrets(&secretsmanager.ListSecretsInput{}) + if err != nil { + return []compose.Secret{}, err + } + var secrets []compose.Secret + + for _, sec := range response.SecretList { + + labels := map[string]string{} + for _, tag := range sec.Tags { + labels[*tag.Key] = *tag.Value + } + description := "" + if sec.Description != nil { + description = *sec.Description + } + secrets = append(secrets, compose.Secret{ + ID: *sec.ARN, + Name: *sec.Name, + Labels: labels, + Description: description, + }) + } + return secrets, nil +} + +func (s sdk) DeleteSecret(ctx context.Context, id string, recover bool) error { + logrus.Debug("List secrets ...") + force := !recover + _, err := s.SM.DeleteSecret(&secretsmanager.DeleteSecretInput{SecretId: &id, ForceDeleteWithoutRecovery: &force}) + return err +} + +func (s sdk) GetLogs(ctx context.Context, name string, consumer compose.LogConsumer) error { + logGroup := fmt.Sprintf("/docker-compose/%s", name) + var startTime int64 + for { + var hasMore = true + var token *string + for hasMore { + events, err := s.CW.FilterLogEvents(&cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: aws.String(logGroup), + NextToken: token, + StartTime: aws.Int64(startTime), + }) + if err != nil { + return err + } + if events.NextToken == nil { + hasMore = false + } else { + token = events.NextToken + } + + for _, event := range events.Events { + p := strings.Split(aws.StringValue(event.LogStreamName), "/") + consumer.Log(p[1], p[2], aws.StringValue(event.Message)) + startTime = *event.IngestionTime + } + } + time.Sleep(500 * time.Millisecond) + } +} + +func (s sdk) DescribeServices(ctx context.Context, cluster string, arns []string) ([]compose.ServiceStatus, error) { + services, err := s.ECS.DescribeServicesWithContext(ctx, &ecs.DescribeServicesInput{ + Cluster: aws.String(cluster), + Services: aws.StringSlice(arns), + Include: aws.StringSlice([]string{"TAGS"}), + }) + if err != nil { + return nil, err + } + + status := []compose.ServiceStatus{} + for _, service := range services.Services { + var name string + for _, t := range service.Tags { + if *t.Key == compose.ServiceTag { + name = aws.StringValue(t.Value) + } + } + if name == "" { + return nil, fmt.Errorf("service %s doesn't have a %s tag", *service.ServiceArn, compose.ServiceTag) + } + targetGroupArns := []string{} + for _, lb := range service.LoadBalancers { + targetGroupArns = append(targetGroupArns, *lb.TargetGroupArn) + } + // getURLwithPortMapping makes 2 queries + // one to get the target groups and another for load balancers + loadBalancers, err := s.getURLWithPortMapping(ctx, targetGroupArns) + if err != nil { + return nil, err + } + status = append(status, compose.ServiceStatus{ + ID: aws.StringValue(service.ServiceName), + Name: name, + Replicas: int(aws.Int64Value(service.RunningCount)), + Desired: int(aws.Int64Value(service.DesiredCount)), + LoadBalancers: loadBalancers, + }) + } + return status, nil +} + +func (s sdk) getURLWithPortMapping(ctx context.Context, targetGroupArns []string) ([]compose.LoadBalancer, error) { + if len(targetGroupArns) == 0 { + return nil, nil + } + groups, err := s.ELB.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{ + TargetGroupArns: aws.StringSlice(targetGroupArns), + }) + if err != nil { + return nil, err + } + lbarns := []*string{} + for _, tg := range groups.TargetGroups { + lbarns = append(lbarns, tg.LoadBalancerArns...) + } + + lbs, err := s.ELB.DescribeLoadBalancersWithContext(ctx, &elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: lbarns, + }) + + if err != nil { + return nil, err + } + filterLB := func(arn *string, lbs []*elbv2.LoadBalancer) *elbv2.LoadBalancer { + if aws.StringValue(arn) == "" { + // load balancer arn is nil/"" + return nil + } + for _, lb := range lbs { + if aws.StringValue(lb.LoadBalancerArn) == aws.StringValue(arn) { + return lb + } + } + return nil + } + loadBalancers := []compose.LoadBalancer{} + for _, tg := range groups.TargetGroups { + for _, lbarn := range tg.LoadBalancerArns { + lb := filterLB(lbarn, lbs.LoadBalancers) + if lb == nil { + continue + } + loadBalancers = append(loadBalancers, compose.LoadBalancer{ + URL: aws.StringValue(lb.DNSName), + TargetPort: int(aws.Int64Value(tg.Port)), + PublishedPort: int(aws.Int64Value(tg.Port)), + Protocol: aws.StringValue(tg.Protocol), + }) + + } + } + return loadBalancers, nil +} + +func (s sdk) ListTasks(ctx context.Context, cluster string, family string) ([]string, error) { + tasks, err := s.ECS.ListTasksWithContext(ctx, &ecs.ListTasksInput{ + Cluster: aws.String(cluster), + Family: aws.String(family), + }) + if err != nil { + return nil, err + } + arns := []string{} + for _, arn := range tasks.TaskArns { + arns = append(arns, *arn) + } + return arns, nil +} + +func (s sdk) GetPublicIPs(ctx context.Context, interfaces ...string) (map[string]string, error) { + desc, err := s.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: aws.StringSlice(interfaces), + }) + if err != nil { + return nil, err + } + publicIPs := map[string]string{} + for _, interf := range desc.NetworkInterfaces { + if interf.Association != nil { + publicIPs[aws.StringValue(interf.NetworkInterfaceId)] = aws.StringValue(interf.Association.PublicIp) + } + } + return publicIPs, nil +} + +func (s sdk) LoadBalancerExists(ctx context.Context, arn string) (bool, error) { + logrus.Debug("CheckRequirements if LoadBalancer exists: ", arn) + lbs, err := s.ELB.DescribeLoadBalancersWithContext(ctx, &elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: []*string{aws.String(arn)}, + }) + if err != nil { + return false, err + } + return len(lbs.LoadBalancers) > 0, nil +} + +func (s sdk) GetLoadBalancerURL(ctx context.Context, arn string) (string, error) { + logrus.Debug("Retrieve load balancer URL: ", arn) + lbs, err := s.ELB.DescribeLoadBalancersWithContext(ctx, &elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: []*string{aws.String(arn)}, + }) + if err != nil { + return "", err + } + dnsName := aws.StringValue(lbs.LoadBalancers[0].DNSName) + if dnsName == "" { + return "", fmt.Errorf("Load balancer %s doesn't have a dns name", aws.StringValue(lbs.LoadBalancers[0].LoadBalancerArn)) + } + return dnsName, nil +} diff --git a/ecs/pkg/compose/api.go b/ecs/pkg/compose/api.go new file mode 100644 index 000000000..77f170559 --- /dev/null +++ b/ecs/pkg/compose/api.go @@ -0,0 +1,26 @@ +package compose + +import ( + "context" + "io" + + "github.com/awslabs/goformation/v4/cloudformation" + "github.com/compose-spec/compose-go/cli" + "github.com/compose-spec/compose-go/types" +) + +type API interface { + Up(ctx context.Context, options *cli.ProjectOptions) error + Down(ctx context.Context, options *cli.ProjectOptions) error + + CreateContextData(ctx context.Context, params map[string]string) (contextData interface{}, description string, err error) + + Convert(project *types.Project) (*cloudformation.Template, error) + Logs(ctx context.Context, options *cli.ProjectOptions, writer io.Writer) error + Ps(ctx context.Context, options *cli.ProjectOptions) ([]ServiceStatus, error) + + CreateSecret(ctx context.Context, secret Secret) (string, error) + InspectSecret(ctx context.Context, id string) (Secret, error) + ListSecrets(ctx context.Context) ([]Secret, error) + DeleteSecret(ctx context.Context, id string, recover bool) error +} diff --git a/ecs/pkg/compose/tags.go b/ecs/pkg/compose/tags.go new file mode 100644 index 000000000..43236d45f --- /dev/null +++ b/ecs/pkg/compose/tags.go @@ -0,0 +1,7 @@ +package compose + +const ( + ProjectTag = "com.docker.compose.project" + NetworkTag = "com.docker.compose.network" + ServiceTag = "com.docker.compose.service" +) diff --git a/ecs/pkg/compose/testdata/simple/compose-with-overrides.yaml b/ecs/pkg/compose/testdata/simple/compose-with-overrides.yaml new file mode 100644 index 000000000..3dc8a0b6f --- /dev/null +++ b/ecs/pkg/compose/testdata/simple/compose-with-overrides.yaml @@ -0,0 +1,4 @@ +version: "3" +services: + simple: + image: haproxy diff --git a/ecs/pkg/compose/testdata/simple/compose.yaml b/ecs/pkg/compose/testdata/simple/compose.yaml new file mode 100644 index 000000000..4b3f9af21 --- /dev/null +++ b/ecs/pkg/compose/testdata/simple/compose.yaml @@ -0,0 +1,4 @@ +version: "3" +services: + simple: + image: nginx \ No newline at end of file diff --git a/ecs/pkg/compose/types.go b/ecs/pkg/compose/types.go new file mode 100644 index 000000000..807b9c7ad --- /dev/null +++ b/ecs/pkg/compose/types.go @@ -0,0 +1,74 @@ +package compose + +import "encoding/json" + +type StackResource struct { + LogicalID string + Type string + ARN string + Status string +} + +type LoadBalancer struct { + URL string + TargetPort int + PublishedPort int + Protocol string +} + +type ServiceStatus struct { + ID string + Name string + Replicas int + Desired int + Ports []string + LoadBalancers []LoadBalancer +} + +const ( + StackCreate = iota + StackUpdate + StackDelete +) + +type LogConsumer interface { + Log(service, container, message string) +} + +type Secret struct { + ID string `json:"ID"` + Name string `json:"Name"` + Labels map[string]string `json:"Labels"` + Description string `json:"Description"` + username string + password string +} + +func NewSecret(name, username, password, description string) Secret { + return Secret{ + Name: name, + username: username, + password: password, + Description: description, + } +} + +func (s Secret) ToJSON() (string, error) { + b, err := json.MarshalIndent(&s, "", "\t") + if err != nil { + return "", err + } + return string(b), nil +} + +func (s Secret) GetCredString() (string, error) { + creds := map[string]string{ + "username": s.username, + "password": s.password, + } + b, err := json.Marshal(&creds) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/ecs/pkg/compose/x.go b/ecs/pkg/compose/x.go new file mode 100644 index 000000000..3a4ffdb61 --- /dev/null +++ b/ecs/pkg/compose/x.go @@ -0,0 +1,15 @@ +package compose + +const ( + ExtensionSecurityGroup = "x-aws-securitygroup" + ExtensionVPC = "x-aws-vpc" + ExtensionPullCredentials = "x-aws-pull_credentials" + ExtensionLB = "x-aws-loadbalancer" + ExtensionCluster = "x-aws-cluster" + ExtensionKeys = "x-aws-keys" + ExtensionMinPercent = "x-aws-min_percent" + ExtensionMaxPercent = "x-aws-max_percent" + ExtensionRetention = "x-aws-logs_retention" + ExtensionRole = "x-aws-role" + ExtensionManagedPolicies = "x-aws-policies" +) diff --git a/ecs/pkg/console/colors.go b/ecs/pkg/console/colors.go new file mode 100644 index 000000000..672b61f5d --- /dev/null +++ b/ecs/pkg/console/colors.go @@ -0,0 +1,71 @@ +package console + +import ( + "fmt" + "strconv" +) + +var NAMES = []string{ + "grey", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", +} + +var COLORS map[string]ColorFunc + +// ColorFunc use ANSI codes to render colored text on console +type ColorFunc func(s string) string + +var Monochrome = func(s string) string { + return s +} + +func ansiColor(code, s string) string { + return fmt.Sprintf("%s%s%s", ansi(code), s, ansi("0")) +} + +func ansi(code string) string { + return fmt.Sprintf("\033[%sm", code) +} + +func makeColorFunc(code string) ColorFunc { + return func(s string) string { + return ansiColor(code, s) + } +} + +var Rainbow = make(chan ColorFunc) + +func init() { + COLORS = map[string]ColorFunc{} + for i, name := range NAMES { + COLORS[name] = makeColorFunc(strconv.Itoa(30 + i)) + COLORS["intense_"+name] = makeColorFunc(strconv.Itoa(30+i) + ";1") + } + + go func() { + i := 0 + rainbow := []ColorFunc{ + COLORS["cyan"], + COLORS["yellow"], + COLORS["green"], + COLORS["magenta"], + COLORS["blue"], + COLORS["intense_cyan"], + COLORS["intense_yellow"], + COLORS["intense_green"], + COLORS["intense_magenta"], + COLORS["intense_blue"], + } + + for { + Rainbow <- rainbow[i] + i = (i + 1) % len(rainbow) + } + }() +} diff --git a/ecs/pkg/docker/contextStore.go b/ecs/pkg/docker/contextStore.go new file mode 100644 index 000000000..30a12de21 --- /dev/null +++ b/ecs/pkg/docker/contextStore.go @@ -0,0 +1,76 @@ +package docker + +import ( + "fmt" + + "github.com/docker/cli/cli/command" + cliconfig "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/context/store" + "github.com/mitchellh/mapstructure" +) + +const contextType = "aws" + +type TypeContext struct { + Type string +} + +func getter() interface{} { + return &TypeContext{} +} + +type AwsContext struct { + Profile string + Region string +} + +func NewContext(name string, awsContext interface{}) error { + _, err := NewContextWithStore(name, awsContext.(AwsContext), cliconfig.ContextStoreDir()) + return err +} + +func NewContextWithStore(name string, awsContext AwsContext, contextDirectory string) (store.Store, error) { + contextStore := initContextStore(contextDirectory) + endpoints := map[string]interface{}{ + "aws": awsContext, + "docker": awsContext, + } + + metadata := store.Metadata{ + Name: name, + Endpoints: endpoints, + Metadata: TypeContext{Type: contextType}, + } + return contextStore, contextStore.CreateOrUpdate(metadata) +} + +func initContextStore(contextDir string) store.Store { + config := store.NewConfig(getter) + return store.New(contextDir, config) +} + +func checkAwsContextExists(contextName string) (*AwsContext, error) { + if contextName == command.DefaultContextName { + return nil, fmt.Errorf("can't use \"%s\" with ECS command because it is not an AWS context", contextName) + } + contextStore := initContextStore(cliconfig.ContextStoreDir()) + metadata, err := contextStore.GetMetadata(contextName) + if err != nil { + return nil, err + } + endpoint := metadata.Endpoints["aws"] + awsContext := AwsContext{} + err = mapstructure.Decode(endpoint, &awsContext) + if err != nil { + return nil, err + } + if awsContext == (AwsContext{}) { + return nil, fmt.Errorf("can't use \"%s\" with ECS command because it is not an AWS context", contextName) + } + return &awsContext, nil +} + +func GetAwsContext(dockerCli command.Cli) (*AwsContext, error) { + contextName := dockerCli.CurrentContext() + return checkAwsContextExists(contextName) +} diff --git a/ecs/pkg/progress/plain.go b/ecs/pkg/progress/plain.go new file mode 100644 index 000000000..8e476807d --- /dev/null +++ b/ecs/pkg/progress/plain.go @@ -0,0 +1,29 @@ +package progress + +import ( + "context" + "fmt" + "io" +) + +type plainWriter struct { + out io.Writer + done chan bool +} + +func (p *plainWriter) Start(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.done: + return nil + } +} + +func (p *plainWriter) Event(e Event) { + fmt.Println(e.ID, e.Text, e.StatusText) +} + +func (p *plainWriter) Stop() { + p.done <- true +} diff --git a/ecs/pkg/progress/spinner.go b/ecs/pkg/progress/spinner.go new file mode 100644 index 000000000..695a56429 --- /dev/null +++ b/ecs/pkg/progress/spinner.go @@ -0,0 +1,50 @@ +package progress + +import ( + "runtime" + "time" +) + +type spinner struct { + time time.Time + index int + chars []string + stop bool + done string +} + +func newSpinner() *spinner { + chars := []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + } + done := "⠿" + + if runtime.GOOS == "windows" { + chars = []string{"-"} + done = "-" + } + + return &spinner{ + index: 0, + time: time.Now(), + chars: chars, + done: done, + } +} + +func (s *spinner) String() string { + if s.stop { + return s.done + } + + d := time.Since(s.time) + if d.Milliseconds() > 100 { + s.index = (s.index + 1) % len(s.chars) + } + + return s.chars[s.index] +} + +func (s *spinner) Stop() { + s.stop = true +} diff --git a/ecs/pkg/progress/tty.go b/ecs/pkg/progress/tty.go new file mode 100644 index 000000000..6877e0964 --- /dev/null +++ b/ecs/pkg/progress/tty.go @@ -0,0 +1,177 @@ +package progress + +import ( + "context" + "fmt" + "io" + "runtime" + "strings" + "sync" + "time" + + "github.com/buger/goterm" + "github.com/morikuni/aec" +) + +type ttyWriter struct { + out io.Writer + events map[string]Event + eventIDs []string + repeated bool + numLines int + done chan bool + mtx *sync.RWMutex +} + +func (w *ttyWriter) Start(ctx context.Context) error { + ticker := time.NewTicker(100 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + w.print() + return ctx.Err() + case <-w.done: + w.print() + return nil + case <-ticker.C: + w.print() + } + } +} + +func (w *ttyWriter) Stop() { + w.done <- true +} + +func (w *ttyWriter) Event(e Event) { + w.mtx.Lock() + defer w.mtx.Unlock() + if !StringContains(w.eventIDs, e.ID) { + w.eventIDs = append(w.eventIDs, e.ID) + } + if _, ok := w.events[e.ID]; ok { + last := w.events[e.ID] + switch e.Status { + case Done, Error: + if last.Status != e.Status { + last.stop() + } + } + last.Status = e.Status + last.Text = e.Text + last.StatusText = e.StatusText + w.events[e.ID] = last + } else { + e.startTime = time.Now() + e.spinner = newSpinner() + w.events[e.ID] = e + } +} + +func (w *ttyWriter) print() { + w.mtx.Lock() + defer w.mtx.Unlock() + if len(w.eventIDs) == 0 { + return + } + terminalWidth := goterm.Width() + b := aec.EmptyBuilder + for i := 0; i <= w.numLines; i++ { + b = b.Up(1) + } + if !w.repeated { + b = b.Down(1) + } + w.repeated = true + fmt.Fprint(w.out, b.Column(0).ANSI) + + // Hide the cursor while we are printing + fmt.Fprint(w.out, aec.Hide) + defer fmt.Fprint(w.out, aec.Show) + + firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) + if w.numLines != 0 && numDone(w.events) == w.numLines { + firstLine = aec.Apply(firstLine, aec.BlueF) + } + fmt.Fprintln(w.out, firstLine) + + var statusPadding int + for _, v := range w.eventIDs { + l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) + if statusPadding < l { + statusPadding = l + } + } + + numLines := 0 + for _, v := range w.eventIDs { + line := lineText(w.events[v], terminalWidth, statusPadding, runtime.GOOS != "windows") + // nolint: errcheck + fmt.Fprint(w.out, line) + numLines++ + } + + w.numLines = numLines +} + +func lineText(event Event, terminalWidth, statusPadding int, color bool) string { + endTime := time.Now() + if event.Status != Working { + endTime = event.endTime + } + + elapsed := endTime.Sub(event.startTime).Seconds() + + textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + padding := statusPadding - textLen + if padding < 0 { + padding = 0 + } + text := fmt.Sprintf(" %s %s %s%s %s", + event.spinner.String(), + event.ID, + event.Text, + strings.Repeat(" ", padding), + event.StatusText, + ) + timer := fmt.Sprintf("%.1fs\n", elapsed) + o := align(text, timer, terminalWidth) + + if color { + color := aec.WhiteF + if event.Status == Done { + color = aec.BlueF + } + if event.Status == Error { + color = aec.RedF + } + return aec.Apply(o, color) + } + + return o +} + +func numDone(events map[string]Event) int { + i := 0 + for _, e := range events { + if e.Status == Done { + i++ + } + } + return i +} + +func align(l, r string, w int) string { + return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) +} + +// StringContains check if an array contains a specific value +func StringContains(array []string, needle string) bool { + for _, val := range array { + if val == needle { + return true + } + } + return false +} diff --git a/ecs/pkg/progress/writer.go b/ecs/pkg/progress/writer.go new file mode 100644 index 000000000..a5b8e1cdf --- /dev/null +++ b/ecs/pkg/progress/writer.go @@ -0,0 +1,112 @@ +package progress + +import ( + "context" + "os" + "sync" + "time" + + "github.com/containerd/console" + "github.com/moby/term" + "golang.org/x/sync/errgroup" +) + +// EventStatus indicates the status of an action +type EventStatus int + +const ( + // Working means that the current task is working + Working EventStatus = iota + // Done means that the current task is done + Done + // Error means that the current task has errored + Error +) + +// Event reprensents a progress event +type Event struct { + ID string + Text string + Status EventStatus + StatusText string + Done bool + + startTime time.Time + endTime time.Time + spinner *spinner +} + +func (e *Event) stop() { + e.endTime = time.Now() + e.spinner.Stop() +} + +// Writer can write multiple progress events +type Writer interface { + Start(context.Context) error + Stop() + Event(Event) +} + +type writerKey struct{} + +// WithContextWriter adds the writer to the context +func WithContextWriter(ctx context.Context, writer Writer) context.Context { + return context.WithValue(ctx, writerKey{}, writer) +} + +// ContextWriter returns the writer from the context +func ContextWriter(ctx context.Context) Writer { + s, _ := ctx.Value(writerKey{}).(Writer) + return s +} + +type progressFunc func(context.Context) error + +// Run will run a writer and the progress function +// in parallel +func Run(ctx context.Context, pf progressFunc) error { + eg, _ := errgroup.WithContext(ctx) + w, err := NewWriter(os.Stderr) + if err != nil { + return err + } + eg.Go(func() error { + return w.Start(context.Background()) + }) + + ctx = WithContextWriter(ctx, w) + + eg.Go(func() error { + defer w.Stop() + return pf(ctx) + }) + + return eg.Wait() +} + +// NewWriter returns a new multi-progress writer +func NewWriter(out console.File) (Writer, error) { + _, isTerminal := term.GetFdInfo(out) + + if isTerminal { + con, err := console.ConsoleFromFile(out) + if err != nil { + return nil, err + } + + return &ttyWriter{ + out: con, + eventIDs: []string{}, + events: map[string]Event{}, + repeated: false, + done: make(chan bool), + mtx: &sync.RWMutex{}, + }, nil + } + + return &plainWriter{ + out: out, + done: make(chan bool), + }, nil +} diff --git a/ecs/secrets/Dockerfile b/ecs/secrets/Dockerfile new file mode 100644 index 000000000..638e113b4 --- /dev/null +++ b/ecs/secrets/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.14.4-alpine AS builder +WORKDIR $GOPATH/src/github.com/docker/ecs-plugin/secrets +COPY . . +RUN GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/secrets main/main.go + +FROM scratch +COPY --from=builder /go/bin/secrets /secrets +ENTRYPOINT ["/secrets"] diff --git a/ecs/secrets/init.go b/ecs/secrets/init.go new file mode 100644 index 000000000..1903d3cc9 --- /dev/null +++ b/ecs/secrets/init.go @@ -0,0 +1,87 @@ +package secrets + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +type Secret struct { + Name string + Keys []string +} + +func CreateSecretFiles(secret Secret, path string) error { + value, ok := os.LookupEnv(secret.Name) + if !ok { + return fmt.Errorf("%q variable not set", secret.Name) + } + + secrets := filepath.Join(path, secret.Name) + + if len(secret.Keys) == 0 { + // raw Secret + fmt.Printf("inject Secret %q info %s\n", secret.Name, secrets) + return ioutil.WriteFile(secrets, []byte(value), 0444) + } + + var unmarshalled interface{} + err := json.Unmarshal([]byte(value), &unmarshalled) + if err != nil { + return fmt.Errorf("%q Secret is not a valid JSON document: %w", secret.Name, err) + } + + dict, ok := unmarshalled.(map[string]interface{}) + if !ok { + return fmt.Errorf("%q Secret is not a JSON dictionary: %w", secret.Name, err) + } + err = os.MkdirAll(secrets, 0755) + if err != nil { + return err + } + + if contains(secret.Keys, "*") { + var keys []string + for k := range dict { + keys = append(keys, k) + } + secret.Keys = keys + } + + for _, k := range secret.Keys { + path := filepath.Join(secrets, k) + fmt.Printf("inject Secret %q info %s\n", k, path) + + v, ok := dict[k] + if !ok { + return fmt.Errorf("%q Secret has no %q key", secret.Name, k) + } + + var raw []byte + if s, ok := v.(string); ok { + raw = []byte(s) + } else { + raw, err = json.Marshal(v) + if err != nil { + return err + } + } + + err = ioutil.WriteFile(path, raw, 0444) + if err != nil { + return err + } + } + return nil +} + +func contains(keys []string, s string) bool { + for _, k := range keys { + if k == s { + return true + } + } + return false +} diff --git a/ecs/secrets/init_test.go b/ecs/secrets/init_test.go new file mode 100644 index 000000000..dd068f5af --- /dev/null +++ b/ecs/secrets/init_test.go @@ -0,0 +1,85 @@ +package secrets + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/fs" +) + +func TestRawSecret(t *testing.T) { + dir := fs.NewDir(t, "secrets").Path() + os.Setenv("raw", "something_secret") + defer os.Unsetenv("raw") + + err := CreateSecretFiles(Secret{ + Name: "raw", + Keys: nil, + }, dir) + assert.NilError(t, err) + file, err := ioutil.ReadFile(filepath.Join(dir, "raw")) + assert.NilError(t, err) + content := string(file) + assert.Equal(t, content, "something_secret") +} + +func TestSelectedKeysSecret(t *testing.T) { + dir := fs.NewDir(t, "secrets").Path() + os.Setenv("json", ` +{ + "foo": "bar", + "zot": "qix" +}`) + defer os.Unsetenv("json") + + err := CreateSecretFiles(Secret{ + Name: "json", + Keys: []string{"foo"}, + }, dir) + assert.NilError(t, err) + file, err := ioutil.ReadFile(filepath.Join(dir, "json", "foo")) + assert.NilError(t, err) + content := string(file) + assert.Equal(t, content, "bar") + + _, err = os.Stat(filepath.Join(dir, "json", "zot")) + assert.Check(t, os.IsNotExist(err)) +} + +func TestAllKeysSecret(t *testing.T) { + dir := fs.NewDir(t, "secrets").Path() + os.Setenv("json", ` +{ + "foo": "bar", + "zot": "qix" +}`) + defer os.Unsetenv("json") + + err := CreateSecretFiles(Secret{ + Name: "json", + Keys: []string{"*"}, + }, dir) + assert.NilError(t, err) + file, err := ioutil.ReadFile(filepath.Join(dir, "json", "foo")) + assert.NilError(t, err) + content := string(file) + assert.Equal(t, content, "bar") + + file, err = ioutil.ReadFile(filepath.Join(dir, "json", "zot")) + assert.NilError(t, err) + content = string(file) + assert.Equal(t, content, "qix") +} + +func TestUnknownSecret(t *testing.T) { + dir := fs.NewDir(t, "secrets").Path() + + err := CreateSecretFiles(Secret{ + Name: "not_set", + Keys: nil, + }, dir) + assert.Check(t, err != nil) +} diff --git a/ecs/secrets/main/main.go b/ecs/secrets/main/main.go new file mode 100644 index 000000000..703f88f8e --- /dev/null +++ b/ecs/secrets/main/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/docker/ecs-plugin/secrets" +) + +const secretsFolder = "/run/secrets" + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "usage: secrets ") + os.Exit(1) + } + + var input []secrets.Secret + err := json.Unmarshal([]byte(os.Args[1]), &input) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + for _, secret := range input { + err := secrets.CreateSecretFiles(secret, secretsFolder) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + } +} diff --git a/ecs/tests/e2e_deploy_services_test.go b/ecs/tests/e2e_deploy_services_test.go new file mode 100644 index 000000000..32b6dab50 --- /dev/null +++ b/ecs/tests/e2e_deploy_services_test.go @@ -0,0 +1,65 @@ +// +build e2e + +package tests + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/docker/ecs-plugin/pkg/amazon/sdk" + "github.com/docker/ecs-plugin/pkg/docker" + "gotest.tools/v3/assert" + "gotest.tools/v3/fs" + "gotest.tools/v3/golden" + "gotest.tools/v3/icmd" +) + +const ( + composeFileName = "compose.yaml" +) + +func TestE2eDeployServices(t *testing.T) { + cmd, cleanup, awsContext := dockerCli.createTestCmd() + defer cleanup() + + composeUpSimpleService(t, cmd, awsContext) +} + +func composeUpSimpleService(t *testing.T, cmd icmd.Cmd, awsContext docker.AwsContext) { + bgContext := context.Background() + composeYAML := golden.Get(t, "input/simple-single-service.yaml") + tmpDir := fs.NewDir(t, t.Name(), + fs.WithFile(composeFileName, "", fs.WithBytes(composeYAML)), + ) + // We can't use the file added in the tmp directory because it will drop if an assertion fails + defer composeDown(t, cmd, golden.Path("input/simple-single-service.yaml")) + defer tmpDir.Remove() + + cmd.Command = dockerCli.Command("ecs", "compose", "--file="+tmpDir.Join(composeFileName), "--project-name", t.Name(), "up") + icmd.RunCmd(cmd).Assert(t, icmd.Success) + + session, err := session.NewSessionWithOptions(session.Options{ + Profile: awsContext.Profile, + Config: aws.Config{ + Region: aws.String(awsContext.Region), + }, + }) + assert.NilError(t, err) + api := sdk.NewAPI(session) + arns, err := api.ListTasks(bgContext, t.Name(), t.Name()) + assert.NilError(t, err) + tasks, err := api.DescribeTasks(bgContext, t.Name(), arns...) + publicIps, err := api.GetPublicIPs(context.Background(), tasks[0].NetworkInterface) + assert.NilError(t, err) + for _, ip := range publicIps { + icmd.RunCommand("curl", "-I", "http://"+ip).Assert(t, icmd.Success) + } + +} + +func composeDown(t *testing.T, cmd icmd.Cmd, composeFile string) { + cmd.Command = dockerCli.Command("ecs", "compose", "--file="+composeFile, "--project-name", t.Name(), "down") + icmd.RunCmd(cmd).Assert(t, icmd.Success) +} diff --git a/ecs/tests/main_test.go b/ecs/tests/main_test.go new file mode 100644 index 000000000..5849e4060 --- /dev/null +++ b/ecs/tests/main_test.go @@ -0,0 +1,126 @@ +package tests + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + + dockerConfigFile "github.com/docker/cli/cli/config/configfile" + "github.com/docker/ecs-plugin/pkg/docker" + "gotest.tools/v3/icmd" +) + +var ( + e2ePath = flag.String("e2e-path", ".", "Set path to the e2e directory") + dockerCliPath = os.Getenv("DOCKERCLI_BINARY") + dockerCli dockerCliCommand + testContextName = "testAwsContextToBeRemoved" +) + +type dockerCliCommand struct { + path string + cliPluginDir string +} + +type ConfigFileOperator func(configFile *dockerConfigFile.ConfigFile) + +func (d dockerCliCommand) createTestCmd(ops ...ConfigFileOperator) (icmd.Cmd, func(), docker.AwsContext) { + configDir, err := ioutil.TempDir("", "config") + if err != nil { + panic(err) + } + configFilePath := filepath.Join(configDir, "config.json") + config := dockerConfigFile.ConfigFile{ + CLIPluginsExtraDirs: []string{ + d.cliPluginDir, + }, + Filename: configFilePath, + } + for _, op := range ops { + op(&config) + } + configFile, err := os.Create(configFilePath) + if err != nil { + panic(err) + } + defer configFile.Close() + err = json.NewEncoder(configFile).Encode(config) + if err != nil { + panic(err) + } + + awsContext := docker.AwsContext{ + Profile: "sandbox.devtools.developer", + Region: "eu-west-3", + } + testStore, err := docker.NewContextWithStore(testContextName, awsContext, filepath.Join(configDir, "contexts")) + if err != nil { + panic(err) + } + cleanup := func() { + fmt.Println("cleanup") + testStore.Remove(testContextName) + os.RemoveAll(configDir) + } + env := append(os.Environ(), + "DOCKER_CONFIG="+configDir, + "DOCKER_CLI_EXPERIMENTAL=enabled") // TODO: Remove this once docker ecs plugin is no more experimental + return icmd.Cmd{Env: env}, cleanup, awsContext +} + +func (d dockerCliCommand) Command(args ...string) []string { + return append([]string{d.path, "--context", testContextName}, args...) +} + +func TestMain(m *testing.M) { + flag.Parse() + if err := os.Chdir(*e2ePath); err != nil { + panic(err) + } + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + dockerEcs := os.Getenv("DOCKERECS_BINARY") + if dockerEcs == "" { + dockerEcs = filepath.Join(cwd, "../dist/docker-ecs") + } + dockerEcs, err = filepath.Abs(dockerEcs) + if err != nil { + panic(err) + } + if dockerCliPath == "" { + dockerCliPath = "docker" + } else { + dockerCliPath, err = filepath.Abs(dockerCliPath) + if err != nil { + panic(err) + } + } + // Prepare docker cli to call the docker-ecs plugin binary: + // - Create a symbolic link with the dockerEcs binary to the plugin directory + cliPluginDir, err := ioutil.TempDir("", "configContent") + if err != nil { + panic(err) + } + defer os.RemoveAll(cliPluginDir) + createDockerECSSymLink(dockerEcs, cliPluginDir) + + dockerCli = dockerCliCommand{path: dockerCliPath, cliPluginDir: cliPluginDir} + os.Exit(m.Run()) +} + +func createDockerECSSymLink(dockerEcs, configDir string) { + dockerEcsExecName := "docker-ecs" + if runtime.GOOS == "windows" { + dockerEcsExecName += ".exe" + } + if err := os.Symlink(dockerEcs, filepath.Join(configDir, dockerEcsExecName)); err != nil { + panic(err) + } +} diff --git a/ecs/tests/testdata/input/simple-single-service.yaml b/ecs/tests/testdata/input/simple-single-service.yaml new file mode 100644 index 000000000..95f30b1d5 --- /dev/null +++ b/ecs/tests/testdata/input/simple-single-service.yaml @@ -0,0 +1,6 @@ +version: "3" +services: + simple: + image: nginx + ports: + - 80:80 \ No newline at end of file diff --git a/ecs/tests/testdata/plugin-usage.golden b/ecs/tests/testdata/plugin-usage.golden new file mode 100644 index 000000000..8114b6f43 --- /dev/null +++ b/ecs/tests/testdata/plugin-usage.golden @@ -0,0 +1,14 @@ + +Usage: docker ecs COMMAND + +run multi-container applications on Amazon ECS. + +Management Commands: + compose + secret Manages secrets + +Commands: + setup + version Show version. + +Run 'docker ecs COMMAND --help' for more information on a command. diff --git a/ecs/tests/version_test.go b/ecs/tests/version_test.go new file mode 100644 index 000000000..ad4d6455f --- /dev/null +++ b/ecs/tests/version_test.go @@ -0,0 +1,18 @@ +package tests + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" +) + +func TestVersionIsSet(t *testing.T) { + cmd, cleanup, _ := dockerCli.createTestCmd() + defer cleanup() + + cmd.Command = dockerCli.Command("ecs", "version") + out := icmd.RunCmd(cmd).Assert(t, icmd.Success).Stdout() + assert.Check(t, !strings.Contains(out, "unknown")) +}