#629: the herdr boot wait went through ports.nanoClock() but hardcoded Fleetd::sleepHerdrPoll, so a test assembling against an unhealthy lead herdr burned 30 real seconds whatever clock it injected. The poll sleep now goes through ResourcePorts.herdrPollWait(). FleetdAssemblyFleetAppTest's lead-down test drops from 30.276s to 0.062s. It also gains @Timeout(10, SEPARATE_THREAD) at class level: the pin's failure mode is otherwise an infinite hang, because the test's fake clock only advances when herdrPollWait() is called. SAME_THREAD cannot interrupt a real Thread.sleep, so the thread mode is load-bearing, not decoration. #625: guard.assertPrimaryClean(System.getenv()) could be deleted with a fully green suite — the check behind charter invariant 1, which keeps the primary on the operator's subscription. main() now delegates to main(String[], ResourcePorts) and the guard reads ports.environment(), so a test can taint the environment without touching the real process env. The guard still runs before cfg.validateAll() and before any socket, broker or HTTP work; two tests with different fixtures pin that ordering as two independently falsifiable claims, not one. MERGE RESOLUTION BY THE LEAD. ResourcePorts.herdrPollWait() is abstract with no default, by design (#629 keeps ResourcePorts free of a none() default). This branch patched the 8 implementations that existed when it forked. PRs #631 and #634 merged ahead of it and added 3 more fakes, so git reported a clean 14-file merge that did not compile: FleetdAssemblyAmqpOpenersTest.RecordingPorts is not abstract and does not override abstract method herdrPollWait() in dev.ltms.fleet.ResourcePorts (plus the two ControllableResourcePorts in #634's tests). I added the override to those 3, matching this branch's own convention for an always-healthy fake: return a Runnable that throws, so if one of those assemblies ever does start polling herdr it fails loudly instead of sleeping quietly. All 13 implementations now carry it. Full suite on the resolved merge: 1889 tests, 0 failures (1886 + this branch's 3).
This commit is contained in:
@@ -131,6 +131,27 @@ public final class Fleetd {
|
||||
}
|
||||
|
||||
static void main(String[] args) {
|
||||
main(args, ResourcePorts.system());
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #625: package-private so a test can drive the literal startup sequence — not a copy
|
||||
* of it — against a non-production {@link ResourcePorts} whose {@link
|
||||
* ResourcePorts#environment()} is tainted, without ever touching the real process environment.
|
||||
* The real process environment is exactly what a test cannot taint from inside the JVM, which
|
||||
* is why nothing could pin {@link SubscriptionGuard#assertPrimaryClean} running at this call
|
||||
* site before this ticket.
|
||||
*
|
||||
* <p>{@link #main(String[])} is the one production caller, passing {@link
|
||||
* ResourcePorts#system()}. Every statement below, in the same order, is otherwise unchanged
|
||||
* from before this ticket — in particular the guard still runs here, before {@code
|
||||
* cfg.validateAll()} and before {@link FleetdAssembly#assembleAndStart} ever touches a socket,
|
||||
* a broker, or HTTP, exactly as it always has. {@code ports} is reused for the guard check and
|
||||
* then handed on to the assembly, rather than a second instance being constructed there, so a
|
||||
* test's fake backs the whole boot path with one consistent view — see {@code
|
||||
* FleetdSubscriptionGuardOrderingTest}.
|
||||
*/
|
||||
static void main(String[] args, ResourcePorts ports) {
|
||||
Path configPath = args.length > 0 ? Path.of(args[0]) : chooseDefaultConfigFile(Path.of(""));
|
||||
// The config file was renamed bridged.yaml -> fleetd.yaml. Name the file we actually
|
||||
// loaded, whichever of the two names it carries.
|
||||
@@ -166,7 +187,7 @@ public final class Fleetd {
|
||||
|
||||
// The primary/host env that launched fleetd must not be tainted.
|
||||
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
|
||||
guard.assertPrimaryClean(System.getenv());
|
||||
guard.assertPrimaryClean(ports.environment());
|
||||
|
||||
// Every FleetConfig.validateXxx() the operator's config can fail — CB-501's auth-exposure
|
||||
// check, CB-531's lead-tab-prefix check, CB-542's subscription-profile check, the charter
|
||||
@@ -189,7 +210,9 @@ public final class Fleetd {
|
||||
// after validateAll() (this line) puts both back under test, in the same relative order,
|
||||
// before either one does any I/O — see FleetdAssembly's javadoc for the full boot-order
|
||||
// contract this preserves exactly.
|
||||
FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ResourcePorts.system());
|
||||
// fleetd #625: the same `ports` the guard check above just used, not a second
|
||||
// ResourcePorts.system() instance — see this method's own javadoc.
|
||||
FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,7 +194,7 @@ final class FleetdAssembly {
|
||||
// CB-504: under supervision (launchd/systemd) fleetd can start before herdr's socket
|
||||
// exists. Wait, then degrade rather than die: serving with /healthz reporting "degraded" is
|
||||
// strictly more useful than exiting.
|
||||
Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), Fleetd::sleepHerdrPoll);
|
||||
Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), ports.herdrPollWait());
|
||||
boolean herdrUp = Fleetd.logHerdrWaitOutcomeAndShouldReap(herdrOutcome);
|
||||
if (herdrUp) {
|
||||
// CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died
|
||||
|
||||
@@ -43,6 +43,19 @@ public interface ResourcePorts {
|
||||
/** A monotonic elapsed-time clock. Production: {@link System#nanoTime()}. */
|
||||
LongSupplier nanoClock();
|
||||
|
||||
/**
|
||||
* fleetd #629: the per-poll wait {@code FleetdAssembly#assembleAndStart} passes to {@code
|
||||
* Fleetd#awaitHerdr} while polling for herdr's socket. Production: {@link
|
||||
* Fleetd#sleepHerdrPoll()} — a real {@code Thread.sleep}. {@link #nanoClock()} alone is not
|
||||
* enough to make {@code awaitHerdr}'s deadline controllable: the old call site passed {@code
|
||||
* Fleetd::sleepHerdrPoll} directly, hardcoded, so a test that injected a fake clock still had
|
||||
* to wait out the real sleep between each poll to ever reach the deadline — the clock looked
|
||||
* injected and was not actually controllable. A test supplies a no-op that advances its own
|
||||
* injected {@link #nanoClock()} instead, so the deadline becomes reachable without any real
|
||||
* wall-clock time passing.
|
||||
*/
|
||||
Runnable herdrPollWait();
|
||||
|
||||
/**
|
||||
* A wall-clock reading, in nanoseconds. Production: {@code System.currentTimeMillis()}
|
||||
* converted to nanoseconds. Kept separate from {@link #nanoClock()} because {@link
|
||||
|
||||
@@ -44,6 +44,11 @@ final class SystemResourcePorts implements ResourcePorts {
|
||||
return System::nanoTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
return Fleetd::sleepHerdrPoll;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LongSupplier wallClockNanos() {
|
||||
return () -> TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis());
|
||||
|
||||
@@ -91,6 +91,13 @@ class FleetdAssemblyAmqpOpenersTest {
|
||||
};
|
||||
}
|
||||
@Override public LongSupplier nanoClock() { return System::nanoTime; }
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
@Override public LongSupplier wallClockNanos() { return System::nanoTime; }
|
||||
@Override public ScheduledExecutorService newScheduler(String purpose) {
|
||||
return Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
@@ -117,6 +117,14 @@ class FleetdAssemblyConnectionIdentityTest {
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
// Deliberately never bind — this test never issues a real HTTP request.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private FleetdRuntime runtime;
|
||||
|
||||
@@ -140,6 +140,14 @@ class FleetdAssemblyCoordinatorLifecycleTest {
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
// No real HTTP bind in a unit test.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SentinelReplyInbox implements ReplyInbox {
|
||||
|
||||
@@ -8,6 +8,7 @@ import dev.ltms.fleet.herdr.HerdrClient;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.URI;
|
||||
@@ -21,6 +22,8 @@ import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -69,13 +72,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
* below is what this class relies on for CB-185's {@code FleetApp} half; {@code
|
||||
* FleetAppTwoDaemonTest} remains the full behavioural proof that {@code FleetApp} itself merges
|
||||
* {@code /sessions} correctly once handed two clients.
|
||||
*
|
||||
* <p><strong>fleetd #629 follow-up.</strong> The fix below (see {@link TwoHerdrResourcePorts})
|
||||
* makes {@link #healthzGoesRedWhenTheLeadDaemonIsDownEvenThoughTheMemberIsUp}'s fake {@code
|
||||
* nanoClock()} frozen unless {@code herdrPollWait()} itself advances it. That is a sharper pin
|
||||
* than an assertion — if a future edit to {@code FleetdAssembly} ever bypasses {@code
|
||||
* ports.herdrPollWait()} again (e.g. reverting to a hardcoded {@code Thread.sleep}), the clock
|
||||
* never advances, {@code Fleetd#awaitHerdr}'s deadline is never reached, and this test hangs
|
||||
* forever instead of failing — proven by deliberately reintroducing that exact regression while
|
||||
* fixing this ticket. {@code @Timeout} turns that silent hang into a bounded, named test failure:
|
||||
* {@code SEPARATE_THREAD} so JUnit's timeout governor can actually interrupt a thread stuck in a
|
||||
* real {@code Thread.sleep} loop (the default {@code SAME_THREAD} mode cannot — it only measures
|
||||
* elapsed time after the test method returns on its own, which never happens here). 10 seconds is
|
||||
* roughly 150x the real passing times measured here (~0.06s), so a slow CI machine has no reason
|
||||
* to flake, and it is still 3x faster than discovering the regression by burning a CI job's whole
|
||||
* wall-clock budget.
|
||||
*/
|
||||
@Timeout(value = 10, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
|
||||
class FleetdAssemblyFleetAppTest {
|
||||
|
||||
private static final class TwoHerdrResourcePorts implements ResourcePorts {
|
||||
|
||||
final Map<Path, HerdrClient> herdrsBySocket = new LinkedHashMap<>();
|
||||
final CopyOnWriteArrayList<ScheduledExecutorService> schedulers = new CopyOnWriteArrayList<>();
|
||||
// fleetd #629: a fake, advanceable clock — NOT System::nanoTime. awaitHerdr's poll wait
|
||||
// (herdrPollWait() below) advances this on every poll instead of sleeping for real, so the
|
||||
// down-lead test below reaches awaitHerdr's deadline without burning real wall-clock time.
|
||||
final AtomicLong nowNanos = new AtomicLong(1_000_000_000L); // arbitrary non-zero start
|
||||
Runnable shutdownHook;
|
||||
|
||||
@Override
|
||||
@@ -107,7 +130,7 @@ class FleetdAssemblyFleetAppTest {
|
||||
|
||||
@Override
|
||||
public LongSupplier nanoClock() {
|
||||
return System::nanoTime;
|
||||
return nowNanos::get;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -115,6 +138,13 @@ class FleetdAssemblyFleetAppTest {
|
||||
return System::nanoTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// fleetd #629: advance the fake clock instead of a real Thread.sleep, so awaitHerdr's
|
||||
// deadline is reached in real time regardless of the configured poll interval.
|
||||
return () -> nowNanos.addAndGet(TimeUnit.SECONDS.toNanos(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScheduledExecutorService newScheduler(String purpose) {
|
||||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
@@ -126,6 +126,14 @@ class FleetdAssemblyLifecycleTest {
|
||||
ledger.add("startHttp");
|
||||
this.startedApp = app;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** A fake {@link ReplyInbox} that is also {@link AutoCloseable}, so the ledger can prove it closes. */
|
||||
|
||||
@@ -98,6 +98,14 @@ class FleetdAssemblyRoleFallbackBoundaryTest {
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
// No real HTTP bind in a unit test.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SentinelReplyInbox implements ReplyInbox {
|
||||
|
||||
@@ -100,6 +100,14 @@ class FleetdBackendQuarantineAssemblyTest {
|
||||
@Override
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
|
||||
@@ -126,6 +126,14 @@ class FleetdCompletionResolverAssemblyTest {
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
// Deliberately never bind a real port.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static FleetConfig writeConfig(Path dir, String profilesYaml, String extraGuardHost,
|
||||
|
||||
@@ -95,6 +95,13 @@ class FleetdExhaustedPatternAssemblyTest {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
@Override
|
||||
public LongSupplier nanoClock() {
|
||||
return nowNanos::get;
|
||||
|
||||
@@ -115,6 +115,14 @@ class FleetdLeadRolloverAssemblyTest {
|
||||
@Override
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
|
||||
@@ -90,6 +90,14 @@ class FleetdLeadSeatAssemblyTest {
|
||||
@Override
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
|
||||
+7
@@ -87,6 +87,13 @@ class FleetdOpenCodeExhaustionForwardingAssemblyTest {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
// Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
|
||||
return () -> {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
|
||||
};
|
||||
}
|
||||
@Override
|
||||
public LongSupplier nanoClock() {
|
||||
return nowNanos::get;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package dev.ltms.fleet;
|
||||
|
||||
import dev.ltms.fleet.guard.GuardException;
|
||||
import dev.ltms.fleet.herdr.HerdrClient;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* fleetd #625: pins {@link dev.ltms.fleet.guard.SubscriptionGuard#assertPrimaryClean}'s call site
|
||||
* in {@link Fleetd#main(String[])} — the ONE place it runs at startup, and the check behind the
|
||||
* bridge charter's invariant 1 (never let the primary carry {@code ANTHROPIC_BASE_URL}). Nothing
|
||||
* pinned it before this ticket: deleting {@code guard.assertPrimaryClean(...)} from {@code main}
|
||||
* left the full suite green, because the call site read the real process environment ({@code
|
||||
* System.getenv()}), which a test cannot taint from inside the JVM.
|
||||
*
|
||||
* <p>{@link Fleetd#main(String[], ResourcePorts)} (added by this ticket) is the literal production
|
||||
* sequence — not a copy of it — driven here with a {@link ResourcePorts} whose {@link
|
||||
* ResourcePorts#environment()} is a plain {@code Map} a test controls. The guard itself was
|
||||
* already pinned by {@code SubscriptionGuardTest}, directly, with a {@code Map} — that proves the
|
||||
* method's behaviour, not that {@code main} still calls it at the right point. This class pins the
|
||||
* call site and, separately, the ORDER: the guard must still run before {@code cfg.validateAll()}
|
||||
* and before {@code FleetdAssembly.assembleAndStart} touches a socket, a broker, or HTTP — not just
|
||||
* be present somewhere in {@code main}.
|
||||
*
|
||||
* <p>Presence alone is not enough (a fix that pins only presence trades an invisible deletion for
|
||||
* an invisible reordering), so each test below is built so that EITHER deleting the guard call OR
|
||||
* moving it later makes the <em>same</em> test fail — with a different exception type than the one
|
||||
* asserted, not a vacuous pass. See each test's own javadoc for how.
|
||||
*/
|
||||
class FleetdSubscriptionGuardOrderingTest {
|
||||
|
||||
private static final Map<String, String> TAINTED_ENV =
|
||||
Map.of("ANTHROPIC_BASE_URL", "http://tainted.example");
|
||||
private static final Map<String, String> CLEAN_ENV = Map.of("PATH", "/usr/bin");
|
||||
|
||||
/**
|
||||
* A {@link ResourcePorts} whose {@link #environment()} is fixed to whatever the test hands it,
|
||||
* and whose every other method refuses to be called at all. That refusal is the ordering pin:
|
||||
* if {@code main} ever reaches {@link FleetdAssembly#assembleAndStart} before the guard has had
|
||||
* a chance to throw, the very first thing the assembly does with {@code ports} is {@link
|
||||
* #connectHerdr} — so a test that expects {@link GuardException} and instead observes {@link
|
||||
* UnsupportedOperationException} has just caught the guard running too late (or not at all).
|
||||
*/
|
||||
private static final class FixedEnvPorts implements ResourcePorts {
|
||||
private final Map<String, String> env;
|
||||
|
||||
FixedEnvPorts(Map<String, String> env) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> environment() {
|
||||
return env;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HerdrClient connectHerdr(Path socketPath) {
|
||||
throw new UnsupportedOperationException("connectHerdr must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||
throw new UnsupportedOperationException("replyInboxOpener must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||
throw new UnsupportedOperationException("leadMailboxOpener must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LongSupplier nanoClock() {
|
||||
throw new UnsupportedOperationException("nanoClock must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LongSupplier wallClockNanos() {
|
||||
throw new UnsupportedOperationException("wallClockNanos must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScheduledExecutorService newScheduler(String purpose) {
|
||||
throw new UnsupportedOperationException("newScheduler must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addShutdownHook(Runnable hook) {
|
||||
throw new UnsupportedOperationException("addShutdownHook must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startHttp(Javalin app, String host, int port) {
|
||||
throw new UnsupportedOperationException("startHttp must not be called before the guard runs");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Runnable herdrPollWait() {
|
||||
throw new UnsupportedOperationException("herdrPollWait must not be called before the guard runs");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Otherwise-invalid: {@code bind.host: 0.0.0.0} with no {@code auth.mode: token} fails {@code
|
||||
* cfg.validateAll()} (CB-501's auth-exposure check — the same fixture {@code
|
||||
* FleetdStartupValidationTest#mainRefusesANonLoopbackBindWithoutTokenMode} uses), with an
|
||||
* {@link IllegalStateException}. That is deliberate: it is what {@code main} would throw INSTEAD
|
||||
* of {@link GuardException} if the guard call were deleted, or moved to run after {@code
|
||||
* validateAll()} — a different, distinguishable exception type.
|
||||
*/
|
||||
private static Path invalidConfig(Path dir) throws Exception {
|
||||
Path f = dir.resolve("fleetd.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
host: 0.0.0.0
|
||||
port: 8765
|
||||
""");
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Passes {@code cfg.validateAll()} cleanly — nothing here trips any of its checks. */
|
||||
private static Path validConfig(Path dir) throws Exception {
|
||||
Path f = dir.resolve("fleetd.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
host: 127.0.0.1
|
||||
port: 8765
|
||||
""");
|
||||
return f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the order against {@code cfg.validateAll()}. The environment is tainted and the config
|
||||
* is otherwise invalid (see {@link #invalidConfig}). If the guard runs first (the required
|
||||
* order), {@code main} throws {@link GuardException} before {@code validateAll()} is ever
|
||||
* reached. If the guard were deleted, or reordered to run after {@code validateAll()}, {@code
|
||||
* validateAll()} throws {@link IllegalStateException} instead and this assertion fails on the
|
||||
* wrong exception type.
|
||||
*/
|
||||
@Test
|
||||
void mainRefusesATaintedEnvironmentBeforeValidatingTheConfig(@TempDir Path dir) throws Exception {
|
||||
Path config = invalidConfig(dir);
|
||||
FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV);
|
||||
|
||||
GuardException ex = assertThrows(GuardException.class,
|
||||
() -> Fleetd.main(new String[]{config.toString()}, ports));
|
||||
assertTrue(ex.getMessage().contains("tainted"),
|
||||
"expected the primary-taint message, got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the order against {@code FleetdAssembly.assembleAndStart}. The environment is tainted
|
||||
* and the config is otherwise VALID (see {@link #validConfig}), so {@code cfg.validateAll()}
|
||||
* passes silently and the next thing that could possibly run is the assembly's first socket
|
||||
* call. If the guard runs first (the required order), {@code main} throws {@link
|
||||
* GuardException} before assembly starts. If the guard were deleted, or reordered to run after
|
||||
* assembly begins touching {@code ports}, {@link FixedEnvPorts#connectHerdr} throws {@link
|
||||
* UnsupportedOperationException} instead and this assertion fails on the wrong exception type.
|
||||
*/
|
||||
@Test
|
||||
void mainRefusesATaintedEnvironmentBeforeAssemblyTouchesAnyPort(@TempDir Path dir) throws Exception {
|
||||
Path config = validConfig(dir);
|
||||
FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV);
|
||||
|
||||
GuardException ex = assertThrows(GuardException.class,
|
||||
() -> Fleetd.main(new String[]{config.toString()}, ports));
|
||||
assertTrue(ex.getMessage().contains("tainted"),
|
||||
"expected the primary-taint message, got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* The CONTROL for the two tests above. Same otherwise-valid config, same {@link FixedEnvPorts}
|
||||
* whose every method but {@code environment()} refuses to be called — but a CLEAN environment.
|
||||
* Without this, a guard that always threw {@link GuardException} regardless of input (the
|
||||
* opposite bug — e.g. the check inverted) would make the two tests above pass for the wrong
|
||||
* reason: not because they actually drove a real taint through a real guard, but because
|
||||
* anything would have thrown {@code GuardException}. Here, with nothing to taint, the guard
|
||||
* must let {@code main} proceed into {@code cfg.validateAll()} and on into the real assembly,
|
||||
* which reaches {@code ports.connectHerdr} — and THAT throws. A loud, positive assertion: if
|
||||
* the boot path never actually ran this far, there is no {@link UnsupportedOperationException}
|
||||
* to catch, only a quiet, unexpected hang or an unrelated early failure.
|
||||
*/
|
||||
@Test
|
||||
void mainProceedsPastTheGuardOnACleanEnvironment(@TempDir Path dir) throws Exception {
|
||||
Path config = validConfig(dir);
|
||||
FixedEnvPorts ports = new FixedEnvPorts(CLEAN_ENV);
|
||||
|
||||
UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class,
|
||||
() -> Fleetd.main(new String[]{config.toString()}, ports));
|
||||
assertTrue(ex.getMessage().contains("connectHerdr"),
|
||||
"expected forward progress to reach the assembly's first port call, got: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user