Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9fe81a9
Enable customer-initiated Blue/Green graceful termination using SIGINT
djglaser Jul 24, 2025
3c60a0e
Implement blue/green read handler changes
djglaser Aug 12, 2025
011c9a2
Cleanup Git history
djglaser Aug 19, 2025
5265a88
r/aws_ecs_service(doc): Add documentation for sigint_cancellation
djglaser Aug 19, 2025
d1ff6c7
Eliminate additional DescribeServices call in Blue/Green rollback
djglaser Aug 19, 2025
9d27bf8
Add acceptance tests for Blue/Green read handler and graceful termina…
djglaser Aug 18, 2025
f8e3b10
Correct schema for blue/green read handler and sigint_cancellation
djglaser Aug 20, 2025
c3a7afa
Add logging and abstract isNewECSDeployment
djglaser Aug 20, 2025
33cd314
Abstract statusServiceWaitForStable refresh member variables
djglaser Aug 20, 2025
e841889
Improves waitServiceStable abstraction for readability
djglaser Aug 21, 2025
9da7f20
Merge branch 'hashicorp:main' into f-bg-sigint-release
djglaser Aug 25, 2025
4829302
changes to rollback goroutine wait pattern
kg-aws Aug 25, 2025
6bd601a
Merge branch 'hashicorp:main' into f-bg-sigint-release
djglaser Aug 26, 2025
e955c2f
Skip sigintRollback acceptance test
djglaser Aug 26, 2025
101608d
Merge branch 'main' into HEAD
ewbankkit Aug 26, 2025
cbae8c3
Add CHANGELOG entries.
ewbankkit Aug 26, 2025
5102bc1
Alphabetize.
ewbankkit Aug 26, 2025
bb35a9e
Use 'acctest.Skip'.
ewbankkit Aug 26, 2025
35f4d81
Skip sigintRollback acceptance test
djglaser Aug 26, 2025
b26ad44
Fix semgrep 'ci.semgrep.aws.prefer-pointer-conversion-int-conversion-…
ewbankkit Aug 26, 2025
4dfe8d8
Fix semgrep 'ci.typed-enum-conversion'.
ewbankkit Aug 26, 2025
be4b43e
Fix semgrep 'ci.calling-fmt.Print-and-variants'.
ewbankkit Aug 26, 2025
83156c9
Fix golangci-lint 'errcheck'.
ewbankkit Aug 26, 2025
c05ab4d
Incorporate PR feedback
kg-aws Aug 26, 2025
3cb072a
Fix golangci-lint 'contextcheck'.
ewbankkit Aug 26, 2025
ffdeb1e
'testAccCheckServiceRemoveDeploymentConfiguration' is unused.
ewbankkit Aug 26, 2025
580e6fb
Change sigint schema name in acceptance test config
djglaser Aug 26, 2025
c1f09a0
Merge commit 'ffdeb1e4bfffc01b597503aa7c1ce2a768ce419d' into HEAD
ewbankkit Aug 26, 2025
5abe3f0
Add copyright header.
ewbankkit Aug 26, 2025
f9a45ff
Fix terrafmt errors.
ewbankkit Aug 26, 2025
295827d
Minimize diffs.
ewbankkit Aug 26, 2025
8c0b5c3
Fix providerlint 'XR007: avoid os/exec.Command'.
ewbankkit Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/43986.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
```release-note:enhancement
resource/aws_ecs_service: Add `sigint_rollback` argument
```

```release-note:enhancement
resource/aws_ecs_service: Change `deployment_configuration` to Optional and Computed
```
208 changes: 188 additions & 20 deletions internal/service/ecs/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"fmt"
"log"
"math"
"slices"
"strconv"
"strings"
"sync"
"time"

