Refactored Process creation to avoid long living zombies

This commit is contained in:
DerDavidBohl
2025-12-05 15:45:09 +01:00
parent 0dfc1aa642
commit a977fbea87
8 changed files with 172 additions and 130 deletions

View File

@@ -85,6 +85,11 @@
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.7.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.6.0</version>
</dependency>
</dependencies>
<build>

View File

@@ -1,13 +1,14 @@
package org.davidbohl.dirigent.deployments.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.File;
import org.davidbohl.dirigent.deployments.models.DeploynentConfiguration;
import org.davidbohl.dirigent.utility.GitService;
import org.davidbohl.dirigent.utility.git.GitService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
@Service
public class DeploymentsConfigurationProvider {

View File

@@ -1,13 +1,35 @@
package org.davidbohl.dirigent.deployments.management;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import org.davidbohl.dirigent.deployments.config.DeploymentsConfigurationProvider;
import org.davidbohl.dirigent.deployments.events.*;
import org.davidbohl.dirigent.deployments.events.AllDeploymentsStartRequestedEvent;
import org.davidbohl.dirigent.deployments.events.DeploymentStateEvent;
import org.davidbohl.dirigent.deployments.events.MultipleNamedDeploymentsStartRequestedEvent;
import org.davidbohl.dirigent.deployments.events.NamedDeploymentStartRequestedEvent;
import org.davidbohl.dirigent.deployments.events.NamedDeploymentStopRequestedEvent;
import org.davidbohl.dirigent.deployments.events.RecreateAllDeploymentStatesEvent;
import org.davidbohl.dirigent.deployments.events.SourceDeploymentStartRequestedEvent;
import org.davidbohl.dirigent.deployments.models.Deployment;
import org.davidbohl.dirigent.deployments.models.DeploynentConfiguration;
import org.davidbohl.dirigent.deployments.state.DeploymentState;
import org.davidbohl.dirigent.deployments.state.DeploymentStatePersistingService;
import org.davidbohl.dirigent.sercrets.SecretService;
import org.davidbohl.dirigent.utility.GitService;
import org.davidbohl.dirigent.utility.git.GitService;
import org.davidbohl.dirigent.utility.process.ProcessResult;
import org.davidbohl.dirigent.utility.process.ProcessRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -15,17 +37,10 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor()
public class DeploymentsService {
public static final String DEPLOYMENTS_DIR_NAME = "deployments";
@@ -35,23 +50,11 @@ public class DeploymentsService {
private final ApplicationEventPublisher applicationEventPublisher;
private final DeploymentStatePersistingService deploymentStatePersistingService;
private final SecretService secretService;
private final ProcessRunner processRunner;
@Value("${dirigent.compose.command}")
private String composeCommand;
public DeploymentsService(
DeploymentsConfigurationProvider deploymentsConfigurationProvider,
GitService gitService,
ApplicationEventPublisher applicationEventPublisher,
DeploymentStatePersistingService deploymentStatePersistingService,
SecretService secretService) {
this.deploymentsConfigurationProvider = deploymentsConfigurationProvider;
this.gitService = gitService;
this.applicationEventPublisher = applicationEventPublisher;
this.deploymentStatePersistingService = deploymentStatePersistingService;
this.secretService = secretService;
}
@EventListener(AllDeploymentsStartRequestedEvent.class)
public void onAllDeploymentsStartRequested(AllDeploymentsStartRequestedEvent event) {
@@ -188,26 +191,13 @@ public class DeploymentsService {
}
logger.info("Upping Compose for {}", deployment.name());
ProcessBuilder builder = new ProcessBuilder(commandArgs)
.directory(deploymentDir);
builder.environment().putAll(secretService.getAllSecretsAsEnvironmentVariableMapByDeployment(deployment.name()));
Process process = builder
.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder errorOutput = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
errorOutput.append(line).append("\n");
}
int exitCode = process.waitFor();
reader.close();
ProcessResult composeUp = processRunner.executeCommand(commandArgs,
deploymentDir,
secretService.getAllSecretsAsEnvironmentVariableMapByDeployment(deployment.name()));
if ((exitCode != 0)) {
applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.FAILED, errorOutput.toString()));
if ((composeUp.exitCode() != 0)) {
applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.FAILED, composeUp.stderr()));
return;
}
} catch (IOException | InterruptedException e) {
@@ -256,10 +246,8 @@ public class DeploymentsService {
List<String> commandArgs = new ArrayList<>(Arrays.stream(composeCommand.split(" ")).toList());
commandArgs.add("down");
new ProcessBuilder(commandArgs)
.directory(new File(DEPLOYMENTS_DIR_NAME + "/" + deploymentName))
.start()
.waitFor();
processRunner.executeCommand(commandArgs, new File(DEPLOYMENTS_DIR_NAME + "/" + deploymentName));
applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deploymentName, DeploymentState.State.STOPPED, "Deployment '%s' stopped".formatted(deploymentName)));
}

