diff --git a/backend/pom.xml b/backend/pom.xml index 4303b9a..825a2f8 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -85,6 +85,11 @@ docker-java-transport-httpclient5 3.7.0 + + org.apache.commons + commons-exec + 1.6.0 + diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/config/DeploymentsConfigurationProvider.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/config/DeploymentsConfigurationProvider.java index 8726cef..9952f13 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/config/DeploymentsConfigurationProvider.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/config/DeploymentsConfigurationProvider.java @@ -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 { diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java index d149d2f..63d4e59 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java @@ -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 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))); } diff --git a/backend/src/main/java/org/davidbohl/dirigent/ui/SpaController.java b/backend/src/main/java/org/davidbohl/dirigent/ui/SpaController.java deleted file mode 100644 index 703b888..0000000 --- a/backend/src/main/java/org/davidbohl/dirigent/ui/SpaController.java +++ /dev/null @@ -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"; -// } -//} \ No newline at end of file diff --git a/backend/src/main/java/org/davidbohl/dirigent/ui/WebConfig.java b/backend/src/main/java/org/davidbohl/dirigent/ui/WebConfig.java deleted file mode 100644 index e81ff64..0000000 --- a/backend/src/main/java/org/davidbohl/dirigent/ui/WebConfig.java +++ /dev/null @@ -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"); -// } -//} diff --git a/backend/src/main/java/org/davidbohl/dirigent/utility/GitService.java b/backend/src/main/java/org/davidbohl/dirigent/utility/git/GitService.java similarity index 58% rename from backend/src/main/java/org/davidbohl/dirigent/utility/GitService.java rename to backend/src/main/java/org/davidbohl/dirigent/utility/git/GitService.java index 2109e8f..80bbaeb 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/utility/GitService.java +++ b/backend/src/main/java/org/davidbohl/dirigent/utility/git/GitService.java @@ -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) { diff --git a/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessResult.java b/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessResult.java new file mode 100644 index 0000000..09a5d6d --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessResult.java @@ -0,0 +1,3 @@ +package org.davidbohl.dirigent.utility.process; + +public record ProcessResult(int exitCode, String stdout, String stderr) {} \ No newline at end of file diff --git a/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessRunner.java b/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessRunner.java new file mode 100644 index 0000000..28ba526 --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/utility/process/ProcessRunner.java @@ -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 commandParts, long timeoutMs, Map env) + throws IOException { + return executeInternal(commandParts, new File(System.getProperty("user.dir")), timeoutMs, env); + } + + public ProcessResult executeCommand(List commandParts) + throws IOException { + return executeInternal(commandParts, new File(System.getProperty("user.dir")), 0, Map.of()); + } + + public ProcessResult executeCommand(List commandParts, File workingDirectory) + throws IOException { + return executeInternal(commandParts, workingDirectory, 0, Map.of()); + } + + public ProcessResult executeCommand(List commandParts, File workingDirectory, Map env) + throws IOException { + return executeInternal(commandParts, workingDirectory, 0, env); + } + + private ProcessResult executeInternal(List commandParts, File workingDirectory, long timeoutMs, Map env) + throws IOException { + + Map 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); + } + +}