From 5723a4d062cfa433cc8c5c682f730d77ba15e842 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:05:55 -0700 Subject: [PATCH 1/4] Add Docker healthcheck backed by Discord gateway state --- Dockerfile | 4 +- README.md | 6 +++ build.gradle | 7 +++ .../infinitybot/HealthCheck.java | 50 +++++++++++++++++++ .../infinitybot/InfinityBot.java | 1 + .../infinitybot/HealthCheckTest.java | 39 +++++++++++++++ 6 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java create mode 100644 src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java diff --git a/Dockerfile b/Dockerfile index 671c44d..3e1b401 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,12 @@ FROM eclipse-temurin:25-jdk AS builder WORKDIR /app COPY . . ARG APP_VERSION=0.0.0-SNAPSHOT -RUN sh ./gradlew --no-daemon "-PappVersion=${APP_VERSION}" shadowJar +RUN sh ./gradlew --no-daemon "-PappVersion=${APP_VERSION}" test shadowJar FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=builder /app/build/libs/InfinityBot-*-all.jar /app/InfinityBot.jar USER 10001:10001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD ["java", "-cp", "/app/InfinityBot.jar", "net.theinfinitymc.infinitybot.HealthCheck"] ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "/app/InfinityBot.jar"] diff --git a/README.md b/README.md index ff8e74d..412f981 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ poToken/visitor-data pairs are supplied manually and are not automatically gener Cipher support solves signature deciphering, **not** YouTube IP blocks, age restrictions, or all sign-in challenges. Test playback on the intended deployment host. See [youtube-source remote cipher documentation](https://github.com/lavalink-devs/youtube-source#using-a-remote-cipher-server). +## Healthcheck + +The bot serves `http://127.0.0.1:8080/health` (loopback only; no port publishing needed), returning HTTP 200 only while JDA reports its Discord gateway as `CONNECTED`, and 503 otherwise. This checks the running bot's connection, not just its process; it does not check voice connections, YouTube playback, or REST API availability separately. + +Docker probes every 30 seconds, allows 60 seconds for startup, and marks the container unhealthy after three consecutive failures. JDA handles reconnects; health recovers automatically when connected again. Docker health status alone does not trigger a restart, even with a restart policy. Inspect it with `docker inspect --format '{{json .State.Health}}' `. + ## Build locally Requires JDK 25. JDAVE provides Discord voice encryption (DAVE). diff --git a/build.gradle b/build.gradle index aa7789d..cf76c4b 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,14 @@ repositories { } } +test { + useJUnitPlatform() +} + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.12.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' + implementation 'net.dv8tion:JDA:6.7.0' implementation 'club.minnced:jdave-api:0.1.8' runtimeOnly 'club.minnced:jdave-native-linux-x86-64:0.1.8' diff --git a/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java b/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java new file mode 100644 index 0000000..218909f --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java @@ -0,0 +1,50 @@ +package net.theinfinitymc.infinitybot; + +import com.sun.net.httpserver.HttpServer; +import net.dv8tion.jda.api.JDA; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.function.Supplier; + +/** Local readiness check backed by JDA's live Discord gateway state. */ +public final class HealthCheck { + private HealthCheck() {} + + static HttpServer start(Supplier status, int port) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + server.createContext("/health", exchange -> { + try { + exchange.sendResponseHeaders(status.get() == JDA.Status.CONNECTED ? 200 : 503, -1); + } finally { + exchange.close(); + } + }); + server.start(); + return server; + } + + static boolean isHealthy(URI endpoint) throws IOException { + HttpURLConnection connection = (HttpURLConnection) endpoint.toURL().openConnection(); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + connection.setInstanceFollowRedirects(false); + try { + return connection.getResponseCode() == 200; + } finally { + connection.disconnect(); + } + } + + /** Docker probe: query the running bot, without opening another Discord session. */ + public static void main(String[] args) { + try { + System.exit(isHealthy(URI.create("http://127.0.0.1:8080/health")) ? 0 : 1); + } catch (IOException exception) { + System.err.println("Bot healthcheck failed: " + exception.getMessage()); + System.exit(1); + } + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java index a76df74..8accb48 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java +++ b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java @@ -36,6 +36,7 @@ public static void main(String[] args) { .build(); listener.registerCommands(jda, audioManager); updateActivity(); + HealthCheck.start(jda::getStatus, 8080); } catch (Exception exception) { throw new IllegalStateException("Failed to load InfinityBot.", exception); } diff --git a/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java b/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java new file mode 100644 index 0000000..bb1aa6b --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java @@ -0,0 +1,39 @@ +package net.theinfinitymc.infinitybot; + +import com.sun.net.httpserver.HttpServer; +import net.dv8tion.jda.api.JDA; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class HealthCheckTest { + @Test + void followsLiveGatewayStateIncludingReconnects() throws IOException { + AtomicReference status = new AtomicReference<>(JDA.Status.INITIALIZING); + HttpServer server = HealthCheck.start(status::get, 0); + try { + assertTrue(server.getAddress().getAddress().isLoopbackAddress()); + URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health"); + for (JDA.Status state : JDA.Status.values()) { + status.set(state); + assertEquals(state == JDA.Status.CONNECTED, HealthCheck.isHealthy(endpoint), state.name()); + } + status.set(JDA.Status.CONNECTED); + assertTrue(HealthCheck.isHealthy(endpoint), "Recovery should become healthy again"); + } finally { + server.stop(0); + } + } + + @Test + void failsWhenServerIsUnavailable() throws IOException { + HttpServer server = HealthCheck.start(() -> JDA.Status.CONNECTED, 0); + URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health"); + server.stop(0); + assertThrows(IOException.class, () -> HealthCheck.isHealthy(endpoint)); + } +} From bb310ab49106c9098f02406baa2be7a917aa999d Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:08:24 -0700 Subject: [PATCH 2/4] Defer healthcheck tests to holistic test evaluation --- Dockerfile | 2 +- build.gradle | 7 ---- .../infinitybot/HealthCheckTest.java | 39 ------------------- 3 files changed, 1 insertion(+), 47 deletions(-) delete mode 100644 src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java diff --git a/Dockerfile b/Dockerfile index 3e1b401..e89e0fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM eclipse-temurin:25-jdk AS builder WORKDIR /app COPY . . ARG APP_VERSION=0.0.0-SNAPSHOT -RUN sh ./gradlew --no-daemon "-PappVersion=${APP_VERSION}" test shadowJar +RUN sh ./gradlew --no-daemon "-PappVersion=${APP_VERSION}" shadowJar FROM eclipse-temurin:25-jre WORKDIR /app diff --git a/build.gradle b/build.gradle index cf76c4b..aa7789d 100644 --- a/build.gradle +++ b/build.gradle @@ -40,14 +40,7 @@ repositories { } } -test { - useJUnitPlatform() -} - dependencies { - testImplementation 'org.junit.jupiter:junit-jupiter:5.12.2' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' - implementation 'net.dv8tion:JDA:6.7.0' implementation 'club.minnced:jdave-api:0.1.8' runtimeOnly 'club.minnced:jdave-native-linux-x86-64:0.1.8' diff --git a/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java b/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java deleted file mode 100644 index bb1aa6b..0000000 --- a/src/test/java/net/theinfinitymc/infinitybot/HealthCheckTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.theinfinitymc.infinitybot; - -import com.sun.net.httpserver.HttpServer; -import net.dv8tion.jda.api.JDA; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.net.URI; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.jupiter.api.Assertions.*; - -class HealthCheckTest { - @Test - void followsLiveGatewayStateIncludingReconnects() throws IOException { - AtomicReference status = new AtomicReference<>(JDA.Status.INITIALIZING); - HttpServer server = HealthCheck.start(status::get, 0); - try { - assertTrue(server.getAddress().getAddress().isLoopbackAddress()); - URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health"); - for (JDA.Status state : JDA.Status.values()) { - status.set(state); - assertEquals(state == JDA.Status.CONNECTED, HealthCheck.isHealthy(endpoint), state.name()); - } - status.set(JDA.Status.CONNECTED); - assertTrue(HealthCheck.isHealthy(endpoint), "Recovery should become healthy again"); - } finally { - server.stop(0); - } - } - - @Test - void failsWhenServerIsUnavailable() throws IOException { - HttpServer server = HealthCheck.start(() -> JDA.Status.CONNECTED, 0); - URI endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health"); - server.stop(0); - assertThrows(IOException.class, () -> HealthCheck.isHealthy(endpoint)); - } -} From bad0e3a3aaa25ed02b84b2db47b90478994fa80c Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:20:49 -0700 Subject: [PATCH 3/4] Use curl for Docker health probe instead of Java helper --- Dockerfile | 5 ++- .../infinitybot/HealthCheck.java | 32 ++----------------- .../infinitybot/InfinityBot.java | 2 +- 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/Dockerfile b/Dockerfile index e89e0fe..391741a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,12 @@ ARG APP_VERSION=0.0.0-SNAPSHOT RUN sh ./gradlew --no-daemon "-PappVersion=${APP_VERSION}" shadowJar FROM eclipse-temurin:25-jre +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/build/libs/InfinityBot-*-all.jar /app/InfinityBot.jar USER 10001:10001 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD ["java", "-cp", "/app/InfinityBot.jar", "net.theinfinitymc.infinitybot.HealthCheck"] + CMD ["curl", "--fail", "--silent", "--show-error", "--noproxy", "*", "--max-time", "4", "http://127.0.0.1:8080/health"] ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "/app/InfinityBot.jar"] diff --git a/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java b/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java index 218909f..d1be2a0 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java +++ b/src/main/java/net/theinfinitymc/infinitybot/HealthCheck.java @@ -4,47 +4,21 @@ import net.dv8tion.jda.api.JDA; import java.io.IOException; -import java.net.HttpURLConnection; import java.net.InetSocketAddress; -import java.net.URI; -import java.util.function.Supplier; /** Local readiness check backed by JDA's live Discord gateway state. */ public final class HealthCheck { private HealthCheck() {} - static HttpServer start(Supplier status, int port) throws IOException { - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + static void start(JDA jda) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0); server.createContext("/health", exchange -> { try { - exchange.sendResponseHeaders(status.get() == JDA.Status.CONNECTED ? 200 : 503, -1); + exchange.sendResponseHeaders(jda.getStatus() == JDA.Status.CONNECTED ? 200 : 503, -1); } finally { exchange.close(); } }); server.start(); - return server; - } - - static boolean isHealthy(URI endpoint) throws IOException { - HttpURLConnection connection = (HttpURLConnection) endpoint.toURL().openConnection(); - connection.setConnectTimeout(2000); - connection.setReadTimeout(2000); - connection.setInstanceFollowRedirects(false); - try { - return connection.getResponseCode() == 200; - } finally { - connection.disconnect(); - } - } - - /** Docker probe: query the running bot, without opening another Discord session. */ - public static void main(String[] args) { - try { - System.exit(isHealthy(URI.create("http://127.0.0.1:8080/health")) ? 0 : 1); - } catch (IOException exception) { - System.err.println("Bot healthcheck failed: " + exception.getMessage()); - System.exit(1); - } } } diff --git a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java index 8accb48..81bd00f 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java +++ b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java @@ -36,7 +36,7 @@ public static void main(String[] args) { .build(); listener.registerCommands(jda, audioManager); updateActivity(); - HealthCheck.start(jda::getStatus, 8080); + HealthCheck.start(jda); } catch (Exception exception) { throw new IllegalStateException("Failed to load InfinityBot.", exception); } From de52113cb0632726e160d5598cc00c562352f4ed Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:23:39 -0700 Subject: [PATCH 4/4] Simplify README healthcheck description --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 412f981..29ed602 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,7 @@ Cipher support solves signature deciphering, **not** YouTube IP blocks, age rest ## Healthcheck -The bot serves `http://127.0.0.1:8080/health` (loopback only; no port publishing needed), returning HTTP 200 only while JDA reports its Discord gateway as `CONNECTED`, and 503 otherwise. This checks the running bot's connection, not just its process; it does not check voice connections, YouTube playback, or REST API availability separately. - -Docker probes every 30 seconds, allows 60 seconds for startup, and marks the container unhealthy after three consecutive failures. JDA handles reconnects; health recovers automatically when connected again. Docker health status alone does not trigger a restart, even with a restart policy. Inspect it with `docker inspect --format '{{json .State.Health}}' `. +The Docker image includes a healthcheck that polls JDA's Discord connection state, checking that the bot is connected rather than just running. ## Build locally