Skip to content

Commit 2e1b9af

Browse files
feat(ecs): track ECS Service deployment stability via Phase A/B Status polling
Previously the AWS::ECS::Service provisioner returned Success at CCAPI-ack time, so downstream resources (ALB Listener URL consumers such as Grafana) frequently saw 503s before tasks were actually serving traffic. This change holds InProgress until the deployment is operationally stable: rolloutState=COMPLETED, runningCount equals desiredCount, and at least one healthy target exists per attached target group. Stability is tracked by a two-phase Status FSM. Phase A polls the CCAPI request token until it resolves. Once Successful, Phase B polls DescribeServices and DescribeTargetHealth until the operational criteria are met. State is encoded in a composite RequestID of the form formae-ecs/<op>/<unixStart>/<ccapiToken>, set once at Create or Update return and preserved across polls via the SDK's existing StatusCheck semantics. Non-default service shapes — CODE_DEPLOY and EXTERNAL controllers, DAEMON scheduling strategy, classic-ELB attachments without targetGroupArn, desiredCount=0, and missing Cluster — fall through to safe defaults rather than entering Phase B. Two new fixtures, ecs-service-with-lb.pkl and its -update.pkl counterpart, exercise the full TG-health-gated lifecycle end-to-end on real AWS. The debug-conformance workflow timeout is raised from 60m to 90m so ALB-based fixtures have enough wall-clock headroom.
1 parent 4deb19f commit 2e1b9af

17 files changed

Lines changed: 2867 additions & 28 deletions

.github/workflows/debug-conformance.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ jobs:
157157
AWS_REGION: us-east-1
158158
FORMAE_TEST_RUN_ID: debug-${{ github.run_id }}-${{ github.run_attempt }}
159159
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
160-
run: make conformance-test-crud-run conformance-test-discovery-run TEST=${{ matrix.test-case }} PARALLEL=1 TIMEOUT=60
160+
# 90-minute timeout accommodates LB-attached fixtures (ALB provisioning
161+
# + ECS stabilization + destroy + reapply takes 65-80m). Smaller fixtures
162+
# are unaffected — they finish well under the bound.
163+
run: make conformance-test-crud-run conformance-test-discovery-run TEST=${{ matrix.test-case }} PARALLEL=1 TIMEOUT=90
161164

162165
- name: Dump formae client log on failure
163166
if: failure()

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.0
1010
github.com/aws/aws-sdk-go-v2/service/ecs v1.77.0
1111
github.com/aws/aws-sdk-go-v2/service/elasticbeanstalk v1.33.20
12+
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12
1213
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8
1314
github.com/aws/aws-sdk-go-v2/service/lambda v1.90.0
1415
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.6

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/ecs v1.77.0 h1:g3RYQmK6uRU5kOuwDthemuiiTbmw
3838
github.com/aws/aws-sdk-go-v2/service/ecs v1.77.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw=
3939
github.com/aws/aws-sdk-go-v2/service/elasticbeanstalk v1.33.20 h1:0jvM1QtdJPeox3KX7zLV0XWb1rJpvHmVkoAPY3P3hz0=
4040
github.com/aws/aws-sdk-go-v2/service/elasticbeanstalk v1.33.20/go.mod h1:73bxyBZbIDX0ulG9AgJsGgodZSiqwMoWDyiDY8Rj6Zk=
41+
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12 h1:TJXv7kZjdXA2maPDaJFFEQPBrPmvPtMybN3qYDOpJ4Y=
42+
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.12/go.mod h1:lwjtb9DHOAmNt7EUW68Zd1Qd+cPyFxacXHN5c9JZ2VY=
4143
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8 h1:p0oB4eZfBfBAOasnKvHJOlNcuHVE/ieuWs7uIZgQlyQ=
4244
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8/go.mod h1:epCaPnGVdiX5ra1lHPfRkVuiQGxrdY8bRI2FBJU+6ok=
4345
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=

