Skip to content

Commit 3e8d975

Browse files
authored
Merge pull request #1 from source-ag/feature/add-scheduled-ecs-task-support
2 parents 4d6639b + cff35b1 commit 3e8d975

File tree

4 files changed

+213
-7
lines changed

4 files changed

+213
-7
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ deployments:
4747
wait-for-service-stability: true # optional, defaults to false
4848
wait-for-minutes: 5 # optional, how long to wait for service stability, defaults to 30
4949
force-new-deployment: true # optional, defaults to false
50+
- id: my-task-family # in case of ECS scheduled tasks, this is the ECS task definition family name
51+
type: ecs-scheduled-task
52+
rule: my-eventbridge-rule # the EventBridge rule that schedules this task
53+
version: v1
54+
event-bus-name: default # optional, defaults to "default"
55+
version-environment-key: VERSION # optional, updates the given environment variable in the container with the version when deploying
5056
```
5157
5258
Typically, you'll have one configuration file for each environment (e.g. dev, prod, staging).
@@ -94,12 +100,13 @@ ploy update development.yml my-service my-other-service v123
94100

95101
## Engines
96102

97-
There are currently two supported deployment engines:
103+
There are currently three supported deployment engines:
98104

99105
- [AWS Lambda](https://aws.amazon.com/lambda/) (type: `lambda`) - with the code packaged as a Docker
100106
image. Version is the image tag.
101107
- [AWS ECS](https://aws.amazon.com/ecs/) (type: `ecs`) - with the code packaged as a Docker image.
102108
Version is the image tag.
109+
- [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.
103110

104111
## Contributing
105112

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

110117
- Better error handling
111118
- Add support for deploying new ECS task definitions for one-off tasks that are not part of a
112-
service
119+
service or a scheduled task
113120
- Add support for other deployment engines. See `github.com/DandyDev/ploy/engine` for examples of
114121
how engines are implemented
115122
- Create command that serves a simple dashboard the visualizes the services that are deployed

engine/ecs_scheduled_task.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/aws/aws-sdk-go-v2/aws"
9+
"github.com/aws/aws-sdk-go-v2/service/ecs"
10+
"github.com/aws/aws-sdk-go-v2/service/eventbridge"
11+
eventbridgetypes "github.com/aws/aws-sdk-go-v2/service/eventbridge/types"
12+
)
13+
14+
type EcsScheduledTaskDeployment struct {
15+
BaseDeploymentConfig `mapstructure:",squash"`
16+
Rule string `mapstructure:"rule"`
17+
EventBusName string `mapstructure:"event-bus-name,omitempty"`
18+
VersionEnvironmentKey string `mapstructure:"version-environment-key,omitempty"`
19+
}
20+
21+
type ECSScheduledTaskDeploymentEngine struct {
22+
ECSClient *ecs.Client
23+
EventBridgeClient *eventbridge.Client
24+
}
25+
26+
func (engine *ECSScheduledTaskDeploymentEngine) Type() string {
27+
return "ecs-scheduled-task"
28+
}
29+
30+
func (engine *ECSScheduledTaskDeploymentEngine) ResolveConfigStruct() Deployment {
31+
return &EcsScheduledTaskDeployment{}
32+
}
33+
34+
func (engine *ECSScheduledTaskDeploymentEngine) Deploy(config Deployment, p func(string, ...any)) error {
35+
taskConfig := config.(*EcsScheduledTaskDeployment)
36+
37+
if taskConfig.Rule == "" {
38+
return fmt.Errorf("ecs-scheduled-task '%s': 'rule' must be set", taskConfig.Id())
39+
}
40+
41+
targets, err := engine.findMatchingTargets(taskConfig)
42+
if err != nil {
43+
return err
44+
}
45+
46+
taskDefinitionOutput, err := engine.ECSClient.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
47+
TaskDefinition: targets[0].EcsParameters.TaskDefinitionArn,
48+
})
49+
if err != nil {
50+
return err
51+
}
52+
53+
p("Registering new task definition for '%s' with version '%s'...", *taskDefinitionOutput.TaskDefinition.Family, taskConfig.Version())
54+
registerOutput, err := engine.ECSClient.RegisterTaskDefinition(
55+
context.Background(),
56+
generateRegisterTaskDefinitionInput(taskDefinitionOutput.TaskDefinition, taskConfig.Version(), taskConfig.VersionEnvironmentKey),
57+
)
58+
if err != nil {
59+
return err
60+
}
61+
62+
newTaskDefArn := registerOutput.TaskDefinition.TaskDefinitionArn
63+
p("Updating %d ECS target(s) on EventBridge rule '%s' with new task definition '%s'...", len(targets), taskConfig.Rule, *newTaskDefArn)
64+
65+
updatedTargets := make([]eventbridgetypes.Target, len(targets))
66+
for i, target := range targets {
67+
updated := target
68+
updatedEcsParams := *target.EcsParameters
69+
updatedEcsParams.TaskDefinitionArn = newTaskDefArn
70+
updated.EcsParameters = &updatedEcsParams
71+
updatedTargets[i] = updated
72+
}
73+
74+
putOutput, err := engine.EventBridgeClient.PutTargets(context.Background(), &eventbridge.PutTargetsInput{
75+
Rule: aws.String(taskConfig.Rule),
76+
EventBusName: aws.String(engine.resolveEventBusName(taskConfig)),
77+
Targets: updatedTargets,
78+
})
79+
if err != nil {
80+
return err
81+
}
82+
if putOutput.FailedEntryCount > 0 {
83+
return fmt.Errorf(
84+
"failed to update %d target(s) on EventBridge rule '%s': %s",
85+
putOutput.FailedEntryCount,
86+
taskConfig.Rule,
87+
*putOutput.FailedEntries[0].ErrorMessage,
88+
)
89+
}
90+
return nil
91+
}
92+
93+
func (engine *ECSScheduledTaskDeploymentEngine) CheckVersion(config Deployment) (string, error) {
94+
taskConfig := config.(*EcsScheduledTaskDeployment)
95+
96+
if taskConfig.Rule == "" {
97+
return "", fmt.Errorf("ecs-scheduled-task '%s': 'rule' must be set", taskConfig.Id())
98+
}
99+
100+
targets, err := engine.findMatchingTargets(taskConfig)
101+
if err != nil {
102+
return "", err
103+
}
104+
105+
taskDefinitionOutput, err := engine.ECSClient.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
106+
TaskDefinition: targets[0].EcsParameters.TaskDefinitionArn,
107+
})
108+
if err != nil {
109+
return "", err
110+
}
111+
112+
return strings.Split(*taskDefinitionOutput.TaskDefinition.ContainerDefinitions[0].Image, ":")[1], nil
113+
}
114+
115+
// findMatchingTargets returns all targets on the configured rule whose task definition family
116+
// matches the deployment id.
117+
func (engine *ECSScheduledTaskDeploymentEngine) findMatchingTargets(taskConfig *EcsScheduledTaskDeployment) ([]eventbridgetypes.Target, error) {
118+
allTargets, err := engine.listAllTargets(taskConfig.Rule, engine.resolveEventBusName(taskConfig))
119+
if err != nil {
120+
return nil, err
121+
}
122+
123+
var matching []eventbridgetypes.Target
124+
for _, target := range allTargets {
125+
if target.EcsParameters != nil &&
126+
target.EcsParameters.TaskDefinitionArn != nil &&
127+
taskDefinitionFamily(*target.EcsParameters.TaskDefinitionArn) == taskConfig.Id() {
128+
matching = append(matching, target)
129+
}
130+
}
131+
132+
if len(matching) == 0 {
133+
return nil, fmt.Errorf("no targets for task definition family '%s' found on EventBridge rule '%s'", taskConfig.Id(), taskConfig.Rule)
134+
}
135+
136+
return matching, nil
137+
}
138+
139+
func (engine *ECSScheduledTaskDeploymentEngine) listAllTargets(ruleName, eventBusName string) ([]eventbridgetypes.Target, error) {
140+
var targets []eventbridgetypes.Target
141+
var nextToken *string
142+
for {
143+
output, err := engine.EventBridgeClient.ListTargetsByRule(context.Background(), &eventbridge.ListTargetsByRuleInput{
144+
Rule: aws.String(ruleName),
145+
EventBusName: aws.String(eventBusName),
146+
NextToken: nextToken,
147+
})
148+
if err != nil {
149+
return nil, err
150+
}
151+
targets = append(targets, output.Targets...)
152+
if output.NextToken == nil {
153+
break
154+
}
155+
nextToken = output.NextToken
156+
}
157+
return targets, nil
158+
}
159+
160+
// taskDefinitionFamily extracts the family name from a task definition ARN or "family:revision" string.
161+
// e.g. "arn:aws:ecs:us-east-1:123456789012:task-definition/my-task:5" → "my-task"
162+
// e.g. "my-task:5" → "my-task"
163+
func taskDefinitionFamily(arnOrName string) string {
164+
s := arnOrName
165+
if idx := strings.LastIndex(s, "/"); idx >= 0 {
166+
s = s[idx+1:]
167+
}
168+
return strings.Split(s, ":")[0]
169+
}
170+
171+
func (engine *ECSScheduledTaskDeploymentEngine) resolveEventBusName(taskConfig *EcsScheduledTaskDeployment) string {
172+
if taskConfig.EventBusName != "" {
173+
return taskConfig.EventBusName
174+
}
175+
return "default"
176+
}
177+
178+
func init() {
179+
RegisterDeploymentEngine("ecs-scheduled-task", func(awsConfig aws.Config) DeploymentEngine {
180+
return &ECSScheduledTaskDeploymentEngine{
181+
ECSClient: ecs.NewFromConfig(awsConfig),
182+
EventBridgeClient: eventbridge.NewFromConfig(awsConfig),
183+
}
184+
})
185+
}

go.mod

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
module github.com/source-ag/ploy
22

3-
go 1.18
3+
go 1.23
44

55
require (
6-
github.com/aws/aws-sdk-go-v2 v1.16.5
6+
github.com/aws/aws-sdk-go-v2 v1.41.1
77
github.com/aws/aws-sdk-go-v2/config v1.15.11
88
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9
99
github.com/aws/aws-sdk-go-v2/service/lambda v1.23.2
@@ -16,13 +16,15 @@ require (
1616
require (
1717
github.com/aws/aws-sdk-go-v2/credentials v1.12.6 // indirect
1818
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6 // indirect
19-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 // indirect
20-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 // indirect
19+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
20+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
2121
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13 // indirect
22+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
23+
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18 // indirect
2224
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6 // indirect
2325
github.com/aws/aws-sdk-go-v2/service/sso v1.11.9 // indirect
2426
github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 // indirect
25-
github.com/aws/smithy-go v1.11.3 // indirect
27+
github.com/aws/smithy-go v1.24.0 // indirect
2628
github.com/hashicorp/errwrap v1.0.0 // indirect
2729
github.com/inconshreveable/mousetrap v1.0.0 // indirect
2830
github.com/jmespath/go-jmespath v0.4.0 // indirect

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/aws/aws-sdk-go-v2 v1.16.5 h1:Ah9h1TZD9E2S1LzHpViBO3Jz9FPL5+rmflmb8hXirtI=
22
github.com/aws/aws-sdk-go-v2 v1.16.5/go.mod h1:Wh7MEsmEApyL5hrWzpDkba4gwAPc5/piwLVLFnCxp48=
3+
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
4+
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
35
github.com/aws/aws-sdk-go-v2/config v1.15.11 h1:qfec8AtiCqVbwMcx51G1yO2PYVfWfhp2lWkDH65V9HA=
46
github.com/aws/aws-sdk-go-v2/config v1.15.11/go.mod h1:mD5tNFciV7YHNjPpFYqJ6KGpoSfY107oZULvTHIxtbI=
57
github.com/aws/aws-sdk-go-v2/credentials v1.12.6 h1:No1wZFW4bcM/uF6Tzzj6IbaeQJM+xxqXOYmoObm33ws=
@@ -8,12 +10,20 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6 h1:+NZzDh/RpcQTpo9xMFUgkse
810
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.6/go.mod h1:ClLMcuQA/wcHPmOIfNzNI4Y1Q0oDbmEkbYhMFOzHDh8=
911
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 h1:Zt7DDk5V7SyQULUUwIKzsROtVzp/kVvcz15uQx/Tkow=
1012
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12/go.mod h1:Afj/U8svX6sJ77Q+FPWMzabJ9QjbwP32YlopgKALUpg=
13+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
14+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
1115
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 h1:eeXdGVtXEe+2Jc49+/vAzna3FAQnUD4AagAw8tzbmfc=
1216
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6/go.mod h1:FwpAKI+FBPIELJIdmQzlLtRe8LQSOreMcM2wBsPMvvc=
17+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
18+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
1319
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13 h1:L/l0WbIpIadRO7i44jZh1/XeXpNDX0sokFppb4ZnXUI=
1420
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.13/go.mod h1:hiM/y1XPp3DoEPhoVEYc/CZcS58dP6RKJRDFp99wdX0=
21+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
22+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
1523
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9 h1:MnjiznQWgWoxl9/mtd5tiR0mzhc/AtVU1g3EzwLYadI=
1624
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.9/go.mod h1:3gZ0i0u8EWCYsLn4Z/JAyLx+TTcWWeDOSgNsMTTpp6Q=
25+
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18 h1:Zqe/Mbpjy3Vk0IKreW4cdxz2PBb0JNCeMwYAKbuBnvg=
26+
github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.18/go.mod h1:oGNgLQOntNCt7Tl3d1NQu5QKFxdufg4huUAmyNECPDU=
1727
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6 h1:0ZxYAZ1cn7Swi/US55VKciCE6RhRHIwCKIWaMLdT6pg=
1828
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.6/go.mod h1:DxAPjquoEHf3rUHh1b9+47RAaXB8/7cB6jkzCt/GOEI=
1929
github.com/aws/aws-sdk-go-v2/service/lambda v1.23.2 h1:+FDf1YuV1IH6AdbxeootqXT3AbcCYCIp3XgZnwpAmcA=
@@ -24,6 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 h1:HLzjwQM9975FQWSF3uENDGHT1gFQ
2434
github.com/aws/aws-sdk-go-v2/service/sts v1.16.7/go.mod h1:lVxTdiiSHY3jb1aeg+BBFtDzZGSUCv6qaNOyEGCJ1AY=
2535
github.com/aws/smithy-go v1.11.3 h1:DQixirEFM9IaKxX1olZ3ke3nvxRS2xMDteKIDWxozW8=
2636
github.com/aws/smithy-go v1.11.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
37+
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
38+
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
2739
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
2840
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2941
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

0 commit comments

Comments
 (0)