View File

@@ -1,23 +0,0 @@
//package org.davidbohl.dirigent.ui;
//
//import jakarta.servlet.http.HttpServletRequest;
//import org.springframework.stereotype.Controller;
//import org.springframework.web.bind.annotation.RequestMapping;
//import org.springframework.web.servlet.HandlerMapping;
//import org.springframework.web.util.UrlPathHelper;
//
//@Controller
//public class SpaController {
//
// @RequestMapping(path = { "/ui/", "/ui/**"})
// public String forward(HttpServletRequest request) {
// UrlPathHelper pathHelper = new UrlPathHelper();
// String path = pathHelper.getPathWithinApplication(request);
//
// if (path.contains(".")) {
// return null;
// }
//
// return "forward:/ui/index.html";
// }
//}

View File

@@ -1,16 +0,0 @@
//package org.davidbohl.dirigent.ui;
//
//import org.springframework.context.annotation.Configuration;
//import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
//import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
//import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
//
//@Configuration
//public class WebConfig implements WebMvcConfigurer {
// @Override
// public void addViewControllers(ViewControllerRegistry registry) {
// // Forward only if it's NOT a request for a resource with a file extension (like .js, .css, .png)
// registry.addViewController("/ui/{path:[^\\.]*}/**") // Match "/ui/something" or "/ui/something/else", excluding URLs with dots.
// .setViewName("forward:/ui/index.html");
// }
//}

View File

