Skip to content

Commit f434728

Browse files
test(servicediscovery): bound the Cloud Map integration test's runtime
The step ceilings summed to more than the test binary's timeout once the teardown was counted, so a run that lost a ceiling could be killed before its cleanups ran, leaving a live Fargate task and a VPC in the account with no diagnostic beyond a job timeout. Derive every wait from one context carrying a 20 minute budget, released after the last cleanup, and shrink the ceilings to fit inside it: 12 minutes of assertions and at most about 7m15s of teardown. Retry a probe error within its ceiling as well, so a single throttled call no longer fails the test, while an operation AWS has already reported as failed still stops the wait at once.
1 parent 506b07d commit f434728

2 files changed

Lines changed: 145 additions & 31 deletions

File tree

pkg/cfres/servicediscovery/privatednsnamespace_integration_test.go

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ package servicediscovery
2424
import (
2525
"context"
2626
"encoding/json"
27+
"errors"
2728
"fmt"
2829
"os"
2930
"sort"
@@ -74,18 +75,30 @@ const (
7475
elasticNetworkInterfaceAttachment = "ElasticNetworkInterface"
7576
)
7677

77-
// The ceilings the waits run against. Each is generous against what the
78-
// operation takes in practice — a namespace settles in well under a minute, a
79-
// task reaches RUNNING in a couple, and deregistration follows the task
78+
// itestBudget is the wall clock the whole test runs against — the assertions
79+
// and the teardown alike, since every wait below derives from a context
80+
// carrying it. It sits far enough inside the test binary's timeout and the CI
81+
// job's ceiling that a run which goes wrong fails with a diagnostic of its own
82+
// and still reaches its cleanups, rather than being killed mid-test with a live
83+
// Fargate task and a VPC left in the account.
84+
//
85+
// The step ceilings below are sized to fit inside it: 2+4+2+2+2 = 12 minutes of
86+
// assertions, and a teardown of at most the drain ceiling plus seven cleanup
87+
// ceilings (2m + 7×45s ≈ 7m15s), for a worst case of about 19m15s.
88+
const itestBudget = 20 * time.Minute
89+
90+
// The ceilings the individual waits run against. Each is generous against what
91+
// the operation takes in practice — a namespace settles in well under a minute,
92+
// a task reaches RUNNING in a couple, and deregistration follows the task
8093
// stopping — and every one of them fails the test when it runs out, so a
8194
// contract that never holds is a failure rather than a pass that waited.
8295
const (
83-
itestNamespaceTimeout = 4 * time.Minute
84-
itestTaskRunningTimeout = 6 * time.Minute
85-
itestRegistrationTimeout = 3 * time.Minute
86-
itestServiceDrainTimeout = 5 * time.Minute
87-
itestDeregistrationTimeout = 5 * time.Minute
88-
itestCleanupTimeout = 2 * time.Minute
96+
itestNamespaceTimeout = 2 * time.Minute
97+
itestTaskRunningTimeout = 4 * time.Minute
98+
itestRegistrationTimeout = 2 * time.Minute
99+
itestServiceDrainTimeout = 2 * time.Minute
100+
itestDeregistrationTimeout = 2 * time.Minute
101+
itestCleanupTimeout = 45 * time.Second
89102
itestPollInterval = 5 * time.Second
90103
itestNamespacePollInterval = 3 * time.Second
91104
)
@@ -100,7 +113,11 @@ func TestPrivateDnsNamespace_Integration_ECSRegistersAndDeregistersTask(t *testi
100113
t.Skip("AWS_REGION not set; skipping integration test")
101114
}
102115

103-
ctx := context.Background()
116+
// Registered before any other cleanup, so it is released last: the cleanups
117+
// run against this same context and need it to still be live.
118+
ctx, cancel := context.WithTimeout(context.Background(), itestBudget)
119+
t.Cleanup(cancel)
120+
104121
cfg := &config.Config{Region: region}
105122
awsCfg, err := cfg.ToAwsConfig(ctx)
106123
require.NoError(t, err, "loading AWS config")
@@ -145,7 +162,7 @@ func TestPrivateDnsNamespace_Integration_ECSRegistersAndDeregistersTask(t *testi
145162
if ecsServiceDeleted {
146163
return
147164
}
148-
if err := deleteECSService(context.Background(), ecsClient, clusterARN, names.ecsService); err != nil {
165+
if err := deleteECSService(ctx, ecsClient, clusterARN, names.ecsService); err != nil {
149166
t.Logf("warning: deleting ECS service %s: %v", names.ecsService, err)
150167
}
151168
})
@@ -234,7 +251,7 @@ func setupNetwork(t *testing.T, ctx context.Context, client *ec2.Client, names i
234251
require.NoError(t, err, "creating VPC")
235252
vpcID := aws.ToString(vpcOut.Vpc.VpcId)
236253
t.Cleanup(func() {
237-
deleteWithRetry(t, "VPC "+vpcID, func(ctx context.Context) error {
254+
deleteWithRetry(t, ctx, "VPC "+vpcID, func(ctx context.Context) error {
238255
_, err := client.DeleteVpc(ctx, &ec2.DeleteVpcInput{VpcId: aws.String(vpcID)})
239256
return err
240257
})
@@ -259,7 +276,7 @@ func setupNetwork(t *testing.T, ctx context.Context, client *ec2.Client, names i
259276
require.NoError(t, err, "creating internet gateway")
260277
igwID := aws.ToString(igwOut.InternetGateway.InternetGatewayId)
261278
t.Cleanup(func() {
262-
deleteWithRetry(t, "internet gateway "+igwID, func(ctx context.Context) error {
279+
deleteWithRetry(t, ctx, "internet gateway "+igwID, func(ctx context.Context) error {
263280
if _, err := client.DetachInternetGateway(ctx, &ec2.DetachInternetGatewayInput{
264281
InternetGatewayId: aws.String(igwID),
265282
VpcId: aws.String(vpcID),
@@ -300,7 +317,7 @@ func setupNetwork(t *testing.T, ctx context.Context, client *ec2.Client, names i
300317
t.Cleanup(func() {
301318
// A stopped task's ENI is released after the task itself is gone, so the
302319
// subnet delete is retried until the release lands.
303-
deleteWithRetry(t, "subnet "+subnetID, func(ctx context.Context) error {
320+
deleteWithRetry(t, ctx, "subnet "+subnetID, func(ctx context.Context) error {
304321
_, err := client.DeleteSubnet(ctx, &ec2.DeleteSubnetInput{SubnetId: aws.String(subnetID)})
305322
return err
306323
})
@@ -367,7 +384,7 @@ func setupCluster(t *testing.T, ctx context.Context, client *ecs.Client, names i
367384
require.NoError(t, err, "creating ECS cluster %s", names.cluster)
368385
clusterARN := aws.ToString(out.Cluster.ClusterArn)
369386
t.Cleanup(func() {
370-
deleteWithRetry(t, "ECS cluster "+clusterARN, func(ctx context.Context) error {
387+
deleteWithRetry(t, ctx, "ECS cluster "+clusterARN, func(ctx context.Context) error {
371388
_, err := client.DeleteCluster(ctx, &ecs.DeleteClusterInput{Cluster: aws.String(clusterARN)})
372389
return err
373390
})
@@ -397,7 +414,7 @@ func setupTaskDefinition(t *testing.T, ctx context.Context, client *ecs.Client,
397414
require.NoError(t, err, "registering task definition %s", names.taskDefinition)
398415
taskDefinitionARN := aws.ToString(out.TaskDefinition.TaskDefinitionArn)
399416
t.Cleanup(func() {
400-
deleteWithRetry(t, "task definition "+taskDefinitionARN, func(ctx context.Context) error {
417+
deleteWithRetry(t, ctx, "task definition "+taskDefinitionARN, func(ctx context.Context) error {
401418
_, err := client.DeregisterTaskDefinition(ctx, &ecs.DeregisterTaskDefinitionInput{
402419
TaskDefinition: aws.String(taskDefinitionARN),
403420
})
@@ -446,7 +463,7 @@ func setupNamespace(
446463
require.NotEmpty(t, namespaceID, "namespace create returned no native id")
447464

448465
t.Cleanup(func() {
449-
deleteNamespace(t, provisioner, namespaceID)
466+
deleteNamespace(t, ctx, provisioner, namespaceID)
450467
})
451468

452469
require.NoError(t,
@@ -459,9 +476,8 @@ func setupNamespace(
459476
// delete to settle. The provisioner re-issues a delete Cloud Map rejects while
460477
// the namespace still holds resources, so this tolerates a Cloud Map service
461478
// whose own delete has not landed yet.
462-
func deleteNamespace(t *testing.T, provisioner *PrivateDnsNamespace, namespaceID string) {
479+
func deleteNamespace(t *testing.T, ctx context.Context, provisioner *PrivateDnsNamespace, namespaceID string) {
463480
t.Helper()
464-
ctx := context.Background()
465481
deleteResult, err := provisioner.Delete(ctx, &resource.DeleteRequest{
466482
NativeID: namespaceID,
467483
ResourceType: resourceType,
@@ -474,7 +490,7 @@ func deleteNamespace(t *testing.T, provisioner *PrivateDnsNamespace, namespaceID
474490
return
475491
}
476492
if err := awaitProvisionerSuccess(
477-
ctx, provisioner, namespaceID, deleteResult.ProgressResult.RequestID, itestNamespaceTimeout,
493+
ctx, provisioner, namespaceID, deleteResult.ProgressResult.RequestID, itestCleanupTimeout,
478494
); err != nil {
479495
t.Logf("warning: waiting for namespace %s to be deleted: %v", namespaceID, err)
480496
}
@@ -507,7 +523,9 @@ func awaitProvisionerSuccess(
507523
case resource.OperationStatusSuccess:
508524
return true, nil
509525
case resource.OperationStatusFailure:
510-
return false, fmt.Errorf("namespace %s reported failure: %s", namespaceID, progress.StatusMessage)
526+
return false, &terminalError{
527+
err: fmt.Errorf("namespace %s reported failure: %s", namespaceID, progress.StatusMessage),
528+
}
511529
default:
512530
return false, nil
513531
}
@@ -545,7 +563,7 @@ func setupCloudMapService(
545563
t.Cleanup(func() {
546564
// Cloud Map rejects the delete while instances are still registered, so
547565
// this is retried for as long as deregistration may take.
548-
deleteWithRetry(t, "Cloud Map service "+serviceID, func(ctx context.Context) error {
566+
deleteWithRetry(t, ctx, "Cloud Map service "+serviceID, func(ctx context.Context) error {
549567
_, err := client.DeleteService(ctx, &servicediscoverysdk.DeleteServiceInput{
550568
Id: aws.String(serviceID),
551569
})
@@ -713,9 +731,8 @@ func deleteECSService(ctx context.Context, client *ecs.Client, cluster, service
713731
// one that never does is reported rather than failing the test: the assertions
714732
// have already run by then, and a leftover resource is the account sweep's to
715733
// reap.
716-
func deleteWithRetry(t *testing.T, description string, remove func(context.Context) error) {
734+
func deleteWithRetry(t *testing.T, ctx context.Context, description string, remove func(context.Context) error) {
717735
t.Helper()
718-
ctx := context.Background()
719736
var lastErr error
720737
err := waitFor(ctx, itestCleanupTimeout, itestPollInterval, func(ctx context.Context) (bool, error) {
721738
lastErr = remove(ctx)
@@ -726,31 +743,59 @@ func deleteWithRetry(t *testing.T, description string, remove func(context.Conte
726743
}
727744
}
728745

729-
// waitFor calls probe every interval until it reports done, reports an error, or
730-
// the timeout runs out. A probe that reports neither done nor an error is one
731-
// whose condition has not been reached yet.
746+
// terminalError marks a probe error retrying cannot resolve — an operation AWS
747+
// has already reported as failed — so waitFor reports it at once instead of
748+
// polling out the rest of its ceiling.
749+
type terminalError struct {
750+
err error
751+
}
752+
753+
func (e *terminalError) Error() string { return e.err.Error() }
754+
755+
func (e *terminalError) Unwrap() error { return e.err }
756+
757+
// waitFor calls probe every interval until it reports done, reports a
758+
// terminalError, or the timeout runs out. A probe that reports neither done nor
759+
// an error is one whose condition has not been reached yet.
760+
//
761+
// Any other probe error is treated as transient and retried within the ceiling,
762+
// so a single throttled call does not fail the test; an error that persists is
763+
// reported alongside the timeout.
732764
func waitFor(
733765
ctx context.Context,
734766
timeout time.Duration,
735767
interval time.Duration,
736768
probe func(context.Context) (bool, error),
737769
) error {
738770
deadline := time.Now().Add(timeout)
771+
var lastErr error
739772
for {
740773
done, err := probe(ctx)
741-
if err != nil {
774+
var terminal *terminalError
775+
if errors.As(err, &terminal) {
742776
return err
743777
}
744-
if done {
778+
if err == nil && done {
745779
return nil
746780
}
781+
lastErr = err
747782
if time.Now().After(deadline) {
748-
return fmt.Errorf("timed out after %s", timeout)
783+
return withLastProbeError(fmt.Errorf("timed out after %s", timeout), lastErr)
749784
}
750785
select {
751786
case <-ctx.Done():
752-
return ctx.Err()
787+
return withLastProbeError(ctx.Err(), lastErr)
753788
case <-time.After(interval):
754789
}
755790
}
756791
}
792+
793+
// withLastProbeError appends the error the last probe reported, if any, to the
794+
// reason the wait gave up, so a wait that ran out against a persistently failing
795+
// call says what was failing.
796+
func withLastProbeError(err error, lastErr error) error {
797+
if lastErr == nil {
798+
return err
799+
}
800+
return fmt.Errorf("%w (last probe error: %v)", err, lastErr)
801+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// © 2025 Platform Engineering Labs Inc.
2+
//
3+
// SPDX-License-Identifier: FSL-1.1-ALv2
4+
5+
//go:build integration
6+
7+
// Tests for the polling helper the integration test's waits are built on. They
8+
// touch no cloud resource, but live under the same build tag as the helper.
9+
10+
package servicediscovery
11+
12+
import (
13+
"context"
14+
"errors"
15+
"testing"
16+
"time"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
func TestWaitFor_RetriesATransientProbeError(t *testing.T) {
23+
calls := 0
24+
err := waitFor(context.Background(), time.Second, time.Millisecond,
25+
func(context.Context) (bool, error) {
26+
calls++
27+
if calls == 1 {
28+
return false, errors.New("throttled")
29+
}
30+
return true, nil
31+
})
32+
33+
require.NoError(t, err)
34+
assert.Equal(t, 2, calls, "the probe should be called again after a transient error")
35+
}
36+
37+
func TestWaitFor_ReportsAPersistentProbeErrorOnceTheCeilingRunsOut(t *testing.T) {
38+
err := waitFor(context.Background(), 10*time.Millisecond, time.Millisecond,
39+
func(context.Context) (bool, error) {
40+
return false, errors.New("access denied")
41+
})
42+
43+
require.Error(t, err)
44+
assert.Contains(t, err.Error(), "timed out")
45+
assert.Contains(t, err.Error(), "access denied")
46+
}
47+
48+
func TestWaitFor_ReportsATerminalProbeErrorWithoutRetrying(t *testing.T) {
49+
calls := 0
50+
err := waitFor(context.Background(), time.Minute, time.Millisecond,
51+
func(context.Context) (bool, error) {
52+
calls++
53+
return false, &terminalError{err: errors.New("operation failed")}
54+
})
55+
56+
require.Error(t, err)
57+
assert.Equal(t, "operation failed", err.Error())
58+
assert.Equal(t, 1, calls, "a terminal error should not be retried")
59+
}
60+
61+
func TestWaitFor_StopsWhenTheContextIsDone(t *testing.T) {
62+
ctx, cancel := context.WithCancel(context.Background())
63+
cancel()
64+
65+
err := waitFor(ctx, time.Minute, time.Millisecond,
66+
func(context.Context) (bool, error) { return false, nil })
67+
68+
require.ErrorIs(t, err, context.Canceled)
69+
}

0 commit comments

Comments
 (0)