Harden run configs
This commit is contained in:
10
pom.xml
10
pom.xml
@@ -126,6 +126,16 @@
|
||||
<classpathScope>runtime</classpathScope>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>reset-db</id>
|
||||
<goals>
|
||||
<goal>java</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<mainClass>com.petshop.backend.ResetDatabaseApplication</mainClass>
|
||||
<classpathScope>runtime</classpathScope>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>docker-up</id>
|
||||
<goals>
|
||||
|
||||
@@ -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<String> composeCommand(String... args) {
|
||||
List<String> 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<String> 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<String> 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
|
||||
}
|
||||
}
|
||||
|
||||
164
src/main/java/com/petshop/backend/DockerComposeSupport.java
Normal file
164
src/main/java/com/petshop/backend/DockerComposeSupport.java
Normal file
@@ -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<String> composeCommand(String... args) {
|
||||
List<String> 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<String> command) {
|
||||
CommandResult result = runCommand(command, true);
|
||||
if (result.exitCode != 0) {
|
||||
throw new IllegalStateException(describeCommandFailure(command, result.output));
|
||||
}
|
||||
}
|
||||
|
||||
private CommandResult runCommand(List<String> 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<String> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user