diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index c759f41..a154c89 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -1506,7 +1506,7 @@ public record FleetConfig( rejectDuplicateMemberSlots(yaml); rejectNegativeMaxLoad(yaml); rejectAutoCompactWindowOutOfRange(yaml); - rejectMalformedErrorPattern(yaml); + rejectMalformedProfilePatterns(yaml); rejectUnknownKind(yaml); rejectUnknownAuthMode(yaml); rejectUnknownPlacement(yaml); @@ -1856,20 +1856,26 @@ public record FleetConfig( } /** - * Reject a profile whose {@code errorPattern} (fleetd #201 Unit 5) is not a valid Java regex, - * naming the profile, the key, and the parser's own message. + * Reject a profile whose {@code errorPattern} (fleetd #201 Unit 5) or {@code exhaustedPattern} + * (CB-578 stage A) is not a valid Java regex, naming the profile, the key, and the parser's own + * message. * - *

Unset/{@code null} means "use {@code CompletionResolver}'s built-in {@code (?i)\bAPI - * Error\s*:} compatibility pattern" and passes silently. A profile that DOES set the key gets it - * compiled once at daemon startup ({@code Fleetd.main}, mirroring {@code exhaustedPattern}) — an - * uncaught {@link java.util.regex.PatternSyntaxException} there crashes startup without naming - * which profile or key is at fault. Validate eagerly here instead, at config load, the same - * "fail loud at load, not lazily later" reasoning as {@link #rejectAutoCompactWindowOutOfRange}. + *

Unset/{@code null} means, for {@code errorPattern}, "use {@code CompletionResolver}'s + * built-in {@code (?i)\bAPI Error\s*:} compatibility pattern", and for {@code exhaustedPattern}, + * "opt out of that classification" — either way it passes silently. A profile that DOES set + * either key gets it compiled once at daemon startup ({@code Fleetd.main}) — an uncaught + * {@link java.util.regex.PatternSyntaxException} there crashes startup without naming which + * profile or key is at fault (fleetd #273: this happened for {@code exhaustedPattern}, which had + * no validator here even though its sibling {@code errorPattern} did). Validate eagerly here + * instead, at config load, the same "fail loud at load, not lazily later" reasoning as + * {@link #rejectAutoCompactWindowOutOfRange}. Both keys are checked from a single load, and any + * failures from either are collected together into one message. * * @param yaml the raw config text - * @throws IllegalStateException when any profile's {@code errorPattern} fails to compile + * @throws IllegalStateException when any profile's {@code errorPattern} or + * {@code exhaustedPattern} fails to compile */ - static void rejectMalformedErrorPattern(String yaml) { + static void rejectMalformedProfilePatterns(String yaml) { Map raw; try { raw = YAML.readValue(yaml, Map.class); @@ -1884,18 +1890,20 @@ public record FleetConfig( if (!(e.getValue() instanceof Map p)) { continue; } - if (!(p.get("errorPattern") instanceof String pattern) || pattern.isBlank()) { - continue; - } - try { - Pattern.compile(pattern); - } catch (PatternSyntaxException ex) { - bad.add("profiles." + e.getKey() + ".errorPattern (\"" + pattern + "\"): " + ex.getMessage()); + for (String key : List.of("errorPattern", "exhaustedPattern")) { + if (!(p.get(key) instanceof String pattern) || pattern.isBlank()) { + continue; + } + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException ex) { + bad.add("profiles." + e.getKey() + "." + key + " (\"" + pattern + "\"): " + ex.getMessage()); + } } } bad.sort(String::compareTo); if (!bad.isEmpty()) { - throw new IllegalStateException("refusing to start: malformed errorPattern — " + throw new IllegalStateException("refusing to start: malformed pattern — " + String.join("; ", bad)); } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java index 3dcab3a..4292139 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -155,6 +155,90 @@ class FleetConfigTest { assertTrue(e.getMessage().contains("errorPattern"), "the offending key is named: " + e.getMessage()); } + // ── fleetd #273: exhaustedPattern gets the same load-time validation as its sibling errorPattern ── + + @Test + void aProfileWithAMalformedExhaustedPatternIsRejectedAtLoadNamingTheProfileAndKey(@TempDir Path dir) + throws Exception { + Path f = dir.resolve("malformed-exhausted-pattern.yaml"); + Files.writeString(f, """ + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 + exhaustedPattern: "[" + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f)); + assertTrue(e.getMessage().contains("ltms-local"), "the offending profile is named: " + e.getMessage()); + assertTrue(e.getMessage().contains("exhaustedPattern"), "the offending key is named: " + e.getMessage()); + } + + @Test + void aMalformedErrorPatternAndAMalformedExhaustedPatternAreBothReportedFromOneLoad(@TempDir Path dir) + throws Exception { + Path f = dir.resolve("both-malformed.yaml"); + Files.writeString(f, """ + profiles: + sonnet: + baseUrl: http://gx00.gw:8000 + errorPattern: "(unterminated[" + terra: + baseUrl: http://gx01.gw:8000 + exhaustedPattern: "[" + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f)); + assertTrue(e.getMessage().contains("sonnet"), "the errorPattern profile is named: " + e.getMessage()); + assertTrue(e.getMessage().contains("errorPattern"), e.getMessage()); + assertTrue(e.getMessage().contains("terra"), "the exhaustedPattern profile is named: " + e.getMessage()); + assertTrue(e.getMessage().contains("exhaustedPattern"), e.getMessage()); + } + + @Test + void validErrorPatternAndExhaustedPatternBothLoadFine(@TempDir Path dir) throws Exception { + Path f = dir.resolve("both-valid.yaml"); + Files.writeString(f, """ + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 + errorPattern: "credential outage" + exhaustedPattern: "usage limit has been reached" + """); + + FleetConfig.Profile w = FleetConfig.load(f).profiles().get("ltms-local"); + assertEquals("credential outage", w.errorPattern()); + assertEquals("usage limit has been reached", w.exhaustedPattern()); + } + + @Test + void aBlankExhaustedPatternNormalizesToNullJustLikeUnset(@TempDir Path dir) throws Exception { + Path f = dir.resolve("blank-exhausted-pattern.yaml"); + Files.writeString(f, """ + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 + exhaustedPattern: " " + """); + + FleetConfig.Profile w = FleetConfig.load(f).profiles().get("ltms-local"); + assertNull(w.exhaustedPattern()); + assertFalse(w.hasExhaustedPattern()); + } + + @Test + void aProfileWithNoExhaustedPatternLoadsFine(@TempDir Path dir) throws Exception { + Path f = dir.resolve("no-exhausted-pattern.yaml"); + Files.writeString(f, """ + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 + """); + + FleetConfig.Profile w = FleetConfig.load(f).profiles().get("ltms-local"); + assertNull(w.exhaustedPattern()); + assertFalse(w.hasExhaustedPattern()); + } + @Test void withProfileCarriesErrorPatternThrough(@TempDir Path dir) throws Exception { Path f = dir.resolve("with-profile-error-pattern.yaml");