"github.com/YakDriver/regexache"
Expand Down Expand Up @@ -614,18 +616,14 @@ func resourceService() *schema.Resource {
"deployment_configuration": {
Type: schema.TypeList,
Optional: true,
Computed: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"strategy": {
Type: schema.TypeString,
Optional: true,
Default: string(awstypes.DeploymentStrategyRolling),
ValidateDiagFunc: enum.Validate[awstypes.DeploymentStrategy](),
},
"bake_time_in_minutes": {
Type: nullable.TypeNullableInt,
Optional: true,
Computed: true,
ValidateFunc: nullable.ValidateTypeStringNullableIntBetween(0, 1440),
},
"lifecycle_hook": {
Expand All @@ -638,11 +636,6 @@ func resourceService() *schema.Resource {
Required: true,
ValidateFunc: verify.ValidARN,
},
names.AttrRoleARN: {
Type: schema.TypeString,
Required: true,
ValidateFunc: verify.ValidARN,
},
"lifecycle_stages": {
Type: schema.TypeList,
Required: true,
Expand All @@ -651,9 +644,20 @@ func resourceService() *schema.Resource {
ValidateDiagFunc: enum.Validate[awstypes.DeploymentLifecycleHookStage](),
},
},
names.AttrRoleARN: {
Type: schema.TypeString,
Required: true,
ValidateFunc: verify.ValidARN,
},
},
},
},
"strategy": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ValidateDiagFunc: enum.Validate[awstypes.DeploymentStrategy](),
},
},
},
},
Expand Down Expand Up @@ -1097,6 +1101,10 @@ func resourceService() *schema.Resource {
},
},
},
"sigint_rollback": {
Type: schema.TypeBool,
Optional: true,
},
names.AttrTags: tftags.TagsSchema(),
names.AttrTagsAll: tftags.TagsSchemaComputed(),
"task_definition": {
Expand Down Expand Up @@ -1430,7 +1438,7 @@ func resourceServiceCreate(ctx context.Context, d *schema.ResourceData, meta any
d.Set(names.AttrARN, output.Service.ServiceArn)

if d.Get("wait_for_steady_state").(bool) {
if _, err := waitServiceStable(ctx, conn, d.Id(), d.Get("cluster").(string), operationTime, d.Timeout(schema.TimeoutCreate)); err != nil {
if _, err := waitServiceStable(ctx, conn, d.Id(), d.Get("cluster").(string), operationTime, d.Get("sigint_rollback").(bool), d.Timeout(schema.TimeoutCreate)); err != nil {
return sdkdiag.AppendErrorf(diags, "waiting for ECS Service (%s) create: %s", d.Id(), err)
}
} else if _, err := waitServiceActive(ctx, conn, d.Id(), d.Get("cluster").(string), d.Timeout(schema.TimeoutCreate)); err != nil {
Expand Down Expand Up @@ -1501,6 +1509,10 @@ func resourceServiceRead(ctx context.Context, d *schema.ResourceData, meta any)
} else {
d.Set("deployment_circuit_breaker", nil)
}

if err := d.Set("deployment_configuration", flattenDeploymentConfiguration(service.DeploymentConfiguration)); err != nil {
return sdkdiag.AppendErrorf(diags, "setting deployment_configuration: %s", err)
}
}
if err := d.Set("deployment_controller", flattenDeploymentController(service.DeploymentController)); err != nil {
return sdkdiag.AppendErrorf(diags, "setting deployment_controller: %s", err)
Expand Down Expand Up @@ -1793,7 +1805,7 @@ func resourceServiceUpdate(ctx context.Context, d *schema.ResourceData, meta any
}

if d.Get("wait_for_steady_state").(bool) {
if _, err := waitServiceStable(ctx, conn, d.Id(), cluster, operationTime, d.Timeout(schema.TimeoutUpdate)); err != nil {
if _, err := waitServiceStable(ctx, conn, d.Id(), cluster, operationTime, d.Get("sigint_rollback").(bool), d.Timeout(schema.TimeoutUpdate)); err != nil {
return sdkdiag.AppendErrorf(diags, "waiting for ECS Service (%s) update: %s", d.Id(), err)
}
} else if _, err := waitServiceActive(ctx, conn, d.Id(), cluster, d.Timeout(schema.TimeoutUpdate)); err != nil {
Expand Down Expand Up @@ -2083,6 +2095,13 @@ const (
serviceStatusStable = "tfSTABLE"
)

var deploymentTerminalStates = enum.Slice(
awstypes.ServiceDeploymentStatusSuccessful,
awstypes.ServiceDeploymentStatusStopped,
awstypes.ServiceDeploymentStatusRollbackFailed,
awstypes.ServiceDeploymentStatusRollbackSuccessful,
)

func statusService(ctx context.Context, conn *ecs.Client, serviceName, clusterNameOrARN string) retry.StateRefreshFunc {
return func() (any, string, error) {
output, err := findServiceNoTagsByTwoPartKey(ctx, conn, serviceName, clusterNameOrARN)
Expand All @@ -2099,13 +2118,14 @@ func statusService(ctx context.Context, conn *ecs.Client, serviceName, clusterNa
}
}

func statusServiceWaitForStable(ctx context.Context, conn *ecs.Client, serviceName, clusterNameOrARN string, operationTime time.Time) retry.StateRefreshFunc {
func statusServiceWaitForStable(ctx context.Context, conn *ecs.Client, serviceName, clusterNameOrARN string, sigintConfig *rollbackState, operationTime time.Time) retry.StateRefreshFunc {
var primaryTaskSet *awstypes.Deployment
var primaryDeploymentArn *string
var isNewPrimaryDeployment bool

return func() (any, string, error) {
outputRaw, serviceStatus, err := statusService(ctx, conn, serviceName, clusterNameOrARN)()

if err != nil {
return nil, "", err
}
Expand Down Expand Up @@ -2136,14 +2156,20 @@ func statusServiceWaitForStable(ctx context.Context, conn *ecs.Client, serviceNa

var err error
primaryDeploymentArn, err = findPrimaryDeploymentARN(ctx, conn, primaryTaskSet, serviceArn, clusterNameOrARN, operationTime)

if err != nil {
return nil, "", err
}
if primaryDeploymentArn == nil {
return output, serviceStatusPending, nil
}
}

if sigintConfig.rollbackConfigured && !sigintConfig.rollbackRoutineStarted {
sigintConfig.waitGroup.Add(1)
go rollbackRoutine(ctx, conn, sigintConfig, primaryDeploymentArn)
sigintConfig.rollbackRoutineStarted = true
}

deploymentStatus, err := findDeploymentStatus(ctx, conn, *primaryDeploymentArn)
if err != nil {
return nil, "", err
Expand All @@ -2164,14 +2190,14 @@ func statusServiceWaitForStable(ctx context.Context, conn *ecs.Client, serviceNa

func findPrimaryTaskSet(deployments []awstypes.Deployment) *awstypes.Deployment {
for _, deployment := range deployments {
if aws.ToString(deployment.Status) == "PRIMARY" {
if aws.ToString(deployment.Status) == taskSetStatusPrimary {
return &deployment
}
}
return nil
}

func findPrimaryDeploymentARN(ctx context.Context, conn *ecs.Client, primaryTaskSet *awstypes.Deployment, serviceArn, clusterNameOrARN string, operationTime time.Time) (*string, error) {
func findPrimaryDeploymentARN(ctx context.Context, conn *ecs.Client, primaryTaskSet *awstypes.Deployment, serviceNameOrARN, clusterNameOrARN string, operationTime time.Time) (*string, error) {
parts := strings.Split(aws.ToString(primaryTaskSet.Id), "/")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid primary task set ID format: %s", aws.ToString(primaryTaskSet.Id))
Expand All @@ -2180,7 +2206,7 @@ func findPrimaryDeploymentARN(ctx context.Context, conn *ecs.Client, primaryTask

input := &ecs.ListServiceDeploymentsInput{
Cluster: aws.String(clusterNameOrARN),
Service: aws.String(serviceNameFromARN(serviceArn)),
Service: aws.String(serviceNameFromARN(serviceNameOrARN)),
CreatedAt: &awstypes.CreatedAt{
After: &operationTime,
},
Expand Down Expand Up @@ -2235,18 +2261,102 @@ func findDeploymentStatus(ctx context.Context, conn *ecs.Client, deploymentArn s
}
}

type rollbackState struct {
rollbackConfigured bool
rollbackRoutineStarted bool
rollbackRoutineStopped chan struct{}
waitGroup sync.WaitGroup
}

func rollbackRoutine(ctx context.Context, conn *ecs.Client, rollbackState *rollbackState, primaryDeploymentArn *string) {
defer rollbackState.waitGroup.Done()

select {
case <-ctx.Done():
log.Printf("[INFO] SIGINT detected. Initiating rollback for deployment: %s", *primaryDeploymentArn)
ctx, cancel := context.WithTimeout(context.Background(), (1 * time.Hour)) // Maximum time before SIGKILL
defer cancel()

if err := rollbackDeployment(ctx, conn, primaryDeploymentArn); err != nil { //nolint:contextcheck // Original Context has been cancelled
log.Printf("[ERROR] Failed to rollback deployment: %s. Err: %s", *primaryDeploymentArn, err)
} else {
log.Printf("[INFO] Deployment: %s rolled back successfully.", *primaryDeploymentArn)
}

case <-rollbackState.rollbackRoutineStopped:
return
}
}

func rollbackDeployment(ctx context.Context, conn *ecs.Client, primaryDeploymentArn *string) error {
// Check if deployment is already in terminal state, meaning rollback is not needed
deploymentStatus, err := findDeploymentStatus(ctx, conn, *primaryDeploymentArn)
if err != nil {
return err
}
if slices.Contains(deploymentTerminalStates, deploymentStatus) {
return nil
}

log.Printf("[INFO] Rolling back deployment %s. This may take a few minutes...", *primaryDeploymentArn)

input := &ecs.StopServiceDeploymentInput{
ServiceDeploymentArn: primaryDeploymentArn,
StopType: awstypes.StopServiceDeploymentStopTypeRollback,
}

_, err = conn.StopServiceDeployment(ctx, input)
if err != nil {
return err
}

return waitForDeploymentTerminalStatus(ctx, conn, *primaryDeploymentArn)
}

func waitForDeploymentTerminalStatus(ctx context.Context, conn *ecs.Client, primaryDeploymentArn string) error {
stateConf := &retry.StateChangeConf{
Pending: enum.Slice(
awstypes.ServiceDeploymentStatusPending,
awstypes.ServiceDeploymentStatusInProgress,
awstypes.ServiceDeploymentStatusRollbackRequested,
awstypes.ServiceDeploymentStatusRollbackInProgress,
),
Target: deploymentTerminalStates,
Refresh: func() (any, string, error) {
status, err := findDeploymentStatus(ctx, conn, primaryDeploymentArn)
return nil, status, err
},
Timeout: 1 * time.Hour, // Maximum time before SIGKILL
}

_, err := stateConf.WaitForStateContext(ctx)
return err
}

// waitServiceStable waits for an ECS Service to reach the status "ACTIVE" and have all desired tasks running.
// Does not return tags.
func waitServiceStable(ctx context.Context, conn *ecs.Client, serviceName, clusterNameOrARN string, operationTime time.Time, timeout time.Duration) (*awstypes.Service, error) { //nolint:unparam
func waitServiceStable(ctx context.Context, conn *ecs.Client, serviceName, clusterNameOrARN string, operationTime time.Time, sigintCancellation bool, timeout time.Duration) (*awstypes.Service, error) { //nolint:unparam
sigintConfig := &rollbackState{
rollbackConfigured: sigintCancellation,
rollbackRoutineStarted: false,
rollbackRoutineStopped: make(chan struct{}),
waitGroup: sync.WaitGroup{},
}

stateConf := &retry.StateChangeConf{
Pending: []string{serviceStatusInactive, serviceStatusDraining, serviceStatusPending},
Target: []string{serviceStatusStable},
Refresh: statusServiceWaitForStable(ctx, conn, serviceName, clusterNameOrARN, operationTime),
Refresh: statusServiceWaitForStable(ctx, conn, serviceName, clusterNameOrARN, sigintConfig, operationTime),
Timeout: timeout,
}

outputRaw, err := stateConf.WaitForStateContext(ctx)

if sigintConfig.rollbackRoutineStarted {
close(sigintConfig.rollbackRoutineStopped)
sigintConfig.waitGroup.Wait()
}

if output, ok := outputRaw.(*awstypes.Service); ok {
return output, err
}
Expand Down Expand Up @@ -2439,6 +2549,64 @@ func flattenDeploymentCircuitBreaker(apiObject *awstypes.DeploymentCircuitBreake
return tfMap
}

func flattenDeploymentConfiguration(apiObject *awstypes.DeploymentConfiguration) []any {
if apiObject == nil {
return nil
}

tfMap := map[string]any{}

if v := apiObject.BakeTimeInMinutes; v != nil {
tfMap["bake_time_in_minutes"] = flex.Int32ToStringValue(v)
}

if v := apiObject.LifecycleHooks; len(v) > 0 {
tfMap["lifecycle_hook"] = flattenLifecycleHooks(v)
}

if v := apiObject.Strategy; v != "" {
tfMap["strategy"] = v
}

if len(tfMap) == 0 {
return nil
}

return []any{tfMap}
}

func flattenLifecycleHooks(apiObjects []awstypes.DeploymentLifecycleHook) []any {
if len(apiObjects) == 0 {
return nil
}

tfList := make([]any, 0, len(apiObjects))

for _, apiObject := range apiObjects {
tfMap := map[string]any{}

if v := apiObject.HookTargetArn; v != nil {
tfMap["hook_target_arn"] = aws.ToString(v)
}

if v := apiObject.RoleArn; v != nil {
tfMap[names.AttrRoleARN] = aws.ToString(v)
}

if v := apiObject.LifecycleStages; len(v) > 0 {
stages := make([]string, 0, len(v))
for _, stage := range v {
stages = append(stages, string(stage))
}
tfMap["lifecycle_stages"] = stages
}

tfList = append(tfList, tfMap)
}

return tfList
}

func expandLifecycleHooks(tfList []any) []awstypes.DeploymentLifecycleHook {
apiObject := make([]awstypes.DeploymentLifecycleHook, 0, len(tfList))

Expand Down
Loading
Loading