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,97 @@
package io.github.hectorvent.floci.core.common;
import java.util.List;
/**
* The message AWS's Smithy validation layer answers when a request breaks its model's constraints:
* "1 validation error detected: a" or "N validation errors detected: a; b", each violation reading
* "Value at 'path' failed to satisfy constraint: ...". The wording is Smithy's, shared by every
* service built on it; only the error code differs by protocol.
*/
public final class SmithyValidationErrors {
private SmithyValidationErrors() {}
/**
* The message for one or more violations. An empty list is the caller's bug, so it fails rather
* than answering "0 validation errors"; check {@code isEmpty()} before throwing.
*/
public static String message(List<String> violations) {
if (violations.isEmpty()) {
throw new IllegalArgumentException("no violations to report");
}
String count = violations.size() == 1
? "1 validation error detected: "
: violations.size() + " validation errors detected: ";
return count + String.join("; ", violations);
}
/** The violation for a required member the request leaves out. */
public static String notNull(String path) {
return "Value null at '" + path + "' failed to satisfy constraint: Member must not be null";
}
/** The 400 error carrying {@link #message} under the protocol's validation error code. */
public static AwsException exception(String errorCode, List<String> violations) {
return new AwsException(errorCode, message(violations), 400);
}
public static AwsException exception(String errorCode, String violation) {
return exception(errorCode, List.of(violation));
}
/** The code most JSON services use, on both the awsJson and the restJson protocols. */
public static AwsException validationException(List<String> violations) {
return exception("ValidationException", violations);
}
public static AwsException validationException(String violation) {
return validationException(List.of(violation));
}
/** The awsQuery protocol's code. */
public static AwsException validationError(List<String> violations) {
return exception("ValidationError", violations);
}
public static AwsException validationError(String violation) {
return validationError(List.of(violation));
}
/** The code of REST JSON services that model their validation failure as BadRequestException. */
public static AwsException badRequest(List<String> violations) {
return exception("BadRequestException", violations);
}
public static AwsException badRequest(String violation) {
return badRequest(List.of(violation));
}
/** The violation for a number outside {@code min} to {@code max}, or {@code null} for one inside. */
public static String outOfRange(String path, long value, long min, long max) {
if (value < min) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must have value greater than or equal to " + min;
}
if (value > max) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must have value less than or equal to " + max;
}
return null;
}
/** The violation for a value outside its enum, listing the allowed values in the model's order. */
public static String notInEnum(String value, String path, List<String> allowed) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must satisfy enum value set: " + allowed;
}
/**
* The violation for a list holding a value outside its members' enum. The list is checked as a
* whole, so the whole list is the offending value and the enum constraint is nested.
*/
public static String notAllInEnum(List<String> values, String path, List<String> allowed) {
return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]";
}
}
@@ -9,9 +9,11 @@ import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import org.jboss.logging.Logger;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The replacement lifecycle for provisioners whose replacing update simply creates the new entity
@@ -121,6 +123,22 @@ public final class ReplacementCleanup {
return cleanup != null && cleanup.path("displaced").size() > 0;
}
/** The physical IDs of replaced resources this resource still has to delete. */
static Set<String> owedPhysicalIds(StackResource r) {
Set<String> owed = new HashSet<>();
ObjectNode cleanup = read(r);
if (cleanup == null) {
return owed;
}
for (JsonNode entry : cleanup.path("displaced")) {
String physicalId = entry.path("physicalId").asText(null);
if (physicalId != null) {
owed.add(physicalId);
}
}
return owed;
}
/**
* The entity the cleanup is working on, or null when none is owed or {@code UpdateReplacePolicy:
* Retain} keeps it. The engine announces one physical id per resource, so a resource owing more
@@ -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;
}
}