pkg/cfres/ecs/classify.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// © 2025 Platform Engineering Labs Inc.
2+
//
3+
// SPDX-License-Identifier: FSL-1.1-ALv2
4+
5+
package ecs
6+
7+
import (
8+
"errors"
9+
"net"
10+
11+
"github.com/aws/smithy-go"
12+
13+
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
14+
)
15+
16+
// classifyAWSError maps an error from an AWS SDK call (or our own ccx layer) to
17+
// a (errorCode, retryable) verdict. See design Q5 for the full mapping.
18+
//
19+
// Discipline: unknown errors default to TERMINAL (GeneralServiceException) — fail
20+
// loudly rather than poll forever on something we don't recognize. The 20-minute
21+
// operation timeout (via inProgressOrTimeout) means even retryable verdicts can
22+
// escalate to terminal Failure if they persist.
23+
func classifyAWSError(err error) (resource.OperationErrorCode, bool) {
24+
if err == nil {
25+
return "", false
26+
}
27+
var ae smithy.APIError
28+
if errors.As(err, &ae) {
29+
switch ae.ErrorCode() {
30+
// Retryable
31+
case "Throttling", "ThrottlingException", "RequestLimitExceeded",
32+
"TooManyRequestsException":
33+
return resource.OperationErrorCodeThrottling, true
34+
case "ServiceUnavailable", "ServiceUnavailableException":
35+
return resource.OperationErrorCodeServiceInternalError, true
36+
case "InternalFailure", "InternalServerError":
37+
return resource.OperationErrorCodeServiceInternalError, true
38+
// Terminal — auth/permissions
39+
case "AccessDenied", "AccessDeniedException", "UnauthorizedOperation":
40+
return resource.OperationErrorCodeAccessDenied, false
41+
case "ExpiredToken", "InvalidClientTokenId",
42+
"InvalidSignatureException", "SignatureDoesNotMatch":
43+
return resource.OperationErrorCodeInvalidCredentials, false
44+
// Terminal — validation
45+
case "ValidationException", "InvalidParameterException",
46+
"InvalidParameterValueException", "InvalidInputException":
47+
return resource.OperationErrorCodeInvalidRequest, false
48+
}
49+
}
50+
// Network errors with .Timeout() == true → retryable
51+
var netErr net.Error
52+
if errors.As(err, &netErr) && netErr.Timeout() {
53+
return resource.OperationErrorCodeNetworkFailure, true
54+
}
55+
return resource.OperationErrorCodeGeneralServiceException, false
56+
}
57+
58+
// classifyForEntry is used by Create/Update entry. Retryable AWS errors return
59+
// a recoverable ErrorCode so the operator's handlePluginResult schedules a CRUD
60+
// retry of the entire operation. Terminal errors get a non-recoverable code so
61+
// the operator surfaces the failure immediately.
62+
func classifyForEntry(err error, op resource.Operation, nativeID, contextMsg string) *resource.ProgressResult {
63+
code, retryable := classifyAWSError(err)
64+
if retryable {
65+
// Map our verdict to a recoverable code the SDK's recoverableErrorCodes
66+
// table actually recognises. (Throttling, NetworkFailure, ServiceInternalError
67+
// are all in the table — see pkg/plugin/resource/resource.go:172-181.)
68+
switch code {
69+
case resource.OperationErrorCodeThrottling,
70+
resource.OperationErrorCodeNetworkFailure,
71+
resource.OperationErrorCodeServiceInternalError:
72+
// already recoverable
73+
default:
74+
code = resource.OperationErrorCodeThrottling
75+
}
76+
}
77+
return terminalFailurePR(op, nativeID, "", code, contextMsg+": "+err.Error())
78+
}
79+
80+
// terminalFailurePR builds a populated Failure ProgressResult.
81+
func terminalFailurePR(op resource.Operation, nativeID, requestID string,
82+
code resource.OperationErrorCode, msg string) *resource.ProgressResult {
83+
return &resource.ProgressResult{
84+
Operation: op,
85+
OperationStatus: resource.OperationStatusFailure,
86+
NativeID: nativeID,
87+
RequestID: requestID,
88+
ErrorCode: code,
89+
StatusMessage: msg,
90+
}
91+
}
92+
93+
// classifyReadResultForFinal classifies a post-stability Read outcome. Handles
94+
// both Go errors and ReadResult.ErrorCode (ccx.ReadResource maps CCAPI errors
95+
// into ErrorCode without returning a Go error — see pkg/ccx/client.go:294-303).
96+
//
97+
// Returns:
98+
// - ok=true: Read returned non-empty Properties → caller emits Success
99+
// - ok=false, retryable=true: route through inProgressOrFinalReadTimeout (grace-bounded)
100+
// - ok=false, retryable=false: terminal Failure with `code`
101+
func classifyReadResultForFinal(rr *resource.ReadResult, readErr error) (resource.OperationErrorCode, bool, bool) {
102+
if readErr != nil {
103+
code, retryable := classifyAWSError(readErr)
104+
return code, retryable, false
105+
}
106+
if rr == nil {
107+
return resource.OperationErrorCodeGeneralServiceException, false, false
108+
}
109+
switch rr.ErrorCode {
110+
case "":
111+
if rr.Properties == "" {
112+
return "", true, false // retryable: empty body without error
113+
}
114+
return "", false, true // success
115+
case resource.OperationErrorCodeNotFound,
116+
resource.OperationErrorCodeThrottling,
117+
resource.OperationErrorCodeServiceInternalError,
118+
resource.OperationErrorCodeServiceTimeout,
119+
resource.OperationErrorCodeNetworkFailure,
120+
resource.OperationErrorCodeInternalFailure:
121+
return rr.ErrorCode, true, false
122+
case resource.OperationErrorCodeAccessDenied,
123+
resource.OperationErrorCodeInvalidCredentials,
124+
resource.OperationErrorCodeInvalidRequest:
125+
return rr.ErrorCode, false, false
126+
default:
127+
return resource.OperationErrorCodeGeneralServiceException, false, false
128+
}
129+
}

