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();
+ }
+}