Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ deployments:
wait-for-service-stability: true # optional, defaults to false
wait-for-minutes: 5 # optional, how long to wait for service stability, defaults to 30
force-new-deployment: true # optional, defaults to false
- id: my-task-family # in case of ECS scheduled tasks, this is the ECS task definition family name
type: ecs-scheduled-task
rule: my-eventbridge-rule # the EventBridge rule that schedules this task
version: v1
event-bus-name: default # optional, defaults to "default"
version-environment-key: VERSION # optional, updates the given environment variable in the container with the version when deploying
```

Typically, you'll have one configuration file for each environment (e.g. dev, prod, staging).
Expand Down Expand Up @@ -94,12 +100,13 @@ ploy update development.yml my-service my-other-service v123

## Engines

There are currently two supported deployment engines:
There are currently three supported deployment engines:

- [AWS Lambda](https://aws.amazon.com/lambda/) (type: `lambda`) - with the code packaged as a Docker
image. Version is the image tag.
- [AWS ECS](https://aws.amazon.com/ecs/) (type: `ecs`) - with the code packaged as a Docker image.
Version is the image tag.
- [AWS ECS Scheduled Tasks](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/scheduled_tasks.html) (type: `ecs-scheduled-task`) - ECS tasks triggered on a schedule via EventBridge rules. Version is the image tag. The `id` field is the ECS task definition family name and the `rule` field is the EventBridge rule name. All targets on that rule referencing the given task definition family are updated.

## Contributing

Expand All @@ -109,7 +116,7 @@ Fork the repo, make your changes, and submit a pull request.

- Better error handling
- Add support for deploying new ECS task definitions for one-off tasks that are not part of a
service
service or a scheduled task
- Add support for other deployment engines. See `github.com/DandyDev/ploy/engine` for examples of
how engines are implemented
- Create command that serves a simple dashboard the visualizes the services that are deployed
Expand Down
185 changes: 185 additions & 0 deletions engine/ecs_scheduled_task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package engine

import (
"context"
"fmt"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ecs"
"github.com/aws/aws-sdk-go-v2/service/eventbridge"
eventbridgetypes "github.com/aws/aws-sdk-go-v2/service/eventbridge/types"
)

type EcsScheduledTaskDeployment struct {
BaseDeploymentConfig `mapstructure:",squash"`
Rule string `mapstructure:"rule"`
EventBusName string `mapstructure:"event-bus-name,omitempty"`
VersionEnvironmentKey string `mapstructure:"version-environment-key,omitempty"`
}

type ECSScheduledTaskDeploymentEngine struct {
ECSClient *ecs.Client
EventBridgeClient *eventbridge.Client
}

func (engine *ECSScheduledTaskDeploymentEngine) Type() string {
return "ecs-scheduled-task"
}

func (engine *ECSScheduledTaskDeploymentEngine) ResolveConfigStruct() Deployment {
return &EcsScheduledTaskDeployment{}
}

func (engine *ECSScheduledTaskDeploymentEngine) Deploy(config Deployment, p func(string, ...any)) error {
taskConfig := config.(*EcsScheduledTaskDeployment)

if taskConfig.Rule == "" {
return fmt.Errorf("ecs-scheduled-task '%s': 'rule' must be set", taskConfig.Id())
}

targets, err := engine.findMatchingTargets(taskConfig)
if err != nil {
return err
}

taskDefinitionOutput, err := engine.ECSClient.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
TaskDefinition: targets[0].EcsParameters.TaskDefinitionArn,
})
if err != nil {
return err
}

p("Registering new task definition for '%s' with version '%s'...", *taskDefinitionOutput.TaskDefinition.Family, taskConfig.Version())
registerOutput, err := engine.ECSClient.RegisterTaskDefinition(
context.Background(),
generateRegisterTaskDefinitionInput(taskDefinitionOutput.TaskDefinition, taskConfig.Version(), taskConfig.VersionEnvironmentKey),
)
if err != nil {
return err
}

newTaskDefArn := registerOutput.TaskDefinition.TaskDefinitionArn
p("Updating %d ECS target(s) on EventBridge rule '%s' with new task definition '%s'...", len(targets), taskConfig.Rule, *newTaskDefArn)

updatedTargets := make([]eventbridgetypes.Target, len(targets))
for i, target := range targets {
updated := target
updatedEcsParams := *target.EcsParameters
updatedEcsParams.TaskDefinitionArn = newTaskDefArn
updated.EcsParameters = &updatedEcsParams
updatedTargets[i] = updated
}

putOutput, err := engine.EventBridgeClient.PutTargets(context.Background(), &eventbridge.PutTargetsInput{
Rule: aws.String(taskConfig.Rule),
EventBusName: aws.String(engine.resolveEventBusName(taskConfig)),
Targets: updatedTargets,
})
if err != nil {
return err
}
if putOutput.FailedEntryCount > 0 {
return fmt.Errorf(
"failed to update %d target(s) on EventBridge rule '%s': %s",
putOutput.FailedEntryCount,
taskConfig.Rule,
*putOutput.FailedEntries[0].ErrorMessage,
)
}
return nil
}

func (engine *ECSScheduledTaskDeploymentEngine) CheckVersion(config Deployment) (string, error) {
taskConfig := config.(*EcsScheduledTaskDeployment)

if taskConfig.Rule == "" {
return "", fmt.Errorf("ecs-scheduled-task '%s': 'rule' must be set", taskConfig.Id())
}

targets, err := engine.findMatchingTargets(taskConfig)
if err != nil {
return "", err
}

taskDefinitionOutput, err := engine.ECSClient.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
TaskDefinition: targets[0].EcsParameters.TaskDefinitionArn,
})
if err != nil {
return "", err
}

return strings.Split(*taskDefinitionOutput.TaskDefinition.ContainerDefinitions[0].Image, ":")[1], nil
}

// findMatchingTargets returns all targets on the configured rule whose task definition family
// matches the deployment id.
func (engine *ECSScheduledTaskDeploymentEngine) findMatchingTargets(taskConfig *EcsScheduledTaskDeployment) ([]eventbridgetypes.Target, error) {
allTargets, err := engine.listAllTargets(taskConfig.Rule, engine.resolveEventBusName(taskConfig))
if err != nil {
return nil, err
}

var matching []eventbridgetypes.Target
for _, target := range allTargets {
if target.EcsParameters != nil &&
target.EcsParameters.TaskDefinitionArn != nil &&
taskDefinitionFamily(*target.EcsParameters.TaskDefinitionArn) == taskConfig.Id() {
matching = append(matching, target)
}
}

if len(matching) == 0 {
return nil, fmt.Errorf("no targets for task definition family '%s' found on EventBridge rule '%s'", taskConfig.Id(), taskConfig.Rule)
}

return matching, nil
}

func (engine *ECSScheduledTaskDeploymentEngine) listAllTargets(ruleName, eventBusName string) ([]eventbridgetypes.Target, error) {
var targets []eventbridgetypes.Target
var nextToken *string
for {
output, err := engine.EventBridgeClient.ListTargetsByRule(context.Background(), &eventbridge.ListTargetsByRuleInput{
Rule: aws.String(ruleName),
EventBusName: aws.String(eventBusName),
NextToken: nextToken,
})
if err != nil {
return nil, err
}
targets = append(targets, output.Targets...)
if output.NextToken == nil {
break
}
nextToken = output.NextToken
}
return targets, nil
}

// taskDefinitionFamily extracts the family name from a task definition ARN or "family:revision" string.
// e.g. "arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" → "my-task"
// e.g. "my-task:5" → "my-task"
func taskDefinitionFamily(arnOrName string) string {
s := arnOrName
if idx := strings.LastIndex(s, "/"); idx >= 0 {
s = s[idx+1:]
}
return strings.Split(s, ":")[0]
}

func (engine *ECSScheduledTaskDeploymentEngine) resolveEventBusName(taskConfig *EcsScheduledTaskDeployment) string {
if taskConfig.EventBusName != "" {
return taskConfig.EventBusName
}
return "default"
}

func init() {
RegisterDeploymentEngine("ecs-scheduled-task", func(awsConfig aws.Config) DeploymentEngine {
return &ECSScheduledTaskDeploymentEngine{
ECSClient: ecs.NewFromConfig(awsConfig),
EventBridgeClient: eventbridge.NewFromConfig(awsConfig),
}
})
}
12 changes: 7 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module github.com/source-ag/ploy

go 1.18
go 1.23

require (
github.com/aws/aws-sdk-go-v2 v1.16.5
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.15.11
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9
github.com/aws/aws-sdk-go-v2/service/lambda v1.23.2
Expand All @@ -16,13 +16,15 @@ require (
require (
github.com/aws/aws-sdk-go-v2/credentials v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 // indirect
github.com/aws/smithy-go v1.11.3 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/aws/aws-sdk-go-v2 v1.16.5 h1:Ah9h1TZD9E2S1LzHpViBO3Jz9FPL5+rmflmb8hXirtI=
github.com/aws/aws-sdk-go-v2 v1.16.5/go.mod h1:Wh7MEsmEApyL5hrWzpDkba4gwAPc5/piwLVLFnCxp48=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.15.11 h1:qfec8AtiCqVbwMcx51G1yO2PYVfWfhp2lWkDH65V9HA=
github.com/aws/aws-sdk-go-v2/config v1.15.11/go.mod h1:mD5tNFciV7YHNjPpFYqJ6KGpoSfY107oZULvTHIxtbI=
github.com/aws/aws-sdk-go-v2/credentials v1.12.6 h1:No1wZFW4bcM/uF6Tzzj6IbaeQJM+xxqXOYmoObm33ws=
Expand All @@ -8,12 +10,20 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6 h1:+NZzDh/RpcQTpo9xMFUgkse
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6/go.mod h1:ClLMcuQA/wcHPmOIfNzNI4Y1Q0oDbmEkbYhMFOzHDh8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 h1:Zt7DDk5V7SyQULUUwIKzsROtVzp/kVvcz15uQx/Tkow=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12/go.mod h1:Afj/U8svX6sJ77Q+FPWMzabJ9QjbwP32YlopgKALUpg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 h1:eeXdGVtXEe+2Jc49+/vAzna3FAQnUD4AagAw8tzbmfc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6/go.mod h1:FwpAKI+FBPIELJIdmQzlLtRe8LQSOreMcM2wBsPMvvc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13 h1:L/l0WbIpIadRO7i44jZh1/XeXpNDX0sokFppb4ZnXUI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13/go.mod h1:hiM/y1XPp3DoEPhoVEYc/CZcS58dP6RKJRDFp99wdX0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9 h1:MnjiznQWgWoxl9/mtd5tiR0mzhc/AtVU1g3EzwLYadI=
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9/go.mod h1:3gZ0i0u8EWCYsLn4Z/JAyLx+TTcWWeDOSgNsMTTpp6Q=
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18 h1:Zqe/Mbpjy3Vk0IKreW4cdxz2PBb0JNCeMwYAKbuBnvg=
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18/go.mod h1:oGNgLQOntNCt7Tl3d1NQu5QKFxdufg4huUAmyNECPDU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6 h1:0ZxYAZ1cn7Swi/US55VKciCE6RhRHIwCKIWaMLdT6pg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6/go.mod h1:DxAPjquoEHf3rUHh1b9+47RAaXB8/7cB6jkzCt/GOEI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.23.2 h1:+FDf1YuV1IH6AdbxeootqXT3AbcCYCIp3XgZnwpAmcA=
Expand All @@ -24,6 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 h1:HLzjwQM9975FQWSF3uENDGHT1gFQ
github.com/aws/aws-sdk-go-v2/service/sts v1.16.7/go.mod h1:lVxTdiiSHY3jb1aeg+BBFtDzZGSUCv6qaNOyEGCJ1AY=
github.com/aws/smithy-go v1.11.3 h1:DQixirEFM9IaKxX1olZ3ke3nvxRS2xMDteKIDWxozW8=
github.com/aws/smithy-go v1.11.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down