pkg/cfres/ecs/classify_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// © 2025 Platform Engineering Labs Inc.
2+
//
3+
// SPDX-License-Identifier: FSL-1.1-ALv2
4+
5+
//go:build unit
6+
7+
package ecs
8+
9+
import (
10+
"errors"
11+
"net"
12+
"testing"
13+
14+
"github.com/aws/smithy-go"
15+
"github.com/stretchr/testify/assert"
16+
17+
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
18+
)
19+
20+
type fakeAPIError struct{ code, msg string }
21+
22+
func (e *fakeAPIError) Error() string { return e.code + ": " + e.msg }
23+
func (e *fakeAPIError) ErrorCode() string { return e.code }
24+
func (e *fakeAPIError) ErrorMessage() string { return e.msg }
25+
func (e *fakeAPIError) ErrorFault() smithy.ErrorFault { return smithy.FaultServer }
26+
27+
func TestClassifyAWSError_Throttling(t *testing.T) {
28+
code, retryable := classifyAWSError(&fakeAPIError{code: "Throttling", msg: "Rate exceeded"})
29+
assert.True(t, retryable)
30+
assert.Equal(t, resource.OperationErrorCodeThrottling, code)
31+
}
32+
33+
func TestClassifyAWSError_AccessDenied(t *testing.T) {
34+
code, retryable := classifyAWSError(&fakeAPIError{code: "AccessDenied", msg: "denied"})
35+
assert.False(t, retryable)
36+
assert.Equal(t, resource.OperationErrorCodeAccessDenied, code)
37+
}
38+
39+
func TestClassifyAWSError_ExpiredToken(t *testing.T) {
40+
code, retryable := classifyAWSError(&fakeAPIError{code: "ExpiredToken", msg: "expired"})
41+
assert.False(t, retryable)
42+
assert.Equal(t, resource.OperationErrorCodeInvalidCredentials, code)
43+
}
44+
45+
func TestClassifyAWSError_ValidationException(t *testing.T) {
46+
code, retryable := classifyAWSError(&fakeAPIError{code: "ValidationException", msg: "bad input"})
47+
assert.False(t, retryable)
48+
assert.Equal(t, resource.OperationErrorCodeInvalidRequest, code)
49+
}
50+
51+
func TestClassifyAWSError_UnknownCode_TerminalGeneralServiceException(t *testing.T) {
52+
code, retryable := classifyAWSError(&fakeAPIError{code: "SomethingBrandNew", msg: "?"})
53+
assert.False(t, retryable)
54+
assert.Equal(t, resource.OperationErrorCodeGeneralServiceException, code)
55+
}
56+
57+
type timeoutErr struct{}
58+
59+
func (timeoutErr) Error() string { return "i/o timeout" }
60+
func (timeoutErr) Timeout() bool { return true }
61+
func (timeoutErr) Temporary() bool { return true }
62+
63+
var _ net.Error = timeoutErr{}
64+
65+
func TestClassifyAWSError_NetworkTimeout_Retryable(t *testing.T) {
66+
code, retryable := classifyAWSError(timeoutErr{})
67+
assert.True(t, retryable)
68+
assert.Equal(t, resource.OperationErrorCodeNetworkFailure, code)
69+
}
70+
71+
func TestClassifyAWSError_PlainError_TerminalGeneralServiceException(t *testing.T) {
72+
code, retryable := classifyAWSError(errors.New("some random non-AWS error"))
73+
assert.False(t, retryable)
74+
assert.Equal(t, resource.OperationErrorCodeGeneralServiceException, code)
75+
}
76+
77+
func TestTerminalFailurePR(t *testing.T) {
78+
pr := terminalFailurePR(resource.OperationCreate, "nid", "rid",
79+
resource.OperationErrorCodeAccessDenied, "denied here")
80+
assert.Equal(t, resource.OperationCreate, pr.Operation)
81+
assert.Equal(t, resource.OperationStatusFailure, pr.OperationStatus)
82+
assert.Equal(t, "nid", pr.NativeID)
83+
assert.Equal(t, "rid", pr.RequestID)
84+
assert.Equal(t, resource.OperationErrorCodeAccessDenied, pr.ErrorCode)
85+
assert.Contains(t, pr.StatusMessage, "denied here")
86+
}
87+
88+
func TestClassifyReadResultForFinal_Success(t *testing.T) {
89+
rr := &resource.ReadResult{Properties: `{"k":"v"}`}
90+
code, retryable, ok := classifyReadResultForFinal(rr, nil)
91+
assert.True(t, ok)
92+
assert.False(t, retryable)
93+
assert.Equal(t, resource.OperationErrorCode(""), code)
94+
}
95+
96+
func TestClassifyReadResultForFinal_NotFound_Retryable(t *testing.T) {
97+
rr := &resource.ReadResult{ErrorCode: resource.OperationErrorCodeNotFound}
98+
_, retryable, ok := classifyReadResultForFinal(rr, nil)
99+
assert.False(t, ok)
100+
assert.True(t, retryable)
101+
}
102+
103+
func TestClassifyReadResultForFinal_AccessDenied_Terminal(t *testing.T) {
104+
rr := &resource.ReadResult{ErrorCode: resource.OperationErrorCodeAccessDenied}
105+
code, retryable, ok := classifyReadResultForFinal(rr, nil)
106+
assert.False(t, ok)
107+
assert.False(t, retryable)
108+
assert.Equal(t, resource.OperationErrorCodeAccessDenied, code)
109+
}
110+
111+
func TestClassifyReadResultForFinal_EmptyProperties_Retryable(t *testing.T) {
112+
rr := &resource.ReadResult{ErrorCode: "", Properties: ""}
113+
_, retryable, ok := classifyReadResultForFinal(rr, nil)
114+
assert.False(t, ok)
115+
assert.True(t, retryable)
116+
}
117+
118+
func TestClassifyReadResultForFinal_GoError_GoesThroughAWSClassifier(t *testing.T) {
119+
_, retryable, ok := classifyReadResultForFinal(nil, &fakeAPIError{code: "Throttling"})
120+
assert.False(t, ok)
121+
assert.True(t, retryable)
122+
}

