diff --git a/.github/workflows/run.e2e-tests.yml b/.github/workflows/run.e2e-tests.yml index 07daea81..8dca4414 100644 --- a/.github/workflows/run.e2e-tests.yml +++ b/.github/workflows/run.e2e-tests.yml @@ -107,6 +107,69 @@ jobs: cd "${{ env.IZE_EXAMPLES_PATH }}" ize down --auto-approve + test-ecs-cron: + name: ECS Cron Tasks + needs: build + strategy: + max-parallel: 1 + matrix: + os: + - ubuntu-latest + runs-on: ${{ matrix.os }} + env: + IZE_EXAMPLES_PATH: ${{ github.workspace }}/examples/ecs-apps-monorepo + steps: + - name: Configure Environment Variables + run: | + echo "${{ github.workspace }}/bin/" >> $GITHUB_PATH + echo "ENV=${{ github.job }}-$(echo $GITHUB_SHA | cut -c 1-6)" >> $GITHUB_ENV + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_PROD }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_PROD }} + aws-region: ${{ env.AWS_REGION }} + env: + AWS_PROFILE: # This is required due to a bug https://stackoverflow.com/a/77731682 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.18.x + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Prepare Test Environment + run: mv "${{ env.IZE_EXAMPLES_PATH }}/.ize/env/testnut" "${{ env.IZE_EXAMPLES_PATH }}/.ize/env/${{ env.ENV }}" + + - uses: actions/download-artifact@v4 + with: + name: ize-${{ matrix.os }}-${{ github.sha }} + path: bin + + - name: Make Executable + run: | + chmod +rx "${{ github.workspace }}/bin/ize" + ize --version + + - name: Create AWS Profile + run: ize gen aws-profile + + - name: Generate Test SSH Key + run: ssh-keygen -q -f ~/.ssh/id_rsa + + - name: Run Tests + run: | + go test -v --timeout 0 --tags="e2e ecs_cron" ./tests/e2e + + - name: Cleanup Infra + if: ${{ always() }} + run: | + cd "${{ env.IZE_EXAMPLES_PATH }}" + ize down --auto-approve + test-tunnel: name: Bastion Tunnel Monorepo needs: build diff --git a/docs/superpowers/plans/2026-03-26-ecs-cron.md b/docs/superpowers/plans/2026-03-26-ecs-cron.md new file mode 100644 index 00000000..93d0b5d4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-ecs-cron.md @@ -0,0 +1,1089 @@ +# ECS Cron Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add ECS scheduled task (cron) support to ize via EventBridge, as a new manager type `ecs-cron`. + +**Architecture:** New `ecscron` manager package implementing the existing `Manager` interface. Uses AWS EventBridge API (CloudWatch Events) to create/update/delete scheduled ECS tasks. Follows the same patterns as the existing `ecs` manager — native-only (no Docker runtime). Configuration via `[ecs_cron.]` sections in `ize.toml`. + +**Tech Stack:** Go, AWS SDK v1 (`github.com/aws/aws-sdk-go/service/eventbridge`), ECS API, CloudWatch Logs API. + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `internal/manager/ecscron/manager.go` | Manager struct, Deploy (create/update rule + target), Destroy (delete rule + target + deregister TDs) | +| Create | `internal/manager/ecscron/run.go` | RunNow — manual trigger of a cron task outside schedule | +| Create | `internal/manager/ecscron/logs.go` | GetLastRunLogs — fetch logs of the most recent task execution | +| Create | `internal/manager/ecscron/network.go` | SSM-based network config helper (shared by Deploy and RunNow) | +| Create | `internal/manager/ecscron/explain.go` | Explain — print equivalent AWS CLI commands | +| Create | `internal/manager/ecscron/manager_test.go` | Unit tests with mocked AWS clients | +| Modify | `internal/config/app.go` | Add `EcsCron` struct | +| Modify | `internal/config/project.go` | Add `EcsCron` map field, EventBridge client to `awsClient` | +| Modify | `internal/config/config.go:452-492` | Add `case "ecs-cron"` in `ConvertApps()` | +| Modify | `internal/commands/deploy.go:127-168` | Add `cfg.EcsCron[name]` branch | +| Modify | `internal/commands/build.go:93-128` | Add `cfg.EcsCron[name]` branch | +| Modify | `internal/commands/push.go:94-131` | Add `cfg.EcsCron[name]` branch | +| Modify | `internal/commands/down.go:311-343` | Add `cfg.EcsCron[name]` branch in `destroyApp()` | +| Modify | `internal/commands/up_apps.go:124-153` | Add `cfg.EcsCron[name]` branch in `deployApp()` | +| Modify | `internal/commands/ize.go:74-93` | Register new `ize cron` subcommand | +| Create | `internal/commands/cron.go` | New `ize cron run` and `ize cron logs` CLI commands | +| Modify | `internal/schema/ize-spec.json` | Add `ecs_cron` property + definition | + +--- + +## Task 1: Config — `EcsCron` struct and project wiring + +**Files:** +- Modify: `internal/config/app.go` +- Modify: `internal/config/project.go:23-52` (Project struct), `54-63` (awsClient) +- Modify: `internal/config/config.go:452-492` (ConvertApps) +- Modify: `internal/schema/ize-spec.json:133-165` (properties), `322-502` (definitions) + +- [ ] **Step 1: Add `EcsCron` struct to `app.go`** + +Append after the `Alias` struct (line 40): + +```go +type EcsCron struct { + Name string `mapstructure:",omitempty"` + Path string `mapstructure:",omitempty"` + Image string `mapstructure:",omitempty"` + Cluster string `mapstructure:",omitempty"` + Schedule string `mapstructure:",omitempty"` + TaskDefinition string `mapstructure:"task_definition,omitempty"` + DockerRegistry string `mapstructure:"docker_registry,omitempty"` + Timeout int `mapstructure:",omitempty"` + SkipDeploy bool `mapstructure:"skip_deploy,omitempty"` + Icon string `mapstructure:"icon,omitempty"` + AwsProfile string `mapstructure:"aws_profile,omitempty"` + AwsRegion string `mapstructure:"aws_region,omitempty"` + DependsOn []string `mapstructure:"depends_on,omitempty"` + Enabled *bool `mapstructure:"enabled,omitempty"` +} +``` + +Fields: +- `Schedule` — EventBridge schedule expression (`rate(...)` or `cron(...)`). Optional if the rule already exists (created via Terraform); required to create a new rule. +- `TaskDefinition` — optional: reuse an existing task definition family name instead of deriving from app name +- `Enabled` — pointer to bool so we can distinguish "not set" (default true) from explicit false + +- [ ] **Step 2: Add `EcsCron` map and EventBridge client to `project.go`** + +Add imports: +```go +"github.com/aws/aws-sdk-go/service/eventbridge" +"github.com/aws/aws-sdk-go/service/eventbridge/eventbridgeiface" +``` + +Add to `Project` struct (after `Alias` field, line 51): +```go +EcsCron map[string]*EcsCron `mapstructure:"ecs_cron,omitempty"` +``` + +Add to `awsClient` struct: +```go +EventBridgeClient eventbridgeiface.EventBridgeAPI +``` + +Add `WithEventBridgeClient` option function (follow existing pattern): +```go +func WithEventBridgeClient(api eventbridgeiface.EventBridgeAPI) Option { + return func(r *awsClient) { + r.EventBridgeClient = api + } +} +``` + +Add `WithEventBridgeClient(eventbridge.New(sess))` to `SettingAWSClient()` (line 125-135). + +Add `EcsCron` to `GetApps()` method (line 137-165): +```go +for name, body := range p.EcsCron { + var v interface{} + v = map[string]interface{}{ + "depends_on": body.DependsOn, + } + apps[name] = &v +} +``` + +- [ ] **Step 3: Add `case "ecs-cron"` to `ConvertApps()` in `config.go`** + +In `ConvertApps()`, add a new map variable: +```go +ecsCron := map[string]interface{}{} +``` + +Add case in switch (after `case "serverless":`): +```go +case "ecs-cron": + ecsCronApp := EcsCron{} + err := mapstructure.Decode(&body, &ecsCronApp) + if err != nil { + return err + } + ecsCron[name] = structToMap(ecsCronApp) +``` + +Add `"ecs_cron": ecsCron` to the `viper.MergeConfigMap` call. + +- [ ] **Step 4: Add JSON schema for `ecs_cron`** + +Add property block in `properties` section (after `alias`, ~line 165): +```json +"ecs_cron": { + "id": "#/properties/ecs_cron", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/ecs_cron" + } + }, + "description": "ECS Cron (scheduled tasks) configuration.", + "additionalProperties": false +} +``` + +Add definition block in `definitions` section (after `ecs`, ~line 377): +```json +"ecs_cron": { + "id": "#/definitions/ecs_cron", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "(optional) Path to ecs cron app folder." + }, + "image": { + "type": "string", + "description": "(optional) Docker image for the scheduled task." + }, + "cluster": { + "type": "string", + "description": "(optional) ECS cluster. Defaults to env-namespace." + }, + "schedule": { + "type": "string", + "description": "(optional) EventBridge schedule expression: rate() or cron(). Required only when creating a new rule. If omitted and rule already exists, the existing schedule is preserved." + }, + "task_definition": { + "type": "string", + "description": "(optional) Existing task definition family to use." + }, + "docker_registry": { + "type": "string", + "description": "(optional) Docker registry. Defaults to ECR." + }, + "timeout": { + "type": "integer", + "description": "(optional) Task timeout in seconds." + }, + "skip_deploy": { + "type": "boolean", + "description": "(optional) Skip deploy." + }, + "icon": { + "type": "string", + "description": "(optional) Icon." + }, + "enabled": { + "type": "boolean", + "description": "(optional) Whether the schedule is enabled. Default true." + }, + "aws_region": { + "type": "string", + "description": "(optional) AWS region override." + }, + "aws_profile": { + "type": "string", + "description": "(optional) AWS profile override." + }, + "depends_on": { + "type": "array", + "description": "(optional) Startup/shutdown dependencies." + } + }, + "description": "ECS Cron (scheduled task) configuration.", + "additionalProperties": false +} +``` + +- [ ] **Step 5: Verify compilation** + +Run: `cd /Users/nya/dev/terraform/ize && go build ./...` + +- [ ] **Step 6: Commit** + +```bash +git add internal/config/app.go internal/config/project.go internal/config/config.go internal/schema/ize-spec.json +git commit -m "feat(ecs-cron): add EcsCron config struct, EventBridge client, and schema" +``` + +--- + +## Task 2: Manager — shared network helper + core Deploy/Destroy + +**Files:** +- Create: `internal/manager/ecscron/network.go` +- Create: `internal/manager/ecscron/manager.go` + +- [ ] **Step 1: Create `network.go` — SSM-based network config (shared by Deploy and RunNow)** + +This mirrors the pattern from `commands/start.go:115-139` but lives in the ecscron package so both Deploy and RunNow can use it. + +```go +package ecscron + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/ssm/ssmiface" +) + +type networkConfig struct { + SecurityGroups struct { + Value string `json:"value"` + } `json:"security_groups"` + Subnets struct { + Value [][]string `json:"value"` + } `json:"subnets"` + VpcPrivateSubnets struct { + Value []string `json:"value"` + } `json:"vpc_private_subnets"` + VpcPublicSubnets struct { + Value []string `json:"value"` + } `json:"vpc_public_subnets"` +} + +func getNetworkConfigFromSSM(svc ssmiface.SSMAPI, env string) (*networkConfig, error) { + resp, err := svc.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(fmt.Sprintf("/%s/terraform-output", env)), + WithDecryption: aws.Bool(true), + }) + if err != nil { + return nil, fmt.Errorf("can't get terraform output: %w", err) + } + + value, err := base64.StdEncoding.DecodeString(*resp.Parameter.Value) + if err != nil { + return nil, fmt.Errorf("can't decode terraform output: %w", err) + } + + var nc networkConfig + if err := json.Unmarshal(value, &nc); err != nil { + return nil, fmt.Errorf("can't unmarshal network configuration: %w", err) + } + return &nc, nil +} +``` + +Note: struct fields match `commands/start.go:31-44` exactly to ensure compatibility with the SSM parameter format. + +- [ ] **Step 2: Create `manager.go` — Manager struct + Deploy + Destroy** + +```go +package ecscron + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + "github.com/aws/aws-sdk-go/service/eventbridge" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/hazelops/ize/internal/aws/utils" + "github.com/hazelops/ize/internal/config" + ecsmanager "github.com/hazelops/ize/internal/manager/ecs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" + "github.com/sirupsen/logrus" +) + +type Manager struct { + Project *config.Project + App *config.EcsCron +} + +func (m *Manager) prepare() { + if m.App.Path == "" { + appsPath := m.Project.AppsPath + if !filepath.IsAbs(appsPath) { + appsPath = filepath.Join(os.Getenv("PWD"), appsPath) + } + m.App.Path = filepath.Join(appsPath, m.App.Name) + } else if !filepath.IsAbs(m.App.Path) { + m.App.Path = filepath.Join(m.Project.RootDir, m.App.Path) + } + + if m.App.Cluster == "" { + m.App.Cluster = fmt.Sprintf("%s-%s", m.Project.Env, m.Project.Namespace) + } + + if m.App.DockerRegistry == "" { + m.App.DockerRegistry = m.Project.DockerRegistry + } + + if m.App.Timeout == 0 { + m.App.Timeout = 300 + } +} + +func (m *Manager) ruleName() string { + return fmt.Sprintf("%s-%s-cron", m.Project.Env, m.App.Name) +} + +func (m *Manager) taskFamily() string { + if m.App.TaskDefinition != "" { + return m.App.TaskDefinition + } + return fmt.Sprintf("%s-%s", m.Project.Env, m.App.Name) +} + +func (m *Manager) ensureSession() error { + if m.App.AwsRegion != "" && m.App.AwsProfile != "" { + sess, err := utils.GetSession(&utils.SessionConfig{ + Region: m.App.AwsRegion, + Profile: m.App.AwsProfile, + }) + if err != nil { + return fmt.Errorf("can't get session: %w", err) + } + m.Project.SettingAWSClient(sess) + } + return nil +} + +func (m *Manager) toEcsApp() *config.Ecs { + return &config.Ecs{ + Name: m.App.Name, + Path: m.App.Path, + Image: m.App.Image, + Cluster: m.App.Cluster, + DockerRegistry: m.App.DockerRegistry, + Timeout: m.App.Timeout, + SkipDeploy: m.App.SkipDeploy, + Icon: m.App.Icon, + AwsProfile: m.App.AwsProfile, + AwsRegion: m.App.AwsRegion, + DependsOn: m.App.DependsOn, + } +} + +// Deploy creates or updates an EventBridge rule and its ECS task target. +func (m *Manager) Deploy(ui terminal.UI) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + if m.App.SkipDeploy { + s := sg.Add("%s: deploy will be skipped", m.App.Name) + defer func() { s.Abort(); time.Sleep(50 * time.Millisecond) }() + s.Done() + return nil + } + + s := sg.Add("%s: deploying cron task...", m.App.Name) + defer func() { s.Abort(); time.Sleep(50 * time.Millisecond) }() + + // Resolve image + image := m.App.Image + if image == "" { + image = fmt.Sprintf("%s/%s:%s", + m.App.DockerRegistry, + fmt.Sprintf("%s-%s", m.Project.Namespace, m.App.Name), + fmt.Sprintf("%s-%s", m.Project.Env, "latest")) + } + + ecsSvc := m.Project.AWSClient.ECSClient + ebSvc := m.Project.AWSClient.EventBridgeClient + family := m.taskFamily() + + // 1. Get current task definition and register new revision with updated image + latestTd, err := getLatestTaskDefinition(ecsSvc, family) + if err != nil { + return fmt.Errorf("task definition %s not found — it must be created via Terraform first: %w", family, err) + } + + for _, cd := range latestTd.ContainerDefinitions { + if *cd.Name == m.App.Name { + pterm.Printfln(`Changed image of container "%s" to: "%s" (was: "%s")`, *cd.Name, image, *cd.Image) + cd.Image = &image + } + } + + rtdo, err := ecsSvc.RegisterTaskDefinition(&ecs.RegisterTaskDefinitionInput{ + ContainerDefinitions: latestTd.ContainerDefinitions, + Family: latestTd.Family, + Volumes: latestTd.Volumes, + TaskRoleArn: latestTd.TaskRoleArn, + ExecutionRoleArn: latestTd.ExecutionRoleArn, + RuntimePlatform: latestTd.RuntimePlatform, + RequiresCompatibilities: latestTd.RequiresCompatibilities, + NetworkMode: latestTd.NetworkMode, + Cpu: latestTd.Cpu, + Memory: latestTd.Memory, + }) + if err != nil { + return fmt.Errorf("can't register task definition: %w", err) + } + + newTdArn := *rtdo.TaskDefinition.TaskDefinitionArn + pterm.Printfln("Registered task definition: %s:%d", *rtdo.TaskDefinition.Family, *rtdo.TaskDefinition.Revision) + + // 2. Create or update EventBridge rule + // Three scenarios: + // a) schedule in config → create/update rule with that schedule + // b) no schedule, rule exists → keep existing rule (only update target) + // c) no schedule, rule doesn't exist → error + ruleName := m.ruleName() + + if m.App.Schedule != "" { + // Scenario (a): schedule provided — create or update the rule + state := "ENABLED" + if m.App.Enabled != nil && !*m.App.Enabled { + state = "DISABLED" + } + + _, err = ebSvc.PutRule(&eventbridge.PutRuleInput{ + Name: &ruleName, + ScheduleExpression: &m.App.Schedule, + State: &state, + Description: aws.String(fmt.Sprintf("IZE cron: %s", m.App.Name)), + }) + if err != nil { + return fmt.Errorf("can't create/update EventBridge rule: %w", err) + } + + pterm.Printfln("EventBridge rule %s: schedule=%s state=%s", ruleName, m.App.Schedule, state) + } else { + // Scenario (b)/(c): no schedule — check if rule already exists + dro, err := ebSvc.DescribeRule(&eventbridge.DescribeRuleInput{ + Name: &ruleName, + }) + if err != nil { + return fmt.Errorf("schedule not specified and EventBridge rule %s not found — schedule is required to create a new rule: %w", ruleName, err) + } + + pterm.Printfln("EventBridge rule %s exists (schedule=%s), updating target only", ruleName, *dro.ScheduleExpression) + } + + // 3. Get role ARN from task definition execution role + roleArn := latestTd.ExecutionRoleArn + if roleArn == nil { + return fmt.Errorf("task definition %s has no execution role — required for EventBridge target", family) + } + + // 4. Get network configuration from SSM + nc, err := getNetworkConfigFromSSM(m.Project.AWSClient.SSMClient, m.Project.Env) + if err != nil { + return fmt.Errorf("can't get network configuration: %w", err) + } + + if len(nc.VpcPrivateSubnets.Value) == 0 { + return fmt.Errorf("vpc_private_subnets is empty in terraform output") + } + + // 5. Get account ID for cluster ARN + accountId, err := m.getAccountId() + if err != nil { + return fmt.Errorf("can't get AWS account ID: %w", err) + } + + // 6. Put target + _, err = ebSvc.PutTargets(&eventbridge.PutTargetsInput{ + Rule: &ruleName, + Targets: []*eventbridge.Target{ + { + Id: aws.String(m.App.Name), + Arn: aws.String(fmt.Sprintf("arn:aws:ecs:%s:%s:cluster/%s", m.Project.AwsRegion, accountId, m.App.Cluster)), + RoleArn: roleArn, + EcsParameters: &eventbridge.EcsParameters{ + TaskDefinitionArn: &newTdArn, + TaskCount: aws.Int64(1), + LaunchType: aws.String("FARGATE"), + NetworkConfiguration: &eventbridge.NetworkConfiguration{ + AwsvpcConfiguration: &eventbridge.AwsVpcConfiguration{ + Subnets: aws.StringSlice(nc.VpcPrivateSubnets.Value), + }, + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("can't put EventBridge target: %w", err) + } + + s.Done() + s = sg.Add("%s: cron task deployed! schedule=%s", m.App.Name, m.App.Schedule) + s.Done() + return nil +} + +// Destroy removes the EventBridge rule, its targets, and deregisters task definitions. +func (m *Manager) Destroy(ui terminal.UI, autoApprove bool) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + s := sg.Add("%s: destroying cron task...", m.App.Name) + defer func() { s.Abort(); time.Sleep(200 * time.Millisecond) }() + + ebSvc := m.Project.AWSClient.EventBridgeClient + ecsSvc := m.Project.AWSClient.ECSClient + ruleName := m.ruleName() + + // 1. Remove all targets from the rule + lto, err := ebSvc.ListTargetsByRule(&eventbridge.ListTargetsByRuleInput{ + Rule: &ruleName, + }) + if err != nil { + // Rule may not exist — treat as already destroyed + logrus.Debugf("rule %s may not exist: %v", ruleName, err) + } else if len(lto.Targets) > 0 { + var ids []*string + for _, t := range lto.Targets { + ids = append(ids, t.Id) + } + _, err = ebSvc.RemoveTargets(&eventbridge.RemoveTargetsInput{ + Rule: &ruleName, + Ids: ids, + }) + if err != nil { + return fmt.Errorf("can't remove targets from rule %s: %w", ruleName, err) + } + } + + // 2. Delete the rule (ignore ResourceNotFoundException) + _, err = ebSvc.DeleteRule(&eventbridge.DeleteRuleInput{ + Name: &ruleName, + }) + if err != nil { + logrus.Debugf("can't delete rule %s (may not exist): %v", ruleName, err) + } + + // 3. Deregister task definitions + family := m.taskFamily() + definitions, err := ecsSvc.ListTaskDefinitions(&ecs.ListTaskDefinitionsInput{ + FamilyPrefix: &family, + Sort: aws.String(ecs.SortOrderDesc), + }) + if err == nil { + for _, tda := range definitions.TaskDefinitionArns { + _, _ = ecsSvc.DeregisterTaskDefinition(&ecs.DeregisterTaskDefinitionInput{ + TaskDefinition: tda, + }) + } + } + + s.Done() + s = sg.Add("%s: cron task destroyed!", m.App.Name) + s.Done() + return nil +} + +// Build delegates to the existing ECS manager (same Docker build pipeline). +func (m *Manager) Build(ui terminal.UI) error { + m.prepare() + em := &ecsmanager.Manager{Project: m.Project, App: m.toEcsApp()} + return em.Build(ui) +} + +// Push delegates to the existing ECS manager (same ECR push pipeline). +func (m *Manager) Push(ui terminal.UI) error { + m.prepare() + em := &ecsmanager.Manager{Project: m.Project, App: m.toEcsApp()} + return em.Push(ui) +} + +// Redeploy re-deploys with the current schedule (same as Deploy for cron tasks). +func (m *Manager) Redeploy(ui terminal.UI) error { + return m.Deploy(ui) +} + +func (m *Manager) Explain() error { + // not yet implemented + return nil +} + +func getLatestTaskDefinition(svc ecsiface.ECSAPI, family string) (*ecs.TaskDefinition, error) { + tds, err := svc.ListTaskDefinitions(&ecs.ListTaskDefinitionsInput{ + FamilyPrefix: &family, + Sort: aws.String("DESC"), + }) + if err != nil { + return nil, err + } + if len(tds.TaskDefinitionArns) == 0 { + return nil, fmt.Errorf("no task definitions found for family %s", family) + } + + dtdo, err := svc.DescribeTaskDefinition(&ecs.DescribeTaskDefinitionInput{ + TaskDefinition: tds.TaskDefinitionArns[0], + }) + if err != nil { + return nil, err + } + return dtdo.TaskDefinition, nil +} + +func (m *Manager) getAccountId() (string, error) { + out, err := m.Project.AWSClient.STSClient.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err != nil { + return "", fmt.Errorf("can't get caller identity: %w", err) + } + return *out.Account, nil +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cd /Users/nya/dev/terraform/ize && go build ./...` + +- [ ] **Step 4: Commit** + +```bash +git add internal/manager/ecscron/ +git commit -m "feat(ecs-cron): add ecscron manager with Deploy, Destroy, Build, Push" +``` + +--- + +## Task 3: Manager — RunNow (manual trigger), Logs, and Explain + +**Files:** +- Create: `internal/manager/ecscron/run.go` +- Create: `internal/manager/ecscron/logs.go` +- Create: `internal/manager/ecscron/explain.go` + +- [ ] **Step 1: Implement `run.go` — manual task trigger** + +```go +package ecscron + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" + "github.com/sirupsen/logrus" +) + +// RunNow manually triggers the cron task outside its schedule via ECS RunTask. +func (m *Manager) RunNow(ui terminal.UI) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + s := sg.Add("%s: starting cron task manually...", m.App.Name) + defer func() { s.Abort() }() + + ecsSvc := m.Project.AWSClient.ECSClient + family := m.taskFamily() + + nc, err := getNetworkConfigFromSSM(m.Project.AWSClient.SSMClient, m.Project.Env) + if err != nil { + return fmt.Errorf("can't get network configuration: %w", err) + } + + if len(nc.VpcPrivateSubnets.Value) == 0 { + return fmt.Errorf("vpc_private_subnets is empty in terraform output") + } + + out, err := ecsSvc.RunTaskWithContext(context.Background(), &ecs.RunTaskInput{ + TaskDefinition: &family, + StartedBy: aws.String("IZE-cron-manual"), + Cluster: &m.App.Cluster, + LaunchType: aws.String(ecs.LaunchTypeFargate), + NetworkConfiguration: &ecs.NetworkConfiguration{ + AwsvpcConfiguration: &ecs.AwsVpcConfiguration{ + Subnets: aws.StringSlice(nc.VpcPrivateSubnets.Value), + }, + }, + }) + if err != nil { + return fmt.Errorf("can't run task: %w", err) + } + + if len(out.Tasks) == 0 { + return fmt.Errorf("no tasks started") + } + + taskArn := *out.Tasks[0].TaskArn + logrus.Debugf("started task: %s", taskArn) + + s.Done() + pterm.Success.Printfln("Task %s started: %s", m.App.Name, taskArn) + return nil +} +``` + +- [ ] **Step 2: Implement `logs.go` — last run logs** + +```go +package ecscron + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" +) + +// GetLastRunLogs fetches CloudWatch logs from the most recent task execution. +func (m *Manager) GetLastRunLogs(ui terminal.UI) error { + m.prepare() + + if err := m.ensureSession(); err != nil { + return err + } + + cwl := m.Project.AWSClient.CloudWatchLogsClient + logGroup := fmt.Sprintf("%s-%s", m.Project.Env, m.App.Name) + + out, err := cwl.DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: &logGroup, + Limit: aws.Int64(1), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }) + if err != nil { + return fmt.Errorf("can't describe log streams for %s: %w", logGroup, err) + } + + if len(out.LogStreams) == 0 { + pterm.Info.Printfln("No log streams found for %s", logGroup) + return nil + } + + stream := out.LogStreams[0] + pterm.DefaultSection.Printfln("Logs from %s (stream: %s):", logGroup, *stream.LogStreamName) + + events, err := cwl.GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + LogGroupName: &logGroup, + LogStreamName: stream.LogStreamName, + }) + if err != nil { + return fmt.Errorf("can't get log events: %w", err) + } + + for _, event := range events.Events { + pterm.Println("| " + *event.Message) + } + + return nil +} +``` + +- [ ] **Step 3: Implement `explain.go`** + +Uses `FuncMap` pattern from `internal/manager/ecs/explain.go`: + +```go +package ecscron + +import ( + "text/template" + + "github.com/hazelops/ize/internal/config" +) + +func (m *Manager) Explain() error { + m.prepare() + return m.Project.Generate(ecsCronExplainTmpl, template.FuncMap{ + "app": func() config.EcsCron { + return *m.App + }, + }) +} + +var ecsCronExplainTmpl = ` +# Create/Update EventBridge rule +aws events put-rule \ + --name {{.Env}}-{{app.Name}}-cron \ + --schedule-expression "{{app.Schedule}}" \ + --state ENABLED + +# Set ECS task as target +aws events put-targets \ + --rule {{.Env}}-{{app.Name}}-cron \ + --targets '[{"Id":"{{app.Name}}","Arn":"arn:aws:ecs:{{.AwsRegion}}::cluster/{{app.Cluster}}","EcsParameters":{"TaskDefinitionArn":"","TaskCount":1,"LaunchType":"FARGATE"}}]' + +# Run task manually +aws ecs run-task \ + --cluster {{app.Cluster}} \ + --task-definition {{.Env}}-{{app.Name}} \ + --launch-type FARGATE + +# Remove targets and delete rule +aws events remove-targets --rule {{.Env}}-{{app.Name}}-cron --ids {{app.Name}} +aws events delete-rule --name {{.Env}}-{{app.Name}}-cron +` +``` + +Note: `Explain()` in `manager.go` (Task 2) should be removed — it is fully implemented here. + +- [ ] **Step 4: Verify compilation** + +Run: `cd /Users/nya/dev/terraform/ize && go build ./...` + +- [ ] **Step 5: Commit** + +```bash +git add internal/manager/ecscron/ +git commit -m "feat(ecs-cron): add RunNow, GetLastRunLogs, and Explain" +``` + +--- + +## Task 4: Wire `ecs-cron` into existing commands + new `ize cron` subcommand + +**Files:** +- Modify: `internal/commands/deploy.go:127-168` +- Modify: `internal/commands/build.go:93-128` +- Modify: `internal/commands/push.go:94-131` +- Modify: `internal/commands/down.go:311-343` +- Modify: `internal/commands/up_apps.go:124-153` +- Create: `internal/commands/cron.go` +- Modify: `internal/commands/ize.go:74-93` + +- [ ] **Step 1: Add import to all 5 existing command files** + +Add to imports in each file: +```go +ecscron "github.com/hazelops/ize/internal/manager/ecscron" +``` + +- [ ] **Step 2: Add `EcsCron` branch to `deploy.go`, `build.go`, `push.go`, `down.go`, `up_apps.go`** + +In each file, after the existing `if app, ok := o.Config.Ecs[...]; ok { ... }` block, add: + +```go +if app, ok := o.Config.EcsCron[o.AppName]; ok { + app.Name = o.AppName + m = &ecscron.Manager{ + Project: o.Config, + App: app, + } +} +``` + +For `down.go` (`destroyApp`), also set `icon = app.Icon`. + +- [ ] **Step 3: Create `cron.go` — `ize cron run ` and `ize cron logs ` commands** + +```go +package commands + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/hazelops/ize/internal/config" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" + "github.com/hazelops/ize/internal/requirements" + "github.com/hazelops/ize/pkg/terminal" + "github.com/spf13/cobra" +) + +type CronOptions struct { + Config *config.Project + AppName string +} + +func NewCmdCron(project *config.Project) *cobra.Command { + cmd := &cobra.Command{ + Use: "cron", + Short: "Manage ECS cron tasks", + } + + cmd.AddCommand( + NewCmdCronRun(project), + NewCmdCronLogs(project), + ) + + return cmd +} + +func NewCmdCronRun(project *config.Project) *cobra.Command { + o := &CronOptions{Config: project} + + cmd := &cobra.Command{ + Use: "run [app-name]", + Short: "Manually run a cron task outside its schedule", + Args: cobra.ExactArgs(1), + ValidArgsFunction: config.GetApps, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + o.AppName = args[0] + + app, ok := o.Config.EcsCron[o.AppName] + if !ok { + return fmt.Errorf("ecs-cron app %s not found in config", o.AppName) + } + + app.Name = o.AppName + m := &ecscron.Manager{Project: o.Config, App: app} + ui := terminal.ConsoleUI(aws.BackgroundContext(), o.Config.PlainText) + return m.RunNow(ui) + }, + } + + return cmd +} + +func NewCmdCronLogs(project *config.Project) *cobra.Command { + o := &CronOptions{Config: project} + + cmd := &cobra.Command{ + Use: "logs [app-name]", + Short: "Show logs from the last cron task execution", + Args: cobra.ExactArgs(1), + ValidArgsFunction: config.GetApps, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + o.AppName = args[0] + + app, ok := o.Config.EcsCron[o.AppName] + if !ok { + return fmt.Errorf("ecs-cron app %s not found in config", o.AppName) + } + + app.Name = o.AppName + m := &ecscron.Manager{Project: o.Config, App: app} + ui := terminal.ConsoleUI(aws.BackgroundContext(), o.Config.PlainText) + return m.GetLastRunLogs(ui) + }, + } + + return cmd +} +``` + +- [ ] **Step 4: Register `ize cron` in `ize.go`** + +Add `NewCmdCron(project)` to the `cmd.AddCommand(...)` block (~line 74-93). + +- [ ] **Step 5: Verify compilation** + +Run: `cd /Users/nya/dev/terraform/ize && go build ./...` + +- [ ] **Step 6: Commit** + +```bash +git add internal/commands/ +git commit -m "feat(ecs-cron): wire ecs-cron into commands, add ize cron run/logs" +``` + +--- + +## Task 5: Unit tests for ecscron manager + +**Files:** +- Create: `internal/manager/ecscron/manager_test.go` + +- [ ] **Step 1: Implement mock clients** + +Create mock implementations of `ecsiface.ECSAPI`, `eventbridgeiface.EventBridgeAPI`, `ssmiface.SSMAPI`, and `cloudwatchlogsiface.CloudWatchLogsAPI` — only stub the methods used by the manager. Use the standard AWS SDK approach: embed the interface and override specific methods. + +- [ ] **Step 2: Write tests for Deploy, Destroy, RunNow, GetLastRunLogs** + +Test that: +- `Deploy()` calls `PutRule` with the correct schedule, `PutTargets` with correct cluster ARN and task definition +- `Destroy()` calls `ListTargetsByRule`, `RemoveTargets`, `DeleteRule` in correct order; handles "rule not found" gracefully +- `RunNow()` calls `RunTask` with correct cluster, task definition, and subnets +- `GetLastRunLogs()` calls `DescribeLogStreams` + `GetLogEvents` + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/nya/dev/terraform/ize && go test ./internal/manager/ecscron/ -v` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/manager/ecscron/manager_test.go +git commit -m "test(ecs-cron): add unit tests for ecscron manager" +``` + +--- + +## Task 6: Full integration — verify build, vet, and existing tests + +- [ ] **Step 1: Run full build** + +Run: `cd /Users/nya/dev/terraform/ize && go build ./...` + +- [ ] **Step 2: Run all existing tests** + +Run: `cd /Users/nya/dev/terraform/ize && go test ./... -count=1` +Expected: all tests pass (no regressions). + +- [ ] **Step 3: Run go vet** + +Run: `cd /Users/nya/dev/terraform/ize && go vet ./...` +Expected: no issues. + +- [ ] **Step 4: Commit any fixes if needed** + +--- + +## Config usage example + +After implementation, users configure cron tasks in `ize.toml`: + +```toml +# New cron task (schedule required): +[ecs_cron.my_batch_job] +schedule = "cron(0 2 * * ? *)" +# or: schedule = "rate(1 hour)" +# image = "custom-image:tag" # optional, defaults to ECR +# task_definition = "existing-family" # optional, defaults to env-name +# enabled = false # optional, defaults to true +# timeout = 600 # optional, defaults to 300 +# cluster = "custom-cluster" # optional, defaults to env-namespace + +# Existing Terraform-managed cron task (schedule omitted — uses existing rule): +[ecs_cron.my_existing_job] +# schedule not set — rule must already exist in EventBridge +# deploy will only update the task definition target +``` + +CLI commands: +- `ize build my_batch_job` — build Docker image +- `ize push my_batch_job` — push to ECR +- `ize deploy my_batch_job` — register new TD + create/update EventBridge rule+target +- `ize down my_batch_job` — remove rule+targets, deregister TDs +- `ize cron run my_batch_job` — manually trigger the task +- `ize cron logs my_batch_job` — view logs of last execution diff --git a/examples/ecs-apps-monorepo/.ize/env/testnut/cronjob.tf b/examples/ecs-apps-monorepo/.ize/env/testnut/cronjob.tf new file mode 100644 index 00000000..3ac9dd74 --- /dev/null +++ b/examples/ecs-apps-monorepo/.ize/env/testnut/cronjob.tf @@ -0,0 +1,98 @@ +module "cronjob" { + depends_on = [ + module.ecs + ] + source = "registry.terraform.io/hazelops/ecs-app/aws" + version = "~>1.4" + + name = "cronjob" + app_type = "worker" + env = var.env + namespace = var.namespace + ecs_cluster_name = local.ecs_cluster_name + + # Containers + docker_registry = local.docker_registry + docker_image_tag = local.docker_image_tag + iam_instance_profile = local.iam_instance_profile + key_name = local.key_name + + # Network + vpc_id = local.vpc_id + public_subnets = local.public_subnets + private_subnets = local.private_subnets + security_groups = local.security_groups + + # Environment variables + environment = { + APP_NAME = "cronjob" + } +} + +# EventBridge rule to schedule the cron task +resource "aws_cloudwatch_event_rule" "cronjob" { + name = "${var.env}-cronjob-cron" + description = "Schedule for cronjob ECS task" + schedule_expression = "rate(1 hour)" + is_enabled = false # disabled by default for testing +} + +# IAM role for EventBridge to run ECS tasks +resource "aws_iam_role" "cronjob_events" { + name = "${var.env}-cronjob-events" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "events.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy" "cronjob_events" { + name = "${var.env}-cronjob-events" + role = aws_iam_role.cronjob_events.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecs:RunTask" + ] + Resource = ["*"] + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole" + ] + Resource = ["*"] + } + ] + }) +} + +# EventBridge target pointing to the ECS task +resource "aws_cloudwatch_event_target" "cronjob" { + rule = aws_cloudwatch_event_rule.cronjob.name + arn = module.ecs.ecs_cluster_arn + role_arn = aws_iam_role.cronjob_events.arn + + ecs_target { + task_definition_arn = module.cronjob.task_definition_arn + task_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = local.private_subnets + } + } +} diff --git a/examples/ecs-apps-monorepo/.ize/env/testnut/ize.toml b/examples/ecs-apps-monorepo/.ize/env/testnut/ize.toml index 070192c4..c7409f31 100644 --- a/examples/ecs-apps-monorepo/.ize/env/testnut/ize.toml +++ b/examples/ecs-apps-monorepo/.ize/env/testnut/ize.toml @@ -48,6 +48,10 @@ timeout = 0 # (optional) ECS deployment timeout can be s # task_definition_revision = "" # (optional) Task definition revision can be specified here. By default latest revision is used to perform a deployment. Normally this parameter can be used via cli during specific deployment needs. +[ecs_cron.cronjob] +# schedule not set — rule is created by Terraform +# deploy will only update the task definition target + # [serverless.] # node_version = "16" # (optional) Node version that will be used by nvm can be specified here that. Default is v14. # path = "" # (optional) Path to the serverless app directory can be specified here. Normally it's derived from app directory and app name. diff --git a/examples/ecs-apps-monorepo/apps/cronjob/Dockerfile b/examples/ecs-apps-monorepo/apps/cronjob/Dockerfile new file mode 100644 index 00000000..e2bfe0e4 --- /dev/null +++ b/examples/ecs-apps-monorepo/apps/cronjob/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.17 + +ARG PROJECT_PATH=. +WORKDIR /go/src/app +COPY ${PROJECT_PATH}/main.go ./ + +RUN go mod init cronjob + +RUN go build -o /usr/bin/app . + +CMD ["app"] diff --git a/examples/ecs-apps-monorepo/apps/cronjob/main.go b/examples/ecs-apps-monorepo/apps/cronjob/main.go new file mode 100644 index 00000000..63137b41 --- /dev/null +++ b/examples/ecs-apps-monorepo/apps/cronjob/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" + "time" +) + +func main() { + fmt.Printf("Cron job started at %s\n", time.Now().Format(time.RFC3339)) + fmt.Printf("APP_NAME=%s\n", os.Getenv("APP_NAME")) + fmt.Println("Cron job completed successfully") +} diff --git a/internal/commands/build.go b/internal/commands/build.go index 9cf83645..0ea2aa7f 100644 --- a/internal/commands/build.go +++ b/internal/commands/build.go @@ -7,6 +7,7 @@ import ( "github.com/hazelops/ize/internal/manager" "github.com/hazelops/ize/internal/manager/alias" "github.com/hazelops/ize/internal/manager/ecs" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" "github.com/hazelops/ize/internal/manager/serverless" "github.com/hazelops/ize/pkg/templates" "github.com/hazelops/ize/pkg/terminal" @@ -123,6 +124,13 @@ func (o *BuildOptions) Run() error { App: app, } } + if app, ok := o.Config.EcsCron[o.AppName]; ok { + app.Name = o.AppName + m = &ecscron.Manager{ + Project: o.Config, + App: app, + } + } return m.Build(ui) } diff --git a/internal/commands/cron.go b/internal/commands/cron.go new file mode 100644 index 00000000..7a1235af --- /dev/null +++ b/internal/commands/cron.go @@ -0,0 +1,84 @@ +package commands + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/hazelops/ize/internal/config" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" + "github.com/hazelops/ize/pkg/terminal" + "github.com/spf13/cobra" +) + +type CronOptions struct { + Config *config.Project + AppName string +} + +func NewCmdCron(project *config.Project) *cobra.Command { + cmd := &cobra.Command{ + Use: "cron", + Short: "Manage ECS cron tasks", + } + + cmd.AddCommand( + NewCmdCronRun(project), + NewCmdCronLogs(project), + ) + + return cmd +} + +func NewCmdCronRun(project *config.Project) *cobra.Command { + o := &CronOptions{Config: project} + + cmd := &cobra.Command{ + Use: "run [app-name]", + Short: "Manually run a cron task outside its schedule", + Args: cobra.ExactArgs(1), + ValidArgsFunction: config.GetApps, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + o.AppName = args[0] + + app, ok := o.Config.EcsCron[o.AppName] + if !ok { + return fmt.Errorf("ecs-cron app %s not found in config", o.AppName) + } + + app.Name = o.AppName + m := &ecscron.Manager{Project: o.Config, App: app} + ui := terminal.ConsoleUI(aws.BackgroundContext(), o.Config.PlainText) + return m.RunNow(ui) + }, + } + + return cmd +} + +func NewCmdCronLogs(project *config.Project) *cobra.Command { + o := &CronOptions{Config: project} + + cmd := &cobra.Command{ + Use: "logs [app-name]", + Short: "Show logs from the last cron task execution", + Args: cobra.ExactArgs(1), + ValidArgsFunction: config.GetApps, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + o.AppName = args[0] + + app, ok := o.Config.EcsCron[o.AppName] + if !ok { + return fmt.Errorf("ecs-cron app %s not found in config", o.AppName) + } + + app.Name = o.AppName + m := &ecscron.Manager{Project: o.Config, App: app} + ui := terminal.ConsoleUI(aws.BackgroundContext(), o.Config.PlainText) + return m.GetLastRunLogs(ui) + }, + } + + return cmd +} diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 47319df9..c5383811 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -8,6 +8,7 @@ import ( "github.com/hazelops/ize/internal/manager" "github.com/hazelops/ize/internal/manager/alias" "github.com/hazelops/ize/internal/manager/ecs" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" "github.com/hazelops/ize/internal/manager/serverless" "github.com/hazelops/ize/internal/requirements" "github.com/hazelops/ize/pkg/templates" @@ -166,6 +167,13 @@ func (o *DeployOptions) Run() error { App: app, } } + if app, ok := o.Config.EcsCron[o.AppName]; ok { + app.Name = o.AppName + m = &ecscron.Manager{ + Project: o.Config, + App: app, + } + } if len(o.TaskDefinitionRevision) != 0 { err := m.Redeploy(ui) diff --git a/internal/commands/down.go b/internal/commands/down.go index 5536d603..891aaa55 100644 --- a/internal/commands/down.go +++ b/internal/commands/down.go @@ -10,6 +10,7 @@ import ( "github.com/hazelops/ize/internal/manager" "github.com/hazelops/ize/internal/manager/alias" "github.com/hazelops/ize/internal/manager/ecs" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" "github.com/hazelops/ize/internal/manager/serverless" "github.com/hazelops/ize/internal/requirements" "github.com/hazelops/ize/internal/terraform" @@ -341,6 +342,14 @@ func destroyApp(name string, cfg *config.Project, autoApprove bool, ui terminal. } icon = app.Icon } + if app, ok := cfg.EcsCron[name]; ok { + app.Name = name + m = &ecscron.Manager{ + Project: cfg, + App: app, + } + icon = app.Icon + } if len(icon) != 0 { icon += " " diff --git a/internal/commands/ize.go b/internal/commands/ize.go index 41a97074..9777d4be 100644 --- a/internal/commands/ize.go +++ b/internal/commands/ize.go @@ -87,6 +87,7 @@ func newRootCmd(project *config.Project) *cobra.Command { NewDebugCmd(project), NewCmdGen(project), NewCmdPush(project), + NewCmdCron(project), NewCmdUp(project), NewCmdNvm(project), NewValidateCmd(), diff --git a/internal/commands/push.go b/internal/commands/push.go index ce1dd6a7..62bb1eea 100644 --- a/internal/commands/push.go +++ b/internal/commands/push.go @@ -7,6 +7,7 @@ import ( "github.com/hazelops/ize/internal/manager" "github.com/hazelops/ize/internal/manager/alias" "github.com/hazelops/ize/internal/manager/ecs" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" "github.com/hazelops/ize/internal/manager/serverless" "github.com/hazelops/ize/pkg/templates" "github.com/hazelops/ize/pkg/terminal" @@ -122,6 +123,13 @@ func (o *PushOptions) Run() error { App: &config.Ecs{Name: o.AppName}, } } + if app, ok := o.Config.EcsCron[o.AppName]; ok { + app.Name = o.AppName + m = &ecscron.Manager{ + Project: o.Config, + App: app, + } + } if o.Explain { return m.Explain() diff --git a/internal/commands/up_apps.go b/internal/commands/up_apps.go index 1e4abfe0..e6261b99 100644 --- a/internal/commands/up_apps.go +++ b/internal/commands/up_apps.go @@ -9,6 +9,7 @@ import ( "github.com/hazelops/ize/internal/manager" "github.com/hazelops/ize/internal/manager/alias" "github.com/hazelops/ize/internal/manager/ecs" + ecscron "github.com/hazelops/ize/internal/manager/ecscron" "github.com/hazelops/ize/internal/manager/serverless" "github.com/hazelops/ize/internal/requirements" "github.com/hazelops/ize/pkg/templates" @@ -151,6 +152,13 @@ func deployApp(name string, ui terminal.UI, cfg *config.Project, isExplain bool) App: app, } } + if app, ok := cfg.EcsCron[name]; ok { + app.Name = name + m = &ecscron.Manager{ + Project: cfg, + App: app, + } + } if isExplain { return m.Explain() diff --git a/internal/config/app.go b/internal/config/app.go index c0ff8a39..e4adcd03 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -38,3 +38,20 @@ type Alias struct { Icon string `mapstructure:"icon,omitempty"` DependsOn []string `mapstructure:"depends_on"` } + +type EcsCron struct { + Name string `mapstructure:",omitempty"` + Path string `mapstructure:",omitempty"` + Image string `mapstructure:",omitempty"` + Cluster string `mapstructure:",omitempty"` + Schedule string `mapstructure:",omitempty"` + TaskDefinition string `mapstructure:"task_definition,omitempty"` + DockerRegistry string `mapstructure:"docker_registry,omitempty"` + Timeout int `mapstructure:",omitempty"` + SkipDeploy bool `mapstructure:"skip_deploy,omitempty"` + Icon string `mapstructure:"icon,omitempty"` + AwsProfile string `mapstructure:"aws_profile,omitempty"` + AwsRegion string `mapstructure:"aws_region,omitempty"` + DependsOn []string `mapstructure:"depends_on,omitempty"` + Enabled *bool `mapstructure:"enabled,omitempty"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 77bc904b..f19b9980 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -452,6 +452,7 @@ func readConfigFile(path string) (*Config, error) { func ConvertApps() error { ecs := map[string]interface{}{} serverless := map[string]interface{}{} + ecsCron := map[string]interface{}{} apps := viper.GetStringMap("app") for name, app := range apps { @@ -474,6 +475,14 @@ func ConvertApps() error { } serverless[name] = structToMap(slsApp) + case "ecs-cron": + ecsCronApp := EcsCron{} + err := mapstructure.Decode(&body, &ecsCronApp) + if err != nil { + return err + } + + ecsCron[name] = structToMap(ecsCronApp) default: return fmt.Errorf("does not support %s type", t) } @@ -483,6 +492,7 @@ func ConvertApps() error { err := viper.MergeConfigMap(map[string]interface{}{ "ecs": ecs, "serverless": serverless, + "ecs_cron": ecsCron, }) if err != nil { return err diff --git a/internal/config/project.go b/internal/config/project.go index 1ef7395f..1a8af2d3 100644 --- a/internal/config/project.go +++ b/internal/config/project.go @@ -8,6 +8,8 @@ import ( "github.com/aws/aws-sdk-go/service/ecr/ecriface" "github.com/aws/aws-sdk-go/service/ecs" "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + "github.com/aws/aws-sdk-go/service/eventbridge" + "github.com/aws/aws-sdk-go/service/eventbridge/eventbridgeiface" "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" @@ -49,6 +51,7 @@ type Project struct { Ecs map[string]*Ecs `mapstructure:",omitempty"` Serverless map[string]*Serverless `mapstructure:",omitempty"` Alias map[string]*Alias `mapstructure:",omitempty"` + EcsCron map[string]*EcsCron `mapstructure:"ecs_cron,omitempty"` } type awsClient struct { @@ -60,6 +63,7 @@ type awsClient struct { SSMClient ssmiface.SSMAPI ELBV2Client elbv2iface.ELBV2API ECRClient ecriface.ECRAPI + EventBridgeClient eventbridgeiface.EventBridgeAPI } type Option func(*awsClient) @@ -112,6 +116,12 @@ func WithECRClient(api ecriface.ECRAPI) Option { } } +func WithEventBridgeClient(api eventbridgeiface.EventBridgeAPI) Option { + return func(r *awsClient) { + r.EventBridgeClient = api + } +} + func NewAWSClient(options ...Option) *awsClient { r := awsClient{} for _, opt := range options { @@ -131,6 +141,7 @@ func (p *Project) SettingAWSClient(sess *session.Session) { WithSSMClient(ssm.New(sess)), WithELBV2Client(elbv2.New(sess)), WithECRClient(ecr.New(sess)), + WithEventBridgeClient(eventbridge.New(sess)), ) } @@ -161,6 +172,14 @@ func (p *Project) GetApps() map[string]*interface{} { apps[name] = &v } + for name, body := range p.EcsCron { + var v interface{} + v = map[string]interface{}{ + "depends_on": body.DependsOn, + } + apps[name] = &v + } + return apps } diff --git a/internal/manager/ecscron/explain.go b/internal/manager/ecscron/explain.go new file mode 100644 index 00000000..3d99faa0 --- /dev/null +++ b/internal/manager/ecscron/explain.go @@ -0,0 +1,39 @@ +package ecscron + +import ( + "text/template" + + "github.com/hazelops/ize/internal/config" +) + +func (m *Manager) Explain() error { + m.prepare() + return m.Project.Generate(ecsCronExplainTmpl, template.FuncMap{ + "app": func() config.EcsCron { + return *m.App + }, + }) +} + +var ecsCronExplainTmpl = ` +# Create/Update EventBridge rule +aws events put-rule \ + --name {{.Env}}-{{app.Name}}-cron \ + --schedule-expression "{{app.Schedule}}" \ + --state ENABLED + +# Set ECS task as target +aws events put-targets \ + --rule {{.Env}}-{{app.Name}}-cron \ + --targets '[{"Id":"{{app.Name}}","Arn":"arn:aws:ecs:{{.AwsRegion}}::cluster/{{app.Cluster}}","EcsParameters":{"TaskDefinitionArn":"","TaskCount":1,"LaunchType":"FARGATE"}}]' + +# Run task manually +aws ecs run-task \ + --cluster {{app.Cluster}} \ + --task-definition {{.Env}}-{{app.Name}} \ + --launch-type FARGATE + +# Remove targets and delete rule +aws events remove-targets --rule {{.Env}}-{{app.Name}}-cron --ids {{app.Name}} +aws events delete-rule --name {{.Env}}-{{app.Name}}-cron +` diff --git a/internal/manager/ecscron/logs.go b/internal/manager/ecscron/logs.go new file mode 100644 index 00000000..51479aee --- /dev/null +++ b/internal/manager/ecscron/logs.go @@ -0,0 +1,54 @@ +package ecscron + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" +) + +// GetLastRunLogs fetches CloudWatch logs from the most recent task execution. +func (m *Manager) GetLastRunLogs(ui terminal.UI) error { + m.prepare() + + if err := m.ensureSession(); err != nil { + return err + } + + cwl := m.Project.AWSClient.CloudWatchLogsClient + logGroup := fmt.Sprintf("%s-%s", m.Project.Env, m.App.Name) + + out, err := cwl.DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: &logGroup, + Limit: aws.Int64(1), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }) + if err != nil { + return fmt.Errorf("can't describe log streams for %s: %w", logGroup, err) + } + + if len(out.LogStreams) == 0 { + pterm.Info.Printfln("No log streams found for %s", logGroup) + return nil + } + + stream := out.LogStreams[0] + pterm.DefaultSection.Printfln("Logs from %s (stream: %s):", logGroup, *stream.LogStreamName) + + events, err := cwl.GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + LogGroupName: &logGroup, + LogStreamName: stream.LogStreamName, + }) + if err != nil { + return fmt.Errorf("can't get log events: %w", err) + } + + for _, event := range events.Events { + pterm.Println("| " + *event.Message) + } + + return nil +} diff --git a/internal/manager/ecscron/manager.go b/internal/manager/ecscron/manager.go new file mode 100644 index 00000000..54c7c0e0 --- /dev/null +++ b/internal/manager/ecscron/manager.go @@ -0,0 +1,361 @@ +package ecscron + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + "github.com/aws/aws-sdk-go/service/eventbridge" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/hazelops/ize/internal/aws/utils" + "github.com/hazelops/ize/internal/config" + ecsmanager "github.com/hazelops/ize/internal/manager/ecs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" + "github.com/sirupsen/logrus" +) + +type Manager struct { + Project *config.Project + App *config.EcsCron +} + +func (m *Manager) prepare() { + if m.App.Path == "" { + appsPath := m.Project.AppsPath + if !filepath.IsAbs(appsPath) { + appsPath = filepath.Join(os.Getenv("PWD"), appsPath) + } + m.App.Path = filepath.Join(appsPath, m.App.Name) + } else if !filepath.IsAbs(m.App.Path) { + m.App.Path = filepath.Join(m.Project.RootDir, m.App.Path) + } + + if m.App.Cluster == "" { + m.App.Cluster = fmt.Sprintf("%s-%s", m.Project.Env, m.Project.Namespace) + } + + if m.App.DockerRegistry == "" { + m.App.DockerRegistry = m.Project.DockerRegistry + } + + if m.App.Timeout == 0 { + m.App.Timeout = 300 + } +} + +func (m *Manager) ruleName() string { + return fmt.Sprintf("%s-%s-cron", m.Project.Env, m.App.Name) +} + +func (m *Manager) taskFamily() string { + if m.App.TaskDefinition != "" { + return m.App.TaskDefinition + } + return fmt.Sprintf("%s-%s", m.Project.Env, m.App.Name) +} + +func (m *Manager) ensureSession() error { + if m.App.AwsRegion != "" && m.App.AwsProfile != "" { + sess, err := utils.GetSession(&utils.SessionConfig{ + Region: m.App.AwsRegion, + Profile: m.App.AwsProfile, + }) + if err != nil { + return fmt.Errorf("can't get session: %w", err) + } + m.Project.SettingAWSClient(sess) + } + return nil +} + +func (m *Manager) toEcsApp() *config.Ecs { + return &config.Ecs{ + Name: m.App.Name, + Path: m.App.Path, + Image: m.App.Image, + Cluster: m.App.Cluster, + DockerRegistry: m.App.DockerRegistry, + Timeout: m.App.Timeout, + SkipDeploy: m.App.SkipDeploy, + Icon: m.App.Icon, + AwsProfile: m.App.AwsProfile, + AwsRegion: m.App.AwsRegion, + DependsOn: m.App.DependsOn, + } +} + +// Deploy creates or updates an EventBridge rule and its ECS task target. +func (m *Manager) Deploy(ui terminal.UI) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + if m.App.SkipDeploy { + s := sg.Add("%s: deploy will be skipped", m.App.Name) + defer func() { s.Abort(); time.Sleep(50 * time.Millisecond) }() + s.Done() + return nil + } + + s := sg.Add("%s: deploying cron task...", m.App.Name) + defer func() { s.Abort(); time.Sleep(50 * time.Millisecond) }() + + // Resolve image + image := m.App.Image + if image == "" { + image = fmt.Sprintf("%s/%s:%s", + m.App.DockerRegistry, + fmt.Sprintf("%s-%s", m.Project.Namespace, m.App.Name), + fmt.Sprintf("%s-%s", m.Project.Env, "latest")) + } + + ecsSvc := m.Project.AWSClient.ECSClient + ebSvc := m.Project.AWSClient.EventBridgeClient + family := m.taskFamily() + + // 1. Get current task definition and register new revision with updated image + latestTd, err := getLatestTaskDefinition(ecsSvc, family) + if err != nil { + return fmt.Errorf("task definition %s not found — it must be created via Terraform first: %w", family, err) + } + + for _, cd := range latestTd.ContainerDefinitions { + if *cd.Name == m.App.Name { + pterm.Printfln(`Changed image of container "%s" to: "%s" (was: "%s")`, *cd.Name, image, *cd.Image) + cd.Image = &image + } + } + + rtdo, err := ecsSvc.RegisterTaskDefinition(&ecs.RegisterTaskDefinitionInput{ + ContainerDefinitions: latestTd.ContainerDefinitions, + Family: latestTd.Family, + Volumes: latestTd.Volumes, + TaskRoleArn: latestTd.TaskRoleArn, + ExecutionRoleArn: latestTd.ExecutionRoleArn, + RuntimePlatform: latestTd.RuntimePlatform, + RequiresCompatibilities: latestTd.RequiresCompatibilities, + NetworkMode: latestTd.NetworkMode, + Cpu: latestTd.Cpu, + Memory: latestTd.Memory, + }) + if err != nil { + return fmt.Errorf("can't register task definition: %w", err) + } + + newTdArn := *rtdo.TaskDefinition.TaskDefinitionArn + pterm.Printfln("Registered task definition: %s:%d", *rtdo.TaskDefinition.Family, *rtdo.TaskDefinition.Revision) + + // 2. Create or update EventBridge rule + // Three scenarios: + // a) schedule in config -> create/update rule with that schedule + // b) no schedule, rule exists -> keep existing rule (only update target) + // c) no schedule, rule doesn't exist -> error + ruleName := m.ruleName() + + if m.App.Schedule != "" { + // Scenario (a): schedule provided — create or update the rule + state := "ENABLED" + if m.App.Enabled != nil && !*m.App.Enabled { + state = "DISABLED" + } + + _, err = ebSvc.PutRule(&eventbridge.PutRuleInput{ + Name: &ruleName, + ScheduleExpression: &m.App.Schedule, + State: &state, + Description: aws.String(fmt.Sprintf("IZE cron: %s", m.App.Name)), + }) + if err != nil { + return fmt.Errorf("can't create/update EventBridge rule: %w", err) + } + + pterm.Printfln("EventBridge rule %s: schedule=%s state=%s", ruleName, m.App.Schedule, state) + } else { + // Scenario (b)/(c): no schedule — check if rule already exists + dro, err := ebSvc.DescribeRule(&eventbridge.DescribeRuleInput{ + Name: &ruleName, + }) + if err != nil { + return fmt.Errorf("schedule not specified and EventBridge rule %s not found — schedule is required to create a new rule: %w", ruleName, err) + } + + pterm.Printfln("EventBridge rule %s exists (schedule=%s), updating target only", ruleName, *dro.ScheduleExpression) + } + + // 3. Get role ARN from task definition execution role + roleArn := latestTd.ExecutionRoleArn + if roleArn == nil { + return fmt.Errorf("task definition %s has no execution role — required for EventBridge target", family) + } + + // 4. Get network configuration from SSM + nc, err := getNetworkConfigFromSSM(m.Project.AWSClient.SSMClient, m.Project.Env) + if err != nil { + return fmt.Errorf("can't get network configuration: %w", err) + } + + if len(nc.VpcPrivateSubnets.Value) == 0 { + return fmt.Errorf("vpc_private_subnets is empty in terraform output") + } + + // 5. Get account ID for cluster ARN + accountId, err := m.getAccountId() + if err != nil { + return fmt.Errorf("can't get AWS account ID: %w", err) + } + + // 6. Put target + _, err = ebSvc.PutTargets(&eventbridge.PutTargetsInput{ + Rule: &ruleName, + Targets: []*eventbridge.Target{ + { + Id: aws.String(m.App.Name), + Arn: aws.String(fmt.Sprintf("arn:aws:ecs:%s:%s:cluster/%s", m.Project.AwsRegion, accountId, m.App.Cluster)), + RoleArn: roleArn, + EcsParameters: &eventbridge.EcsParameters{ + TaskDefinitionArn: &newTdArn, + TaskCount: aws.Int64(1), + LaunchType: aws.String("FARGATE"), + NetworkConfiguration: &eventbridge.NetworkConfiguration{ + AwsvpcConfiguration: &eventbridge.AwsVpcConfiguration{ + Subnets: aws.StringSlice(nc.VpcPrivateSubnets.Value), + }, + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("can't put EventBridge target: %w", err) + } + + s.Done() + s = sg.Add("%s: cron task deployed! schedule=%s", m.App.Name, m.App.Schedule) + s.Done() + return nil +} + +// Destroy removes the EventBridge rule, its targets, and deregisters task definitions. +func (m *Manager) Destroy(ui terminal.UI, autoApprove bool) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + s := sg.Add("%s: destroying cron task...", m.App.Name) + defer func() { s.Abort(); time.Sleep(200 * time.Millisecond) }() + + ebSvc := m.Project.AWSClient.EventBridgeClient + ecsSvc := m.Project.AWSClient.ECSClient + ruleName := m.ruleName() + + // 1. Remove all targets from the rule + lto, err := ebSvc.ListTargetsByRule(&eventbridge.ListTargetsByRuleInput{ + Rule: &ruleName, + }) + if err != nil { + // Rule may not exist — treat as already destroyed + logrus.Debugf("rule %s may not exist: %v", ruleName, err) + } else if len(lto.Targets) > 0 { + var ids []*string + for _, t := range lto.Targets { + ids = append(ids, t.Id) + } + _, err = ebSvc.RemoveTargets(&eventbridge.RemoveTargetsInput{ + Rule: &ruleName, + Ids: ids, + }) + if err != nil { + return fmt.Errorf("can't remove targets from rule %s: %w", ruleName, err) + } + } + + // 2. Delete the rule (ignore ResourceNotFoundException) + _, err = ebSvc.DeleteRule(&eventbridge.DeleteRuleInput{ + Name: &ruleName, + }) + if err != nil { + logrus.Debugf("can't delete rule %s (may not exist): %v", ruleName, err) + } + + // 3. Deregister task definitions + family := m.taskFamily() + definitions, err := ecsSvc.ListTaskDefinitions(&ecs.ListTaskDefinitionsInput{ + FamilyPrefix: &family, + Sort: aws.String(ecs.SortOrderDesc), + }) + if err == nil { + for _, tda := range definitions.TaskDefinitionArns { + _, _ = ecsSvc.DeregisterTaskDefinition(&ecs.DeregisterTaskDefinitionInput{ + TaskDefinition: tda, + }) + } + } + + s.Done() + s = sg.Add("%s: cron task destroyed!", m.App.Name) + s.Done() + return nil +} + +// Build delegates to the existing ECS manager (same Docker build pipeline). +func (m *Manager) Build(ui terminal.UI) error { + m.prepare() + em := &ecsmanager.Manager{Project: m.Project, App: m.toEcsApp()} + return em.Build(ui) +} + +// Push delegates to the existing ECS manager (same ECR push pipeline). +func (m *Manager) Push(ui terminal.UI) error { + m.prepare() + em := &ecsmanager.Manager{Project: m.Project, App: m.toEcsApp()} + return em.Push(ui) +} + +// Redeploy re-deploys with the current schedule (same as Deploy for cron tasks). +func (m *Manager) Redeploy(ui terminal.UI) error { + return m.Deploy(ui) +} + +func getLatestTaskDefinition(svc ecsiface.ECSAPI, family string) (*ecs.TaskDefinition, error) { + tds, err := svc.ListTaskDefinitions(&ecs.ListTaskDefinitionsInput{ + FamilyPrefix: &family, + Sort: aws.String("DESC"), + }) + if err != nil { + return nil, err + } + if len(tds.TaskDefinitionArns) == 0 { + return nil, fmt.Errorf("no task definitions found for family %s", family) + } + + dtdo, err := svc.DescribeTaskDefinition(&ecs.DescribeTaskDefinitionInput{ + TaskDefinition: tds.TaskDefinitionArns[0], + }) + if err != nil { + return nil, err + } + return dtdo.TaskDefinition, nil +} + +func (m *Manager) getAccountId() (string, error) { + out, err := m.Project.AWSClient.STSClient.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err != nil { + return "", fmt.Errorf("can't get caller identity: %w", err) + } + return *out.Account, nil +} diff --git a/internal/manager/ecscron/manager_test.go b/internal/manager/ecscron/manager_test.go new file mode 100644 index 00000000..2338acfe --- /dev/null +++ b/internal/manager/ecscron/manager_test.go @@ -0,0 +1,371 @@ +package ecscron + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + "github.com/aws/aws-sdk-go/service/eventbridge" + "github.com/aws/aws-sdk-go/service/eventbridge/eventbridgeiface" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/ssm/ssmiface" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/aws/aws-sdk-go/service/sts/stsiface" + "github.com/hazelops/ize/internal/config" + "github.com/hazelops/ize/pkg/terminal" +) + +// --- Mock ECS --- + +type mockECS struct { + ecsiface.ECSAPI + listTDOut *ecs.ListTaskDefinitionsOutput + describeTDOut *ecs.DescribeTaskDefinitionOutput + registerTDOut *ecs.RegisterTaskDefinitionOutput + deregisterErr error + runTaskOut *ecs.RunTaskOutput + runTaskErr error +} + +func (m *mockECS) ListTaskDefinitions(*ecs.ListTaskDefinitionsInput) (*ecs.ListTaskDefinitionsOutput, error) { + return m.listTDOut, nil +} + +func (m *mockECS) DescribeTaskDefinition(*ecs.DescribeTaskDefinitionInput) (*ecs.DescribeTaskDefinitionOutput, error) { + return m.describeTDOut, nil +} + +func (m *mockECS) RegisterTaskDefinition(*ecs.RegisterTaskDefinitionInput) (*ecs.RegisterTaskDefinitionOutput, error) { + return m.registerTDOut, nil +} + +func (m *mockECS) DeregisterTaskDefinition(*ecs.DeregisterTaskDefinitionInput) (*ecs.DeregisterTaskDefinitionOutput, error) { + return nil, m.deregisterErr +} + +func (m *mockECS) RunTaskWithContext(ctx context.Context, input *ecs.RunTaskInput, opts ...request.Option) (*ecs.RunTaskOutput, error) { + if m.runTaskErr != nil { + return nil, m.runTaskErr + } + return m.runTaskOut, nil +} + +// --- Mock EventBridge --- + +type mockEventBridge struct { + eventbridgeiface.EventBridgeAPI + putRuleErr error + putTargetsErr error + describeRuleOut *eventbridge.DescribeRuleOutput + describeRuleErr error + listTargetsOut *eventbridge.ListTargetsByRuleOutput + listTargetsErr error + removeTargErr error + deleteRuleErr error +} + +func (m *mockEventBridge) PutRule(*eventbridge.PutRuleInput) (*eventbridge.PutRuleOutput, error) { + return &eventbridge.PutRuleOutput{}, m.putRuleErr +} + +func (m *mockEventBridge) PutTargets(*eventbridge.PutTargetsInput) (*eventbridge.PutTargetsOutput, error) { + return &eventbridge.PutTargetsOutput{}, m.putTargetsErr +} + +func (m *mockEventBridge) DescribeRule(*eventbridge.DescribeRuleInput) (*eventbridge.DescribeRuleOutput, error) { + return m.describeRuleOut, m.describeRuleErr +} + +func (m *mockEventBridge) ListTargetsByRule(*eventbridge.ListTargetsByRuleInput) (*eventbridge.ListTargetsByRuleOutput, error) { + return m.listTargetsOut, m.listTargetsErr +} + +func (m *mockEventBridge) RemoveTargets(*eventbridge.RemoveTargetsInput) (*eventbridge.RemoveTargetsOutput, error) { + return &eventbridge.RemoveTargetsOutput{}, m.removeTargErr +} + +func (m *mockEventBridge) DeleteRule(*eventbridge.DeleteRuleInput) (*eventbridge.DeleteRuleOutput, error) { + return &eventbridge.DeleteRuleOutput{}, m.deleteRuleErr +} + +// --- Mock SSM --- + +type mockSSM struct { + ssmiface.SSMAPI +} + +func (m *mockSSM) GetParameter(*ssm.GetParameterInput) (*ssm.GetParameterOutput, error) { + nc := networkConfig{} + nc.VpcPrivateSubnets.Value = []string{"subnet-abc123"} + data, _ := json.Marshal(nc) + encoded := base64.StdEncoding.EncodeToString(data) + return &ssm.GetParameterOutput{ + Parameter: &ssm.Parameter{Value: &encoded}, + }, nil +} + +// --- Mock STS --- + +type mockSTS struct { + stsiface.STSAPI +} + +func (m *mockSTS) GetCallerIdentity(*sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + Account: aws.String("123456789012"), + }, nil +} + +// --- Mock CloudWatch Logs --- + +type mockCWL struct { + cloudwatchlogsiface.CloudWatchLogsAPI + streams []*cloudwatchlogs.LogStream + events []*cloudwatchlogs.OutputLogEvent +} + +func (m *mockCWL) DescribeLogStreams(*cloudwatchlogs.DescribeLogStreamsInput) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + return &cloudwatchlogs.DescribeLogStreamsOutput{LogStreams: m.streams}, nil +} + +func (m *mockCWL) GetLogEvents(*cloudwatchlogs.GetLogEventsInput) (*cloudwatchlogs.GetLogEventsOutput, error) { + return &cloudwatchlogs.GetLogEventsOutput{Events: m.events}, nil +} + +// --- Helper --- + +func newTestManager(ecsMock *mockECS, ebMock *mockEventBridge, ssmMock *mockSSM, stsMock *mockSTS, cwlMock *mockCWL) *Manager { + project := &config.Project{ + Env: "test", + Namespace: "ns", + AwsRegion: "us-east-1", + DockerRegistry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + AWSClient: config.NewAWSClient( + config.WithECSClient(ecsMock), + config.WithEventBridgeClient(ebMock), + config.WithSSMClient(ssmMock), + config.WithSTSClient(stsMock), + config.WithCloudWatchLogsClient(cwlMock), + ), + } + + return &Manager{ + Project: project, + App: &config.EcsCron{ + Name: "my-job", + Schedule: "rate(1 hour)", + }, + } +} + +func baseTD() (*ecs.ListTaskDefinitionsOutput, *ecs.DescribeTaskDefinitionOutput, *ecs.RegisterTaskDefinitionOutput) { + arn := "arn:aws:ecs:us-east-1:123456789012:task-definition/test-my-job:1" + family := "test-my-job" + execRole := "arn:aws:iam::123456789012:role/exec-role" + containerName := "my-job" + image := "old-image:latest" + + listOut := &ecs.ListTaskDefinitionsOutput{ + TaskDefinitionArns: []*string{&arn}, + } + descOut := &ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecs.TaskDefinition{ + TaskDefinitionArn: &arn, + Family: &family, + Revision: aws.Int64(1), + ExecutionRoleArn: &execRole, + ContainerDefinitions: []*ecs.ContainerDefinition{ + {Name: &containerName, Image: &image}, + }, + }, + } + regOut := &ecs.RegisterTaskDefinitionOutput{ + TaskDefinition: &ecs.TaskDefinition{ + TaskDefinitionArn: aws.String(arn + "2"), + Family: &family, + Revision: aws.Int64(2), + }, + } + return listOut, descOut, regOut +} + +// --- Tests --- + +func TestDeploy_WithSchedule(t *testing.T) { + listOut, descOut, regOut := baseTD() + + m := newTestManager( + &mockECS{listTDOut: listOut, describeTDOut: descOut, registerTDOut: regOut}, + &mockEventBridge{}, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.Deploy(ui) + if err != nil { + t.Fatalf("Deploy() error = %v", err) + } +} + +func TestDeploy_WithoutSchedule_RuleExists(t *testing.T) { + listOut, descOut, regOut := baseTD() + + m := newTestManager( + &mockECS{listTDOut: listOut, describeTDOut: descOut, registerTDOut: regOut}, + &mockEventBridge{ + describeRuleOut: &eventbridge.DescribeRuleOutput{ + ScheduleExpression: aws.String("rate(1 hour)"), + }, + }, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + m.App.Schedule = "" // no schedule + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.Deploy(ui) + if err != nil { + t.Fatalf("Deploy() without schedule (rule exists) error = %v", err) + } +} + +func TestDeploy_WithoutSchedule_RuleNotFound(t *testing.T) { + listOut, descOut, regOut := baseTD() + + m := newTestManager( + &mockECS{listTDOut: listOut, describeTDOut: descOut, registerTDOut: regOut}, + &mockEventBridge{ + describeRuleErr: fmt.Errorf("ResourceNotFoundException"), + }, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + m.App.Schedule = "" + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.Deploy(ui) + if err == nil { + t.Fatal("Deploy() without schedule and no rule should fail") + } +} + +func TestDestroy(t *testing.T) { + m := newTestManager( + &mockECS{ + listTDOut: &ecs.ListTaskDefinitionsOutput{ + TaskDefinitionArns: []*string{aws.String("arn:1")}, + }, + }, + &mockEventBridge{ + listTargetsOut: &eventbridge.ListTargetsByRuleOutput{ + Targets: []*eventbridge.Target{ + {Id: aws.String("my-job")}, + }, + }, + }, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.Destroy(ui, true) + if err != nil { + t.Fatalf("Destroy() error = %v", err) + } +} + +func TestDestroy_RuleNotFound(t *testing.T) { + m := newTestManager( + &mockECS{ + listTDOut: &ecs.ListTaskDefinitionsOutput{}, + }, + &mockEventBridge{ + listTargetsErr: fmt.Errorf("ResourceNotFoundException"), + deleteRuleErr: fmt.Errorf("ResourceNotFoundException"), + }, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.Destroy(ui, true) + if err != nil { + t.Fatalf("Destroy() with missing rule should succeed, got error = %v", err) + } +} + +func TestRunNow(t *testing.T) { + m := newTestManager( + &mockECS{ + runTaskOut: &ecs.RunTaskOutput{ + Tasks: []*ecs.Task{ + {TaskArn: aws.String("arn:aws:ecs:us-east-1:123456789012:task/test-ns/abc123")}, + }, + }, + }, + &mockEventBridge{}, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.RunNow(ui) + if err != nil { + t.Fatalf("RunNow() error = %v", err) + } +} + +func TestGetLastRunLogs_WithLogs(t *testing.T) { + streamName := "main/my-job/abc123" + m := newTestManager( + &mockECS{}, + &mockEventBridge{}, + &mockSSM{}, + &mockSTS{}, + &mockCWL{ + streams: []*cloudwatchlogs.LogStream{ + {LogStreamName: &streamName}, + }, + events: []*cloudwatchlogs.OutputLogEvent{ + {Message: aws.String("hello from cron")}, + }, + }, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.GetLastRunLogs(ui) + if err != nil { + t.Fatalf("GetLastRunLogs() error = %v", err) + } +} + +func TestGetLastRunLogs_NoStreams(t *testing.T) { + m := newTestManager( + &mockECS{}, + &mockEventBridge{}, + &mockSSM{}, + &mockSTS{}, + &mockCWL{}, + ) + + ui := terminal.ConsoleUI(context.Background(), true) + err := m.GetLastRunLogs(ui) + if err != nil { + t.Fatalf("GetLastRunLogs() with no streams should succeed, got error = %v", err) + } +} diff --git a/internal/manager/ecscron/network.go b/internal/manager/ecscron/network.go new file mode 100644 index 00000000..9237d717 --- /dev/null +++ b/internal/manager/ecscron/network.go @@ -0,0 +1,47 @@ +package ecscron + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/ssm/ssmiface" +) + +type networkConfig struct { + SecurityGroups struct { + Value string `json:"value"` + } `json:"security_groups"` + Subnets struct { + Value [][]string `json:"value"` + } `json:"subnets"` + VpcPrivateSubnets struct { + Value []string `json:"value"` + } `json:"vpc_private_subnets"` + VpcPublicSubnets struct { + Value []string `json:"value"` + } `json:"vpc_public_subnets"` +} + +func getNetworkConfigFromSSM(svc ssmiface.SSMAPI, env string) (*networkConfig, error) { + resp, err := svc.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(fmt.Sprintf("/%s/terraform-output", env)), + WithDecryption: aws.Bool(true), + }) + if err != nil { + return nil, fmt.Errorf("can't get terraform output: %w", err) + } + + value, err := base64.StdEncoding.DecodeString(*resp.Parameter.Value) + if err != nil { + return nil, fmt.Errorf("can't decode terraform output: %w", err) + } + + var nc networkConfig + if err := json.Unmarshal(value, &nc); err != nil { + return nil, fmt.Errorf("can't unmarshal network configuration: %w", err) + } + return &nc, nil +} diff --git a/internal/manager/ecscron/run.go b/internal/manager/ecscron/run.go new file mode 100644 index 00000000..dee17621 --- /dev/null +++ b/internal/manager/ecscron/run.go @@ -0,0 +1,65 @@ +package ecscron + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/hazelops/ize/pkg/terminal" + "github.com/pterm/pterm" + "github.com/sirupsen/logrus" +) + +// RunNow manually triggers the cron task outside its schedule via ECS RunTask. +func (m *Manager) RunNow(ui terminal.UI) error { + m.prepare() + + sg := ui.StepGroup() + defer sg.Wait() + + if err := m.ensureSession(); err != nil { + return err + } + + s := sg.Add("%s: starting cron task manually...", m.App.Name) + defer func() { s.Abort() }() + + ecsSvc := m.Project.AWSClient.ECSClient + family := m.taskFamily() + + nc, err := getNetworkConfigFromSSM(m.Project.AWSClient.SSMClient, m.Project.Env) + if err != nil { + return fmt.Errorf("can't get network configuration: %w", err) + } + + if len(nc.VpcPrivateSubnets.Value) == 0 { + return fmt.Errorf("vpc_private_subnets is empty in terraform output") + } + + out, err := ecsSvc.RunTaskWithContext(context.Background(), &ecs.RunTaskInput{ + TaskDefinition: &family, + StartedBy: aws.String("IZE-cron-manual"), + Cluster: &m.App.Cluster, + LaunchType: aws.String(ecs.LaunchTypeFargate), + NetworkConfiguration: &ecs.NetworkConfiguration{ + AwsvpcConfiguration: &ecs.AwsVpcConfiguration{ + Subnets: aws.StringSlice(nc.VpcPrivateSubnets.Value), + }, + }, + }) + if err != nil { + return fmt.Errorf("can't run task: %w", err) + } + + if len(out.Tasks) == 0 { + return fmt.Errorf("no tasks started") + } + + taskArn := *out.Tasks[0].TaskArn + logrus.Debugf("started task: %s", taskArn) + + s.Done() + pterm.Success.Printfln("Task %s started: %s", m.App.Name, taskArn) + return nil +} diff --git a/internal/schema/ize-spec.json b/internal/schema/ize-spec.json index 0a4cda92..1d27d917 100644 --- a/internal/schema/ize-spec.json +++ b/internal/schema/ize-spec.json @@ -163,6 +163,17 @@ "description": "(optional) Alias mode can be enabled here. This can be used to combine various apps via depends_on parameter.", "additionalProperties": false }, + "ecs_cron": { + "id": "#/properties/ecs_cron", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/ecs_cron" + } + }, + "description": "ECS Cron (scheduled tasks) configuration.", + "additionalProperties": false + }, "terraform": { "id": "#/properties/terraform", "type": "object", @@ -375,6 +386,66 @@ "description": "Ecs app configuration.", "additionalProperties": false }, + "ecs_cron": { + "id": "#/definitions/ecs_cron", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "(optional) Path to ecs cron app folder." + }, + "image": { + "type": "string", + "description": "(optional) Docker image for the scheduled task." + }, + "cluster": { + "type": "string", + "description": "(optional) ECS cluster. Defaults to env-namespace." + }, + "schedule": { + "type": "string", + "description": "(optional) EventBridge schedule expression: rate() or cron(). Required only when creating a new rule. If omitted and rule already exists, the existing schedule is preserved." + }, + "task_definition": { + "type": "string", + "description": "(optional) Existing task definition family to use." + }, + "docker_registry": { + "type": "string", + "description": "(optional) Docker registry. Defaults to ECR." + }, + "timeout": { + "type": "integer", + "description": "(optional) Task timeout in seconds." + }, + "skip_deploy": { + "type": "boolean", + "description": "(optional) Skip deploy." + }, + "icon": { + "type": "string", + "description": "(optional) Icon." + }, + "enabled": { + "type": "boolean", + "description": "(optional) Whether the schedule is enabled. Default true." + }, + "aws_region": { + "type": "string", + "description": "(optional) AWS region override." + }, + "aws_profile": { + "type": "string", + "description": "(optional) AWS profile override." + }, + "depends_on": { + "type": "array", + "description": "(optional) Startup/shutdown dependencies." + } + }, + "description": "ECS Cron (scheduled task) configuration.", + "additionalProperties": false + }, "serverless": { "id": "#/definitions/serverless", "type": "object", diff --git a/tests/e2e/ecs_cron_test.go b/tests/e2e/ecs_cron_test.go new file mode 100644 index 00000000..2bb74234 --- /dev/null +++ b/tests/e2e/ecs_cron_test.go @@ -0,0 +1,239 @@ +//go:build e2e && ecs_cron +// +build e2e,ecs_cron + +package test + +import ( + "os" + "strings" + "testing" +) + +// TestIzeCronUpInfra deploys the infrastructure (VPC, ECS cluster, cronjob task definition, EventBridge rule). +// This test reuses the ecs-apps-monorepo example which now includes a cronjob module. +func TestIzeCronUpInfra(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("up", "infra") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize up infra: %s", stderr) + } + + if !strings.Contains(stdout, "Deploy infra completed!") { + t.Errorf("No success message detected after ize up infra:\n%s", stdout) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronBuild builds the cronjob Docker image. +func TestIzeCronBuild(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("build", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize build cronjob: %s", stderr) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronPush pushes the cronjob image to ECR. +func TestIzeCronPush(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("push", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize push cronjob: %s", stderr) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronDeploy deploys the cron task — registers new task definition revision +// and updates the EventBridge target. Since no schedule is set in ize.toml, +// the existing Terraform-managed rule is kept as-is. +func TestIzeCronDeploy(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("deploy", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize deploy cronjob: %s", stderr) + } + + if !strings.Contains(stdout, "cron task deployed") { + t.Errorf("No success message detected after ize deploy cronjob:\n%s", stdout) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronRun manually triggers the cron task outside its schedule. +func TestIzeCronRun(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("cron", "run", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize cron run cronjob: %s", stderr) + } + + if !strings.Contains(stdout, "Task cronjob started") { + t.Errorf("No success message detected after ize cron run cronjob:\n%s", stdout) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronLogs fetches logs from the last cron task execution. +func TestIzeCronLogs(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("cron", "logs", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize cron logs cronjob: %s", stderr) + } + + // Logs may or may not be available depending on timing, + // but the command should not error out + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronDeployWithSchedule tests deploying with an explicit schedule override. +// This creates/updates the EventBridge rule with a new schedule. +func TestIzeCronDeployWithSchedule(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + // TODO: When CLI flag --schedule is implemented, test schedule override here. + // For now this test verifies the deploy path still works after the first deploy. + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("deploy", "cronjob") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize deploy cronjob: %s", stderr) + } + + if !strings.Contains(stdout, "cron task deployed") { + t.Errorf("No success message detected after ize deploy cronjob:\n%s", stdout) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +} + +// TestIzeCronDown destroys the cron task (EventBridge rule + targets + task definitions). +func TestIzeCronDown(t *testing.T) { + if examplesRootDir == "" { + t.Fatalf("Missing required environment variable IZE_EXAMPLES_PATH") + } + + defer recovery(t) + + ize := NewBinary(t, izeBinary, examplesRootDir) + + stdout, stderr, err := ize.RunRaw("down", "--auto-approve") + + if err != nil { + t.Errorf("error: %s", err) + } + + if stderr != "" { + t.Errorf("unexpected stderr output ize down: %s", stderr) + } + + if !strings.Contains(stdout, "Destroy all completed!") { + t.Errorf("No success message detected after down:\n%s", stdout) + } + + if os.Getenv("RUNNER_DEBUG") == "1" { + t.Log(stdout) + } +}