diff --git a/ecs/Makefile b/ecs/Makefile index 5d3c5883d..e07af815e 100644 --- a/ecs/Makefile +++ b/ecs/Makefile @@ -2,7 +2,7 @@ clean: rm -rf dist/ build: - go build -v -o dist/docker-ecs cmd/main/main.go + go build -v -o dist/docker-ecs cmd/main.go test: ## Run tests go test ./... -v diff --git a/ecs/cmd/commands/compose.go b/ecs/cmd/commands/compose.go new file mode 100644 index 000000000..972477b3a --- /dev/null +++ b/ecs/cmd/commands/compose.go @@ -0,0 +1,117 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/docker/ecs-plugin/pkg/amazon" + "github.com/docker/ecs-plugin/pkg/compose" + "github.com/spf13/cobra" +) + +type ClusterOptions struct { + Profile string + Region string + Cluster string +} + +func ComposeCommand(clusteropts *ClusterOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "compose", + } + opts := &compose.ProjectOptions{} + opts.AddFlags(cmd.Flags()) + + cmd.AddCommand( + ConvertCommand(clusteropts, opts), + UpCommand(clusteropts, opts), + DownCommand(clusteropts, opts), + ) + return cmd +} + +type upOptions struct { + loadBalancerArn string +} + +func (o upOptions) LoadBalancerArn() *string { + if o.loadBalancerArn == "" { + return nil + } + return &o.loadBalancerArn +} + +func ConvertCommand(clusteropts *ClusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "convert", + RunE: compose.WithProject(projectOpts, func(project *compose.Project, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + template, err := client.Convert(context.Background(), project) + if err != nil { + return err + } + + j, err := template.JSON() + if err != nil { + fmt.Printf("Failed to generate JSON: %s\n", err) + } else { + fmt.Printf("%s\n", string(j)) + } + return nil + }), + } + return cmd +} + +func UpCommand(clusteropts *ClusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { + opts := upOptions{} + cmd := &cobra.Command{ + Use: "up", + RunE: compose.WithProject(projectOpts, func(project *compose.Project, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + return client.ComposeUp(context.Background(), project) + }), + } + cmd.Flags().StringVar(&opts.loadBalancerArn, "load-balancer", "", "") + return cmd +} + +type downOptions struct { + DeleteCluster bool +} + +func DownCommand(clusteropts *ClusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { + opts := downOptions{} + cmd := &cobra.Command{ + Use: "down", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + if len(args) == 0 { + project, err := compose.ProjectFromOptions(projectOpts) + if err != nil { + return err + } + return client.ComposeDown(context.Background(), project.Name, opts.DeleteCluster) + } + // project names passed as parameters + for _, name := range args { + err := client.ComposeDown(context.Background(), name, opts.DeleteCluster) + if err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.DeleteCluster, "delete-cluster", false, "Delete cluster") + return cmd +} diff --git a/ecs/cmd/commands/secret.go b/ecs/cmd/commands/secret.go new file mode 100644 index 000000000..0c228c54b --- /dev/null +++ b/ecs/cmd/commands/secret.go @@ -0,0 +1,147 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/docker/ecs-plugin/pkg/amazon" + "github.com/docker/ecs-plugin/pkg/docker" + "github.com/spf13/cobra" +) + +type createSecretOptions struct { + Label string +} + +type deleteSecretOptions struct { + recover bool +} + +func SecretCommand(clusteropts *ClusterOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "Manages secrets", + } + + cmd.AddCommand( + CreateSecret(clusteropts), + InspectSecret(clusteropts), + ListSecrets(clusteropts), + DeleteSecret(clusteropts), + ) + return cmd +} + +func CreateSecret(clusteropts *ClusterOptions) *cobra.Command { + //opts := createSecretOptions{} + cmd := &cobra.Command{ + Use: "create NAME SECRET", + Short: "Creates a secret.", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + if len(args) == 0 { + return errors.New("Missing mandatory parameter: NAME") + } + name := args[0] + secret := args[1] + id, err := client.CreateSecret(context.Background(), name, secret) + fmt.Println(id) + return err + }, + } + return cmd +} + +func InspectSecret(clusteropts *ClusterOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect ID", + Short: "Displays secret details", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + if len(args) == 0 { + return errors.New("Missing mandatory parameter: ID") + } + id := args[0] + secret, err := client.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(clusteropts *ClusterOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List secrets stored for the existing account.", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + secrets, err := client.ListSecrets(context.Background()) + if err != nil { + return err + } + + printList(os.Stdout, secrets) + return nil + }, + } + return cmd +} + +func DeleteSecret(clusteropts *ClusterOptions) *cobra.Command { + opts := deleteSecretOptions{} + cmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm", "remove"}, + Short: "Removes a secret.", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := amazon.NewClient(clusteropts.Profile, clusteropts.Cluster, clusteropts.Region) + if err != nil { + return err + } + if len(args) == 0 { + return errors.New("Missing mandatory parameter: [NAME]") + } + return client.DeleteSecret(context.Background(), args[0], opts.recover) + }, + } + cmd.Flags().BoolVar(&opts.recover, "recover", false, "Enable recovery.") + return cmd +} + +func printList(out io.Writer, secrets []docker.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/main.go b/ecs/cmd/main.go new file mode 100644 index 000000000..72d072a09 --- /dev/null +++ b/ecs/cmd/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + + "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/cli/cli-plugins/plugin" + "github.com/docker/cli/cli/command" + commands "github.com/docker/ecs-plugin/cmd/commands" + "github.com/spf13/cobra" +) + +const version = "0.0.1" + +func main() { + plugin.Run(func(dockerCli command.Cli) *cobra.Command { + cmd := NewRootCmd("ecs", dockerCli) + return cmd + }, manager.Metadata{ + SchemaVersion: "0.1.0", + Vendor: "Docker Inc.", + Version: version, + Experimental: true, + }) +} + +// NewRootCmd returns the base root command. +func NewRootCmd(name string, dockerCli command.Cli) *cobra.Command { + var opts commands.ClusterOptions + + cmd := &cobra.Command{ + Short: "Docker ECS", + Long: `run multi-container applications on Amazon ECS.`, + Use: name, + Annotations: map[string]string{"experimentalCLI": "true"}, + } + cmd.AddCommand( + VersionCommand(), + commands.ComposeCommand(&opts), + commands.SecretCommand(&opts), + ) + cmd.Flags().StringVarP(&opts.Profile, "profile", "p", "default", "AWS Profile") + cmd.Flags().StringVarP(&opts.Cluster, "cluster", "c", "default", "ECS cluster") + cmd.Flags().StringVarP(&opts.Region, "region", "r", "", "AWS region") + + return cmd +} + +func VersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show version.", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("Docker ECS plugin %s\n", version) + return nil + }, + } +} diff --git a/ecs/cmd/main/main.go b/ecs/cmd/main/main.go deleted file mode 100644 index 9326f3eee..000000000 --- a/ecs/cmd/main/main.go +++ /dev/null @@ -1,261 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - "github.com/docker/cli/cli-plugins/manager" - "github.com/docker/cli/cli-plugins/plugin" - "github.com/docker/cli/cli/command" - "github.com/docker/ecs-plugin/pkg/amazon" - "github.com/docker/ecs-plugin/pkg/compose" - "github.com/spf13/cobra" -) - -const version = "0.0.1" - -func main() { - plugin.Run(func(dockerCli command.Cli) *cobra.Command { - cmd := NewRootCmd("ecs", dockerCli) - return cmd - }, manager.Metadata{ - SchemaVersion: "0.1.0", - Vendor: "Docker Inc.", - Version: version, - Experimental: true, - }) -} - -type clusterOptions struct { - profile string - region string - cluster string -} - -// NewRootCmd returns the base root command. -func NewRootCmd(name string, dockerCli command.Cli) *cobra.Command { - var opts clusterOptions - - cmd := &cobra.Command{ - Short: "Docker ECS", - Long: `run multi-container applications on Amazon ECS.`, - Use: name, - Annotations: map[string]string{"experimentalCLI": "true"}, - } - cmd.AddCommand( - VersionCommand(), - ComposeCommand(&opts), - SecretCommand(&opts), - ) - cmd.Flags().StringVarP(&opts.profile, "profile", "p", "default", "AWS Profile") - cmd.Flags().StringVarP(&opts.cluster, "cluster", "c", "default", "ECS cluster") - cmd.Flags().StringVarP(&opts.region, "region", "r", "", "AWS region") - - return cmd -} - -func VersionCommand() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Show version.", - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Printf("Docker ECS plugin %s\n", version) - return nil - }, - } -} - -func ComposeCommand(clusteropts *clusterOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "compose", - } - opts := &compose.ProjectOptions{} - opts.AddFlags(cmd.Flags()) - - cmd.AddCommand( - ConvertCommand(clusteropts, opts), - UpCommand(clusteropts, opts), - DownCommand(clusteropts, opts), - ) - return cmd -} - -type upOptions struct { - loadBalancerArn string -} - -func (o upOptions) LoadBalancerArn() *string { - if o.loadBalancerArn == "" { - return nil - } - return &o.loadBalancerArn -} - -func ConvertCommand(clusteropts *clusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "convert", - RunE: compose.WithProject(projectOpts, func(project *compose.Project, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - template, err := client.Convert(context.Background(), project) - if err != nil { - return err - } - - j, err := template.JSON() - if err != nil { - fmt.Printf("Failed to generate JSON: %s\n", err) - } else { - fmt.Printf("%s\n", string(j)) - } - return nil - }), - } - return cmd -} - -func UpCommand(clusteropts *clusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { - opts := upOptions{} - cmd := &cobra.Command{ - Use: "up", - RunE: compose.WithProject(projectOpts, func(project *compose.Project, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - return client.ComposeUp(context.Background(), project) - }), - } - cmd.Flags().StringVar(&opts.loadBalancerArn, "load-balancer", "", "") - return cmd -} - -type downOptions struct { - DeleteCluster bool -} - -func DownCommand(clusteropts *clusterOptions, projectOpts *compose.ProjectOptions) *cobra.Command { - opts := downOptions{} - cmd := &cobra.Command{ - Use: "down", - RunE: func(cmd *cobra.Command, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - if len(args) == 0 { - project, err := compose.ProjectFromOptions(projectOpts) - if err != nil { - return err - } - return client.ComposeDown(context.Background(), project.Name, opts.DeleteCluster) - } - // project names passed as parameters - for _, name := range args { - err := client.ComposeDown(context.Background(), name, opts.DeleteCluster) - if err != nil { - return err - } - } - return nil - }, - } - cmd.Flags().BoolVar(&opts.DeleteCluster, "delete-cluster", false, "Delete cluster") - return cmd -} - -func SecretCommand(clusteropts *clusterOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "secret", - } - opts := &compose.ProjectOptions{} - opts.AddFlags(cmd.Flags()) - - cmd.AddCommand( - CreateSecret(clusteropts), - InspectSecret(clusteropts), - ListSecrets(clusteropts), - DeleteSecret(clusteropts), - ) - return cmd -} - -type createSecretOptions struct { - Label string -} - -func CreateSecret(clusteropts *clusterOptions) *cobra.Command { - //opts := createSecretOptions{} - cmd := &cobra.Command{ - Use: "create [NAME]", - RunE: func(cmd *cobra.Command, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - if len(args) == 0 { - return errors.New("Missing mandatory parameter: [NAME]") - } - name := args[0] - content := "blabla" - id, err := client.CreateSecret(context.Background(), name, content) - fmt.Println(id) - return err - }, - } - //cmd.Flags().BoolVar(&opts.Label, "label", false, "Secret label") - return cmd -} - -func InspectSecret(clusteropts *clusterOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "inspect [NAME]", - RunE: func(cmd *cobra.Command, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - if len(args) == 0 { - return errors.New("Missing mandatory parameter: [NAME]") - } - name := args[0] - return client.InspectSecret(context.Background(), name) - }, - } - return cmd -} - -func ListSecrets(clusteropts *clusterOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - return client.ListSecrets(context.Background()) - }, - } - return cmd -} - -func DeleteSecret(clusteropts *clusterOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "delete [NAME]", - RunE: func(cmd *cobra.Command, args []string) error { - client, err := amazon.NewClient(clusteropts.profile, clusteropts.cluster, clusteropts.region) - if err != nil { - return err - } - if len(args) == 0 { - return errors.New("Missing mandatory parameter: [NAME]") - } - return client.DeleteSecret(context.Background(), args[0]) - }, - } - return cmd -} diff --git a/ecs/pkg/amazon/sdk.go b/ecs/pkg/amazon/sdk.go index 2aa017e9f..8f8c34033 100644 --- a/ecs/pkg/amazon/sdk.go +++ b/ecs/pkg/amazon/sdk.go @@ -22,6 +22,8 @@ import ( "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" cf "github.com/awslabs/goformation/v4/cloudformation" "github.com/sirupsen/logrus" + + "github.com/docker/ecs-plugin/pkg/docker" ) type sdk struct { @@ -198,22 +200,68 @@ func (s sdk) DeleteStack(ctx context.Context, name string) error { return err } -func (s sdk) CreateSecret(ctx context.Context, name string, content string) (string, error) { +func (s sdk) CreateSecret(ctx context.Context, name string, secret string) (string, error) { logrus.Debug("Create secret " + name) - return "test", nil + response, err := s.SM.CreateSecret(&secretsmanager.CreateSecretInput{Name: &name, SecretString: &secret}) + if err != nil { + return "", err + } + return *response.ARN, nil } -func (s sdk) InspectSecret(ctx context.Context, name string) error { - fmt.Printf("... done. \n") - return nil +func (s sdk) InspectSecret(ctx context.Context, id string) (docker.Secret, error) { + logrus.Debug("Inspect secret " + id) + response, err := s.SM.DescribeSecret(&secretsmanager.DescribeSecretInput{SecretId: &id}) + if err != nil { + return docker.Secret{}, err + } + labels := map[string]string{} + for _, tag := range response.Tags { + labels[*tag.Key] = *tag.Value + } + secret := docker.Secret{ + ID: *response.ARN, + Name: *response.Name, + Labels: labels, + } + if response.Description != nil { + secret.Description = *response.Description + } + return secret, nil } -func (s sdk) ListSecrets(ctx context.Context) error { - fmt.Printf("... done. \n") - return nil +func (s sdk) ListSecrets(ctx context.Context) ([]docker.Secret, error) { + + logrus.Debug("List secrets ...") + response, err := s.SM.ListSecrets(&secretsmanager.ListSecretsInput{}) + if err != nil { + return []docker.Secret{}, err + } + var secrets []docker.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, docker.Secret{ + ID: *sec.ARN, + Name: *sec.Name, + Labels: labels, + Description: description, + }) + } + return secrets, nil } -func (s sdk) DeleteSecret(ctx context.Context, name string) error { - fmt.Printf("... done. \n") - return 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 } diff --git a/ecs/pkg/amazon/secrets.go b/ecs/pkg/amazon/secrets.go index daabe79bc..649705f02 100644 --- a/ecs/pkg/amazon/secrets.go +++ b/ecs/pkg/amazon/secrets.go @@ -2,27 +2,29 @@ package amazon import ( "context" + + "github.com/docker/ecs-plugin/pkg/docker" ) type secretsAPI interface { CreateSecret(ctx context.Context, name string, content string) (string, error) - InspectSecret(ctx context.Context, name string) error - ListSecrets(ctx context.Context) error - DeleteSecret(ctx context.Context, name string) error + InspectSecret(ctx context.Context, id string) (docker.Secret, error) + ListSecrets(ctx context.Context) ([]docker.Secret, error) + DeleteSecret(ctx context.Context, id string, recover bool) error } func (c client) CreateSecret(ctx context.Context, name string, content string) (string, error) { return c.api.CreateSecret(ctx, name, content) } -func (c client) InspectSecret(ctx context.Context, name string) error { - return c.api.InspectSecret(ctx, name) +func (c client) InspectSecret(ctx context.Context, id string) (docker.Secret, error) { + return c.api.InspectSecret(ctx, id) } -func (c client) ListSecrets(ctx context.Context) error { +func (c client) ListSecrets(ctx context.Context) ([]docker.Secret, error) { return c.api.ListSecrets(ctx) } -func (c client) DeleteSecret(ctx context.Context, name string) error { - return c.api.DeleteSecret(ctx, name) +func (c client) DeleteSecret(ctx context.Context, id string, recover bool) error { + return c.api.DeleteSecret(ctx, id, recover) } diff --git a/ecs/pkg/compose/api.go b/ecs/pkg/compose/api.go index 32095102c..e23651b63 100644 --- a/ecs/pkg/compose/api.go +++ b/ecs/pkg/compose/api.go @@ -4,6 +4,7 @@ import ( "context" "github.com/awslabs/goformation/v4/cloudformation" + "github.com/docker/ecs-plugin/pkg/docker" ) type API interface { @@ -11,8 +12,8 @@ type API interface { ComposeUp(ctx context.Context, project *Project) error ComposeDown(ctx context.Context, projectName string, deleteCluster bool) error - CreateSecret(ctx context.Context, name string, content string) (string, error) - InspectSecret(ctx context.Context, name string) error - ListSecrets(ctx context.Context) error - DeleteSecret(ctx context.Context, name string) error + CreateSecret(ctx context.Context, name string, secret string) (string, error) + InspectSecret(ctx context.Context, id string) (docker.Secret, error) + ListSecrets(ctx context.Context) ([]docker.Secret, error) + DeleteSecret(ctx context.Context, id string, recover bool) error } diff --git a/ecs/pkg/docker/secret.go b/ecs/pkg/docker/secret.go new file mode 100644 index 000000000..0efae5d67 --- /dev/null +++ b/ecs/pkg/docker/secret.go @@ -0,0 +1,20 @@ +package docker + +import ( + "encoding/json" +) + +type Secret struct { + ID string `json:"ID"` + Name string `json:"Name"` + Labels map[string]string `json:"Labels"` + Description string `json:"Description"` +} + +func (s Secret) ToJSON() (string, error) { + b, err := json.MarshalIndent(&s, "", "\t") + if err != nil { + return "", err + } + return string(b), nil +}