@@ -1,19 +1,25 @@
package org.davidbohl.dirigent.utility;
package org.davidbohl.dirigent.utility.git;
import org.davidbohl.dirigent.utility.process.ProcessResult;
import org.davidbohl.dirigent.utility.process.ProcessRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import lombok.RequiredArgsConstructor;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class GitService {
private final Logger logger = LoggerFactory.getLogger(GitService.class);
@@ -21,6 +27,8 @@ public class GitService {
@Value("${dirigent.git.authToken}")
private String authToken;
private final ProcessRunner processRunner;
public boolean updateRepo(String repoUrl, String targetDir, String rev) throws IOException, InterruptedException {
logger.info("Cloning or pulling git repository '{}' to dir '{}' @ rev '{}'", repoUrl, targetDir, rev);
@@ -51,14 +59,12 @@ public class GitService {
String currentHeadRev = getHeadRev(destinationDir);
new ProcessBuilder("git", "reset", "--hard", "HEAD")
.directory(destinationDir).start().waitFor();
new ProcessBuilder("git", "fetch", "--all")
.directory(destinationDir).start().waitFor();
new ProcessBuilder("git", "checkout", rev)
.directory(destinationDir).start().waitFor();
new ProcessBuilder("git", "pull")
.directory(destinationDir).start().waitFor();
processRunner.executeCommand(List.of("git", "reset", "--hard", "HEAD"), destinationDir);
processRunner.executeCommand(List.of("git", "fetch", "--all"), destinationDir);
processRunner.executeCommand(List.of("git", "checkout", rev), destinationDir);
processRunner.executeCommand(List.of("git", "pull"), destinationDir);
String newHeadRev = getHeadRev(destinationDir);
changed = !currentHeadRev.equals(newHeadRev);
@@ -66,18 +72,11 @@ public class GitService {
changed = true;
logger.debug("Local Repo does not exist. Cloning repository.");
ensureFileOrDirectoryIsDeletedRecursive(destinationDir);
Process process = new ProcessBuilder("git", "clone", remoteGitUri, targetDir)
.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String line;
StringBuilder stringBuilder = new StringBuilder();
while ((line = reader.readLine()) != null) {
stringBuilder.append(line).append(System.lineSeparator());
}
throw new IOException("Git clone failed with exit code " + exitCode + ": " + stringBuilder);
ProcessResult gitClone = processRunner.executeCommand(List.of("git", "clone", remoteGitUri, targetDir));
if (gitClone.exitCode() != 0) {
throw new IOException("Git clone failed with exit code " + gitClone.exitCode() + ": " + gitClone.stderr());
}
}
@@ -95,30 +94,15 @@ public class GitService {
return uriComponentsBuilder.toUriString();
}
private static String getCurrentGitRemoteUrl(File destinationDir) throws IOException, InterruptedException {
Process process = new ProcessBuilder("git", "config", "--get", "remote.origin.url")
.directory(destinationDir).start();
return getStdOutFromProcess(process).trim();
private String getCurrentGitRemoteUrl(File destinationDir) throws IOException, InterruptedException {
return processRunner.executeCommand(List.of("git", "config", "--get", "remote.origin.url"), destinationDir).stdout().trim();
}
private static String getHeadRev(File destinationDir) throws IOException, InterruptedException {
Process process = new ProcessBuilder("git", "rev-parse", "HEAD")
.directory(destinationDir).start();
return getStdOutFromProcess(process);
private String getHeadRev(File destinationDir) throws IOException, InterruptedException {
return processRunner.executeCommand(List.of("git", "rev-parse", "HEAD"), destinationDir).stdout().trim();
}
private static String getStdOutFromProcess(Process process) throws IOException, InterruptedException {
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
StringBuilder stringBuilder = new StringBuilder();
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
String currentRev = stringBuilder.toString();
process.waitFor();
return currentRev;
}
private static void ensureFileOrDirectoryIsDeletedRecursive(File directoryOrFileToBeDeleted) {
File[] allContents = directoryOrFileToBeDeleted.listFiles();
if (allContents != null) {

View File

@@ -0,0 +1,3 @@
package org.davidbohl.dirigent.utility.process;
public record ProcessResult(int exitCode, String stdout, String stderr) {}

View File

@@ -0,0 +1,100 @@
package org.davidbohl.dirigent.utility.process;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteException;
import org.apache.commons.exec.ExecuteWatchdog;
import org.apache.commons.exec.Executor;
import org.apache.commons.exec.PumpStreamHandler;
import org.springframework.stereotype.Component;
@Component
public class ProcessRunner {
public ProcessResult executeCommand(List<String> commandParts, long timeoutMs, Map<String, String> env)
throws IOException {
return executeInternal(commandParts, new File(System.getProperty("user.dir")), timeoutMs, env);
}
public ProcessResult executeCommand(List<String> commandParts)
throws IOException {
return executeInternal(commandParts, new File(System.getProperty("user.dir")), 0, Map.of());
}
public ProcessResult executeCommand(List<String> commandParts, File workingDirectory)
throws IOException {
return executeInternal(commandParts, workingDirectory, 0, Map.of());
}
public ProcessResult executeCommand(List<String> commandParts, File workingDirectory, Map<String, String> env)
throws IOException {
return executeInternal(commandParts, workingDirectory, 0, env);
}
private ProcessResult executeInternal(List<String> commandParts, File workingDirectory, long timeoutMs, Map<String, String> env)
throws IOException {
Map<String, String> finalEnv = System.getenv();
if(env != null && env.size() > 0)
finalEnv.putAll(env);
CommandLine command = new CommandLine(commandParts.get(0));
for (int i = 1; i < commandParts.size(); i++) {
command.addArgument(commandParts.get(i));
}
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
PumpStreamHandler streamHandler = new PumpStreamHandler(stdout, stderr);
Executor executor = DefaultExecutor.builder().get();
executor.setExitValue(0);
executor.setStreamHandler(streamHandler);
executor.setWorkingDirectory(workingDirectory);
ExecuteWatchdog watchdog = null;
if (timeoutMs > 0) {
watchdog = ExecuteWatchdog.builder()
.setTimeout(Duration.ofMillis(timeoutMs))
.get();
executor.setWatchdog(watchdog);
}
int exitCode = -1;
try {
exitCode = executor.execute(command, finalEnv);
} catch (ExecuteException e) {
exitCode = e.getExitValue();
} catch (InterruptedIOException e) {
throw e;
} finally {
streamHandler.stop();
try {
stdout.close();
} catch (IOException ignored) {
}
try {
stderr.close();
} catch (IOException ignored) {
}
}
String stdoutString = stdout.toString(StandardCharsets.UTF_8);
String stderrString = stderr.toString(StandardCharsets.UTF_8);
return new ProcessResult(exitCode, stdoutString, stderrString);
}
}