diff --git a/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java b/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java new file mode 100644 index 0000000..bb33c6c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java @@ -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 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 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 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 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 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 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 values, String path, List allowed) { + return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: " + + "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]"; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java index 9ab3340..d9c1ff2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java @@ -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 owedPhysicalIds(StackResource r) { + Set 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 diff --git a/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java b/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java new file mode 100644 index 0000000..ff2bea8 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java @@ -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 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 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 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("DELETE_FAILED"))); + 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); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java b/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java new file mode 100644 index 0000000..e6a169b --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java @@ -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. + * + *
{@code
+ * @RegisterExtension
+ * final PartitionCleanup cleanup = new PartitionCleanup();
+ * ...
+ * createTable(region, name);
+ * cleanup.register(() -> deleteTable(region, name));
+ * }
+ * + * 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 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); + } + } + } +} diff --git a/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java b/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java new file mode 100644 index 0000000..1acdf9e --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java @@ -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 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, 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, 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 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; + } +}