chore: add the upstream helpers the composite alarm change builds on

Composite alarms from upstream (#6408) use SmithyValidationErrors for AWS's
validation messages, and their tests use the CfnStackWaits, PartitionCleanup
and ThreadStates test helpers and ReplacementCleanup.owedPhysicalIds. These
are copied from upstream as they were at that commit; none brings in other
behaviour.
This commit is contained in:
Zakaria
2026-10-24 22:23:29 -04:00
parent fa3327bf71
commit a9df81a294
5 changed files with 454 additions and 0 deletions
@@ -0,0 +1,199 @@
package io.github.hectorvent.floci.testing;
import io.restassured.path.xml.XmlPath;
import io.restassured.path.xml.element.Node;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import java.util.List;
import java.util.Set;
import static io.restassured.RestAssured.given;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.fail;
/**
* DeleteStack answers before the stack is gone. The deletion runs on an executor, so a test
* that looks at the stack's resources right after the answer races it.
*/
public final class CfnStackWaits {
private CfnStackWaits() {
}
/** What DescribeStacks reported: the status and, when present, the StackStatusReason. */
public record StackState(String status, String reason) {
}
private static final Set<String> TERMINAL = Set.of(
"CREATE_COMPLETE", "CREATE_FAILED", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED",
"UPDATE_COMPLETE", "UPDATE_FAILED", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_FAILED",
"DELETE_COMPLETE", "DELETE_FAILED");
private static final Set<String> OPERATION_STARTS = Set.of("CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS");
/**
* Waits until DescribeStacks reports a terminal status for a create or update and returns
* it, so the caller decides what counts as success. A DescribeStacks that is not 200 (the
* stack does not exist) fails at once with the error body rather than polling the clock out;
* ten seconds without a terminal status fails too.
*/
public static StackState awaitTerminal(String stackName) {
return awaitTerminal(stackName, null);
}
public static StackState awaitTerminal(String stackName, String authorization) {
long deadline = System.currentTimeMillis() + 10_000;
StackState last = new StackState(null, "");
while (System.currentTimeMillis() < deadline) {
RequestSpecification request = given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeStacks")
.formParam("StackName", stackName);
if (authorization != null) {
request.header("Authorization", authorization);
}
Response response = request.when().post("/");
if (response.statusCode() != 200) {
fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString());
}
XmlPath xml = response.xmlPath();
String status = xml.getString("**.find { it.name() == 'StackStatus' }");
String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }");
last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason);
if (last.status() != null && TERMINAL.contains(last.status())) {
return last;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted while waiting for stack " + stackName, e);
}
}
fail("Stack " + stackName + " reached no terminal status within ten seconds, last: " + last);
return last;
}
/**
* Waits until DescribeStacks reports {@code expectedStatus} and returns that state. Any other
* terminal status fails at once with its reason, as do a DescribeStacks that is not 200 and ten
* seconds without the expected status.
*/
public static StackState awaitStatus(String stackName, String expectedStatus) {
long deadline = System.currentTimeMillis() + 10_000;
StackState last = new StackState(null, "");
while (System.currentTimeMillis() < deadline) {
Response response = given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeStacks")
.formParam("StackName", stackName)
.when().post("/");
if (response.statusCode() != 200) {
fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString());
}
XmlPath xml = response.xmlPath();
String status = xml.getString("**.find { it.name() == 'StackStatus' }");
String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }");
last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason);
if (expectedStatus.equals(last.status())) {
return last;
}
if (last.status() != null && TERMINAL.contains(last.status())) {
fail("Stack " + stackName + " reached " + last.status() + " instead of " + expectedStatus
+ ": " + last.reason());
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted while waiting for stack " + stackName, e);
}
}
fail("Stack " + stackName + " did not reach " + expectedStatus + " within ten seconds, last: " + last);
return last;
}
/**
* The reason on the first resource event that failed a create or update in the stack's latest
* operation, the one its last stack-level CREATE_IN_PROGRESS or UPDATE_IN_PROGRESS began. The
* stack's own reason only names the failed resource, as on AWS, so the failure's message is read
* here. Fails when DescribeStackEvents is not 200 or no resource failed in that operation.
*/
public static String failedResourceReason(String stackName) {
return failedResourceReason(stackName, null);
}
public static String failedResourceReason(String stackName, String authorization) {
RequestSpecification request = given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeStackEvents")
.formParam("StackName", stackName);
if (authorization != null) {
request.header("Authorization", authorization);
}
Response response = request.when().post("/");
if (response.statusCode() != 200) {
fail("DescribeStackEvents " + stackName + " answered " + response.statusCode() + ": " + response.asString());
}
List<Node> newestFirst = response.xmlPath().getList("**.findAll { it.name() == 'member' }", Node.class);
String reason = null;
for (Node event : newestFirst) {
String status = text(event, "ResourceStatus");
if (text(event, "StackName").equals(text(event, "LogicalResourceId"))
&& text(event, "StackId").equals(text(event, "PhysicalResourceId"))) {
if (OPERATION_STARTS.contains(status)) {
break;
}
} else if ("CREATE_FAILED".equals(status) || "UPDATE_FAILED".equals(status)) {
reason = text(event, "ResourceStatusReason");
}
}
if (reason == null) {
fail("Stack " + stackName + " has no failed resource event in its latest operation: " + response.asString());
}
return reason;
}
private static String text(Node event, String name) {
Node child = event.getNode(name);
return child == null ? "" : child.value();
}
/**
* Waits until DescribeStacks answers 400, which is how a missing stack is reported. Fails on
* DELETE_FAILED or after ten seconds.
*/
public static void awaitStackDeleted(String stackName) {
awaitStackDeleted(stackName, null);
}
public static void awaitStackDeleted(String stackName, String authorization) {
long deadline = System.currentTimeMillis() + 10_000;
String body = "";
while (System.currentTimeMillis() < deadline) {
RequestSpecification request = given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeStacks")
.formParam("StackName", stackName);
if (authorization != null) {
request.header("Authorization", authorization);
}
Response response = request.when().post("/");
body = response.asString();
assertThat(body, not(containsString("<StackStatus>DELETE_FAILED</StackStatus>")));
if (response.statusCode() == 400) {
return;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted while waiting for stack " + stackName + " to be deleted", e);
}
}
fail("Stack " + stackName + " was not deleted within ten seconds: " + body);
}
}
@@ -0,0 +1,47 @@
package io.github.hectorvent.floci.testing;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* Deletes what a test created, in reverse order, after each test. The {@code @QuarkusTest}
* emulator is shared by every test class, so a resource left behind outlives the class that made
* it: one under {@code cn-north-1} shows up in every later cross-region listing (ResourceExplorer
* scans all resources), and an IAM user or an S3 bucket shows up in the next class to list either
* one unscoped. A test that leaves such a resource registers each create here.
*
* <pre>{@code
* @RegisterExtension
* final PartitionCleanup cleanup = new PartitionCleanup();
* ...
* createTable(region, name);
* cleanup.register(() -> deleteTable(region, name));
* }</pre>
*
* A failing cleanup never fails the test: it is reported on stderr so the leak is visible and
* the assertion that actually failed stays the headline.
*/
public final class PartitionCleanup implements AfterEachCallback {
private final Deque<Runnable> actions = new ArrayDeque<>();
public void register(Runnable action) {
actions.push(action);
}
@Override
public void afterEach(ExtensionContext context) {
while (!actions.isEmpty()) {
Runnable action = actions.pop();
try {
action.run();
} catch (RuntimeException | AssertionError e) {
System.err.println("PartitionCleanup: cleanup step failed after "
+ context.getDisplayName() + ": " + e);
}
}
}
}
@@ -0,0 +1,93 @@
package io.github.hectorvent.floci.testing;
import java.time.Duration;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/** Waiting for a thread to reach a lock, for tests that force two requests to overlap. */
public final class ThreadStates {
/** Every state a thread is in once it has stopped running: parked in any way, or ended. */
public static final Set<Thread.State> PARKED_OR_DONE = Collections.unmodifiableSet(EnumSet.of(
Thread.State.BLOCKED, Thread.State.WAITING, Thread.State.TIMED_WAITING, Thread.State.TERMINATED));
private ThreadStates() {}
/**
* Returns once {@code thread} is blocked entering a monitor or parked on a lock, or {@code task} has
* finished, whichever comes first. Code that serializes correctly leaves the second request blocked
* behind the first; code that does not lets it run to the end, which the caller's assertions
* then catch. Polls every 5 ms and gives up after five seconds rather than hang a broken test.
*/
public static void awaitBlockedOrDone(AtomicReference<Thread> thread, Future<?> task)
throws InterruptedException {
awaitBlockedOrDone(thread::get, task::isDone, 5);
}
/**
* Returns once {@code thread} is blocked entering a monitor, parked on a lock, or is no longer alive.
* Same wait as the {@link Future} overload, for a test that owns the {@link Thread} directly; gives up
* after ten seconds and fails, since a thread still running unblocked by then is not overlapping the
* way the test needs.
*/
public static void awaitBlockedOrDone(Thread thread) throws InterruptedException {
assertTrue(awaitBlockedOrDone(() -> thread, () -> !thread.isAlive(), 10),
"worker blocked or finished before timeout");
}
private static boolean awaitBlockedOrDone(Supplier<Thread> thread, BooleanSupplier done, long timeoutSeconds)
throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
while (System.nanoTime() < deadline && !done.getAsBoolean()) {
if (isBlocked(thread.get())) {
return true;
}
Thread.sleep(5);
}
return done.getAsBoolean() || isBlocked(thread.get());
}
private static boolean isBlocked(Thread thread) {
if (thread == null) {
return false;
}
Thread.State state = thread.getState();
return state == Thread.State.BLOCKED || state == Thread.State.WAITING;
}
/**
* Waits until {@code thread} is parked in any way (blocked on a monitor, waiting or timed
* waiting) or has ended, and returns the state it reached. Fails after ten seconds.
*/
public static Thread.State awaitParkedOrDone(Thread thread) {
return awaitState(thread, PARKED_OR_DONE, Duration.ofSeconds(10), "the thread");
}
/**
* Waits until {@code thread} is in one of {@code states} and returns the one it reached, checking
* every millisecond. Fails naming {@code what} and the state it was left in once {@code timeout}
* passes, so a thread that never gets there fails the test instead of hanging it.
*/
public static Thread.State awaitState(Thread thread, Set<Thread.State> states, Duration timeout, String what) {
long deadline = System.nanoTime() + timeout.toNanos();
Thread.State state = thread.getState();
while (!states.contains(state)) {
if (System.nanoTime() - deadline > 0) {
fail(what + " never reached " + states + ", it was " + state);
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
state = thread.getState();
}
return state;
}
}