pkg/cfres/ecs/clients.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// © 2025 Platform Engineering Labs Inc.
2+
//
3+
// SPDX-License-Identifier: FSL-1.1-ALv2
4+
5+
package ecs
6+
7+
import (
8+
"context"
9+
"fmt"
10+
11+
awsecs "github.com/aws/aws-sdk-go-v2/service/ecs"
12+
awselbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
13+
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
14+
15+
"github.com/platform-engineering-labs/formae-plugin-aws/pkg/ccx"
16+
"github.com/platform-engineering-labs/formae-plugin-aws/pkg/config"
17+
)
18+
19+
// ccxClient is the surface of pkg/ccx the ECS provisioner depends on. Defined
20+
// here so unit tests can mock just the methods we actually call.
21+
type ccxClient interface {
22+
CreateResource(ctx context.Context, req *resource.CreateRequest) (*resource.CreateResult, error)
23+
UpdateResource(ctx context.Context, req *resource.UpdateRequest) (*resource.UpdateResult, error)
24+
StatusResource(ctx context.Context, req *resource.StatusRequest, readFunc func(context.Context, *resource.ReadRequest) (*resource.ReadResult, error)) (*resource.StatusResult, error)
25+
ReadResource(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResult, error)
26+
}
27+
28+
// ecsClient is the surface of the AWS ECS SDK used by the Service provisioner for stability polling.
29+
type ecsClient interface {
30+
DescribeServices(ctx context.Context, params *awsecs.DescribeServicesInput, optFns ...func(*awsecs.Options)) (*awsecs.DescribeServicesOutput, error)
31+
}
32+
33+
// elbv2Client is the surface of the AWS ELBv2 SDK used by the Service provisioner for target-health checks.
34+
type elbv2Client interface {
35+
DescribeTargetHealth(ctx context.Context, params *awselbv2.DescribeTargetHealthInput, optFns ...func(*awselbv2.Options)) (*awselbv2.DescribeTargetHealthOutput, error)
36+
}
37+
38+
func defaultCCXClientFactory(cfg *config.Config) (ccxClient, error) {
39+
return ccx.NewClient(cfg)
40+
}
41+
42+
func defaultECSClientFactory(cfg *config.Config) (ecsClient, error) {
43+
awsCfg, err := cfg.ToAwsConfig(context.Background())
44+
if err != nil {
45+
return nil, fmt.Errorf("ecs: build AWS config: %w", err)
46+
}
47+
return awsecs.NewFromConfig(awsCfg), nil
48+
}
49+
50+
func defaultELBv2ClientFactory(cfg *config.Config) (elbv2Client, error) {
51+
awsCfg, err := cfg.ToAwsConfig(context.Background())
52+
if err != nil {
53+
return nil, fmt.Errorf("elbv2: build AWS config: %w", err)
54+
}
55+
return awselbv2.NewFromConfig(awsCfg), nil
56+
}

0 commit comments

Comments
 (0)