From cff35b1185bf0b0ddf708e2a5f3109f31b6a002e Mon Sep 17 00:00:00 2001 From: Jelle Pelgrims Date: Mon, 23 Feb 2026 14:37:57 +0100 Subject: [PATCH] feature: Add scheduled ECS task support --- README.md | 11 ++- engine/ecs_scheduled_task.go | 185 +++++++++++++++++++++++++++++++++++ go.mod | 12 ++- go.sum | 12 +++ 4 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 engine/ecs_scheduled_task.go diff --git a/README.md b/README.md index 6394259..f57847a 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 @@ -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 diff --git a/engine/ecs_scheduled_task.go b/engine/ecs_scheduled_task.go new file mode 100644 index 0000000..8f046c0 --- /dev/null +++ b/engine/ecs_scheduled_task.go @@ -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), + } + }) +} diff --git a/go.mod b/go.mod index 3c78015..d504853 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 5d23538..5f77389 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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=