From 8368ff2359b8f0c77e344c52bf0b38d70e128201 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Fri, 13 Mar 2026 10:05:32 -0600 Subject: [PATCH] Harden run configs --- pom.xml | 10 + .../petshop/backend/DevStackApplication.java | 215 +++++++++--------- .../petshop/backend/DockerComposeSupport.java | 164 +++++++++++++ .../backend/ResetDatabaseApplication.java | 10 + 4 files changed, 296 insertions(+), 103 deletions(-) create mode 100644 src/main/java/com/petshop/backend/DockerComposeSupport.java create mode 100644 src/main/java/com/petshop/backend/ResetDatabaseApplication.java diff --git a/pom.xml b/pom.xml index 980c753b..1d3a79ac 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,16 @@ runtime + + reset-db + + java + + + com.petshop.backend.ResetDatabaseApplication + runtime + + docker-up diff --git a/src/main/java/com/petshop/backend/DevStackApplication.java b/src/main/java/com/petshop/backend/DevStackApplication.java index 76b76de4..f60e9957 100644 --- a/src/main/java/com/petshop/backend/DevStackApplication.java +++ b/src/main/java/com/petshop/backend/DevStackApplication.java @@ -2,17 +2,12 @@ package com.petshop.backend; import com.petshop.backend.config.FlywayContextInitializer; import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.server.PortInUseException; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.ContextClosedEvent; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.net.BindException; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -25,133 +20,147 @@ public class DevStackApplication { private static final Duration WATCH_INTERVAL = Duration.ofSeconds(2); public static void main(String[] args) { - DevStackController controller = new DevStackController(); + DockerComposeSupport docker = new DockerComposeSupport(); ConfigurableApplicationContext context = null; CountDownLatch shutdownLatch = new CountDownLatch(1); + ScheduledExecutorService scheduler = null; + AtomicBoolean shuttingDown = new AtomicBoolean(false); try { - controller.startDatabase(); + validateJavaVersion(); + docker.ensureDockerAvailable(); + docker.startDatabase(); context = new SpringApplicationBuilder(BackendApplication.class) .initializers(new FlywayContextInitializer()) .run(args); - ConfigurableApplicationContext appContext = context; context.addApplicationListener(event -> { if (event instanceof ContextClosedEvent) { shutdownLatch.countDown(); } }); - controller.watchDatabase(appContext); + scheduler = startDatabaseWatch(docker, context, shuttingDown); shutdownLatch.await(); + } catch (RuntimeException ex) { + throw new IllegalStateException(describeStartupFailure(ex), ex); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException("Dev stack interrupted", ex); } finally { - controller.stopWatching(); + if (scheduler != null) { + scheduler.shutdownNow(); + } if (context != null && context.isActive()) { context.close(); } - controller.stopDatabase(); + if (!shuttingDown.get()) { + docker.stopDatabase(); + } } } - private static final class DevStackController { - private final Path projectDir = Paths.get("").toAbsolutePath(); - private final AtomicBoolean shuttingDown = new AtomicBoolean(false); - private ScheduledExecutorService scheduler; - - private void startDatabase() { - runCommand(composeCommand("up", "-d", "--wait", "db")); + private static void validateJavaVersion() { + int feature = Runtime.version().feature(); + if (feature < 25) { + throw new IllegalStateException("JDK 25 or newer is required. IntelliJ is currently using Java " + Runtime.version() + "."); } + } - private void stopDatabase() { - if (!shuttingDown.compareAndSet(false, true)) { + private static ScheduledExecutorService startDatabaseWatch(DockerComposeSupport docker, ConfigurableApplicationContext context, AtomicBoolean shuttingDown) { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduler.scheduleWithFixedDelay(() -> { + if (shuttingDown.get()) { return; } - stopWatching(); - runCommand(composeCommand("stop", "db")); - } - - private void watchDatabase(ConfigurableApplicationContext context) { - scheduler = Executors.newSingleThreadScheduledExecutor(); - scheduler.scheduleWithFixedDelay(() -> { - if (shuttingDown.get()) { - return; + if (!docker.isDatabaseRunning()) { + shuttingDown.set(true); + if (context.isActive()) { + context.close(); } - if (!isDatabaseRunning()) { - shuttingDown.set(true); - if (context.isActive()) { - context.close(); - } + } + }, WATCH_INTERVAL.toSeconds(), WATCH_INTERVAL.toSeconds(), TimeUnit.SECONDS); + return scheduler; + } + + private static String describeStartupFailure(RuntimeException ex) { + Throwable cause = deepestCause(ex); + String message = cause.getMessage(); + String lowerMessage = ""; + if (message != null) { + lowerMessage = message.toLowerCase(Locale.ROOT); + } + + switch (classifyStartupFailure(cause, lowerMessage)) { + case SERVER_PORT_IN_USE: + return "Backend startup failed because port 8080 is already in use. Stop the other process or set SERVER_PORT to a different value."; + case DOCKER_NOT_RUNNING: + return "Backend startup failed because Docker is not available. Start Docker Desktop and try again."; + case DOCKER_NOT_INSTALLED: + return "Backend startup failed because Docker is not installed or not on PATH. Install Docker Desktop and reopen IntelliJ."; + case DATABASE_PORT_IN_USE: + return "Backend startup failed because port 3306 is already in use. Stop the conflicting MySQL service or container, then run Reset Database and try again."; + case STALE_SCHEMA: + return "Backend startup failed because the database schema is stale or incomplete. Run Reset Database and then start Pet Shop Application again."; + case FLYWAY_MIGRATION_FAILED: + return "Backend startup failed during Flyway migration. Run Reset Database and check the database logs if the problem persists."; + case DATASOURCE_CONFIG_MISSING: + return "Backend startup failed because datasource configuration was not loaded. Reload the Maven project and rerun Pet Shop Application."; + case DATABASE_UNREACHABLE: + return "Backend startup failed because it could not connect to MySQL. Make sure Docker Desktop is running, the database container is healthy, and port 3306 is reachable."; + case UNKNOWN: + if (message == null || message.isBlank()) { + return "Backend startup failed: " + ex.getClass().getSimpleName(); } - }, WATCH_INTERVAL.toSeconds(), WATCH_INTERVAL.toSeconds(), TimeUnit.SECONDS); - } - - private void stopWatching() { - if (scheduler != null) { - scheduler.shutdownNow(); - scheduler = null; - } - } - - private boolean isDatabaseRunning() { - CommandResult result = runCommand(composeCommand("ps", "--status", "running", "--services", "db"), false); - return result.exitCode == 0 && result.output.lines() - .map(String::trim) - .anyMatch("db"::equals); - } - - private List composeCommand(String... args) { - List command = new ArrayList<>(); - command.add(resolveDockerExecutable()); - command.add("compose"); - command.add("-f"); - command.add("docker-compose.dev.yml"); - for (String arg : args) { - command.add(arg); - } - return command; - } - - private String resolveDockerExecutable() { - String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); - return os.contains("win") ? "docker.exe" : "docker"; - } - - private void runCommand(List command) { - CommandResult result = runCommand(command, true); - if (result.exitCode != 0) { - throw new IllegalStateException(result.output.isBlank() ? "Command failed: " + String.join(" ", command) : result.output); - } - } - - private CommandResult runCommand(List command, boolean printOutput) { - ProcessBuilder builder = new ProcessBuilder(command); - builder.directory(projectDir.toFile()); - builder.redirectErrorStream(true); - - try { - Process process = builder.start(); - StringBuilder output = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append(System.lineSeparator()); - if (printOutput) { - System.out.println(line); - } - } - } - int exitCode = process.waitFor(); - return new CommandResult(exitCode, output.toString().trim()); - } catch (IOException ex) { - throw new IllegalStateException("Unable to run docker command", ex); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Docker command interrupted", ex); - } + return "Backend startup failed: " + message; + default: + throw new IllegalStateException("Unhandled startup failure classification"); } } - private record CommandResult(int exitCode, String output) { + private static StartupFailure classifyStartupFailure(Throwable cause, String lowerMessage) { + if (cause instanceof PortInUseException || cause instanceof BindException || lowerMessage.contains("port 8080") && lowerMessage.contains("already in use")) { + return StartupFailure.SERVER_PORT_IN_USE; + } + if (lowerMessage.contains("docker desktop") || lowerMessage.contains("docker daemon") || lowerMessage.contains("docker engine") || lowerMessage.contains("cannot connect to the docker daemon")) { + return StartupFailure.DOCKER_NOT_RUNNING; + } + if (lowerMessage.contains("docker executable") || lowerMessage.contains("no such file or directory") && lowerMessage.contains("docker")) { + return StartupFailure.DOCKER_NOT_INSTALLED; + } + if (lowerMessage.contains("port is already allocated") || lowerMessage.contains("address already in use") && lowerMessage.contains("3306")) { + return StartupFailure.DATABASE_PORT_IN_USE; + } + if (lowerMessage.contains("schema validation: missing table")) { + return StartupFailure.STALE_SCHEMA; + } + if (lowerMessage.contains("flyway") || lowerMessage.contains("migration")) { + return StartupFailure.FLYWAY_MIGRATION_FAILED; + } + if (lowerMessage.contains("datasource properties are required")) { + return StartupFailure.DATASOURCE_CONFIG_MISSING; + } + if (lowerMessage.contains("access denied for user") || lowerMessage.contains("communications link failure") || lowerMessage.contains("connection refused") || lowerMessage.contains("unable to determine dialect without jdbc metadata")) { + return StartupFailure.DATABASE_UNREACHABLE; + } + return StartupFailure.UNKNOWN; + } + + private static Throwable deepestCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private enum StartupFailure { + SERVER_PORT_IN_USE, + DOCKER_NOT_RUNNING, + DOCKER_NOT_INSTALLED, + DATABASE_PORT_IN_USE, + STALE_SCHEMA, + FLYWAY_MIGRATION_FAILED, + DATASOURCE_CONFIG_MISSING, + DATABASE_UNREACHABLE, + UNKNOWN } } diff --git a/src/main/java/com/petshop/backend/DockerComposeSupport.java b/src/main/java/com/petshop/backend/DockerComposeSupport.java new file mode 100644 index 00000000..2a1f4396 --- /dev/null +++ b/src/main/java/com/petshop/backend/DockerComposeSupport.java @@ -0,0 +1,164 @@ +package com.petshop.backend; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class DockerComposeSupport { + + private final Path projectDir = Paths.get("").toAbsolutePath(); + + void ensureDockerAvailable() { + CommandResult versionResult = runCommand(List.of(resolveDockerExecutable(), "version"), false); + if (versionResult.exitCode != 0) { + throw new IllegalStateException(describeDockerFailure(versionResult.output)); + } + } + + void startDatabase() { + runCommand(composeCommand("up", "-d", "--wait", "db")); + } + + void stopDatabase() { + runCommand(composeCommand("stop", "db")); + } + + void resetDatabase() { + runCommand(composeCommand("down", "-v", "--remove-orphans")); + String volumeName = getDatabaseVolumeName(); + if (volumeExists(volumeName)) { + runCommand(List.of(resolveDockerExecutable(), "volume", "rm", "-f", volumeName)); + } + } + + boolean isDatabaseRunning() { + CommandResult result = runCommand(composeCommand("ps", "--status", "running", "--services", "db"), false); + if (result.exitCode != 0) { + return false; + } + return result.output.lines() + .map(String::trim) + .anyMatch("db"::equals); + } + + private boolean volumeExists(String volumeName) { + CommandResult result = runCommand(List.of(resolveDockerExecutable(), "volume", "ls", "--format", "{{.Name}}"), false); + if (result.exitCode != 0) { + return false; + } + return result.output.lines() + .map(String::trim) + .anyMatch(volumeName::equals); + } + + private String getDatabaseVolumeName() { + return projectDir.getFileName().toString() + "_db_data"; + } + + private List composeCommand(String... args) { + List command = new ArrayList<>(); + command.add(resolveDockerExecutable()); + command.add("compose"); + command.add("-f"); + command.add("docker-compose.dev.yml"); + for (String arg : args) { + command.add(arg); + } + return command; + } + + private String resolveDockerExecutable() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return "docker.exe"; + } + return "docker"; + } + + private void runCommand(List command) { + CommandResult result = runCommand(command, true); + if (result.exitCode != 0) { + throw new IllegalStateException(describeCommandFailure(command, result.output)); + } + } + + private CommandResult runCommand(List command, boolean printOutput) { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(projectDir.toFile()); + builder.redirectErrorStream(true); + + try { + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append(System.lineSeparator()); + if (printOutput) { + System.out.println(line); + } + } + } + int exitCode = process.waitFor(); + return new CommandResult(exitCode, output.toString().trim()); + } catch (IOException ex) { + String executable = command.isEmpty() ? "docker" : command.getFirst(); + throw new IllegalStateException("Unable to run " + executable + ". Install Docker Desktop and make sure it is available on PATH.", ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Docker command interrupted", ex); + } + } + + private String describeDockerFailure(String output) { + String lowerOutput = ""; + if (output != null) { + lowerOutput = output.toLowerCase(Locale.ROOT); + } + if (lowerOutput.contains("docker desktop") || lowerOutput.contains("docker daemon") || lowerOutput.contains("docker engine") || lowerOutput.contains("cannot connect")) { + return "Docker Desktop is not running. Start Docker Desktop and rerun the command."; + } + if (output == null || output.isBlank()) { + return "Docker is unavailable. Start Docker Desktop and rerun the command."; + } + return output; + } + + private String describeCommandFailure(List command, String output) { + String renderedCommand = String.join(" ", command); + String lowerOutput = ""; + if (output != null) { + lowerOutput = output.toLowerCase(Locale.ROOT); + } + if (renderedCommand.contains(" up ") || renderedCommand.endsWith(" up -d --wait db")) { + if (lowerOutput.contains("port is already allocated") || lowerOutput.contains("address already in use")) { + return "Database startup failed because port 3306 is already in use. Stop the conflicting MySQL service or container, then run Reset Database."; + } + if (lowerOutput.contains("docker desktop") || lowerOutput.contains("docker daemon") || lowerOutput.contains("docker engine")) { + return "Database startup failed because Docker Desktop is not running."; + } + if (output == null || output.isBlank()) { + return "Database startup failed while bringing up the Docker MySQL container."; + } + return output; + } + if (renderedCommand.contains(" volume rm ")) { + if (output == null || output.isBlank()) { + return "Database reset failed while removing the Docker volume."; + } + return output; + } + if (output == null || output.isBlank()) { + return "Command failed: " + renderedCommand; + } + return output; + } + + private record CommandResult(int exitCode, String output) { + } +} diff --git a/src/main/java/com/petshop/backend/ResetDatabaseApplication.java b/src/main/java/com/petshop/backend/ResetDatabaseApplication.java new file mode 100644 index 00000000..056ec36e --- /dev/null +++ b/src/main/java/com/petshop/backend/ResetDatabaseApplication.java @@ -0,0 +1,10 @@ +package com.petshop.backend; + +public class ResetDatabaseApplication { + + public static void main(String[] args) { + DockerComposeSupport docker = new DockerComposeSupport(); + docker.ensureDockerAvailable(); + docker.resetDatabase(); + } +}