From 2151801d40ef59532722ef3ac8977b7fe8bdd49c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:44:34 +0200 Subject: [PATCH] feat: add DockerRuntimeOrchestrator with docker-java Co-Authored-By: Claude Sonnet 4.6 --- .../runtime/DockerRuntimeOrchestrator.java | 167 ++++++++++++++++++ .../DockerRuntimeOrchestratorTest.java | 32 ++++ 2 files changed, 199 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java b/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java new file mode 100644 index 0000000..af20552 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java @@ -0,0 +1,167 @@ +package net.siegeln.cameleer.saas.runtime; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.command.BuildImageResultCallback; +import com.github.dockerjava.api.model.*; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +@Component +public class DockerRuntimeOrchestrator implements RuntimeOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class); + private DockerClient dockerClient; + + @PostConstruct + public void init() { + var config = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + var httpClient = new ApacheDockerHttpClient.Builder() + .dockerHost(config.getDockerHost()) + .build(); + dockerClient = DockerClientImpl.getInstance(config, httpClient); + log.info("Docker client initialized, host: {}", config.getDockerHost()); + } + + @PreDestroy + public void close() throws IOException { + if (dockerClient != null) { + dockerClient.close(); + } + } + + @Override + public String buildImage(BuildImageRequest request) { + Path buildDir = null; + try { + buildDir = Files.createTempDirectory("cameleer-build-"); + var dockerfile = buildDir.resolve("Dockerfile"); + Files.writeString(dockerfile, + "FROM " + request.baseImage() + "\nCOPY app.jar /app/app.jar\n"); + Files.copy(request.jarPath(), buildDir.resolve("app.jar"), StandardCopyOption.REPLACE_EXISTING); + + var imageId = dockerClient.buildImageCmd(buildDir.toFile()) + .withTags(Set.of(request.imageTag())) + .exec(new BuildImageResultCallback()) + .awaitImageId(); + + log.info("Built image {} -> {}", request.imageTag(), imageId); + return imageId; + } catch (IOException e) { + throw new RuntimeException("Failed to build image: " + e.getMessage(), e); + } finally { + if (buildDir != null) { + deleteDirectory(buildDir); + } + } + } + + @Override + public String startContainer(StartContainerRequest request) { + var envList = request.envVars().entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .toList(); + + var hostConfig = HostConfig.newHostConfig() + .withMemory(request.memoryLimitBytes()) + .withMemorySwap(request.memoryLimitBytes()) + .withCpuShares(request.cpuShares()) + .withNetworkMode(request.network()); + + var container = dockerClient.createContainerCmd(request.imageRef()) + .withName(request.containerName()) + .withEnv(envList) + .withHostConfig(hostConfig) + .withHealthcheck(new HealthCheck() + .withTest(List.of("CMD-SHELL", + "wget -qO- http://localhost:" + request.healthCheckPort() + "/health || exit 1")) + .withInterval(10_000_000_000L) // 10s + .withTimeout(5_000_000_000L) // 5s + .withRetries(3) + .withStartPeriod(30_000_000_000L)) // 30s + .exec(); + + dockerClient.startContainerCmd(container.getId()).exec(); + log.info("Started container {} ({})", request.containerName(), container.getId()); + return container.getId(); + } + + @Override + public void stopContainer(String containerId) { + try { + dockerClient.stopContainerCmd(containerId).withTimeout(30).exec(); + log.info("Stopped container {}", containerId); + } catch (Exception e) { + log.warn("Failed to stop container {}: {}", containerId, e.getMessage()); + } + } + + @Override + public void removeContainer(String containerId) { + try { + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + log.info("Removed container {}", containerId); + } catch (Exception e) { + log.warn("Failed to remove container {}: {}", containerId, e.getMessage()); + } + } + + @Override + public ContainerStatus getContainerStatus(String containerId) { + try { + var inspection = dockerClient.inspectContainerCmd(containerId).exec(); + var state = inspection.getState(); + return new ContainerStatus( + state.getStatus(), + Boolean.TRUE.equals(state.getRunning()), + state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0, + state.getError()); + } catch (Exception e) { + return new ContainerStatus("not_found", false, -1, e.getMessage()); + } + } + + @Override + public void streamLogs(String containerId, LogConsumer consumer) { + dockerClient.logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withFollowStream(true) + .withTimestamps(true) + .exec(new ResultCallback.Adapter() { + @Override + public void onNext(Frame frame) { + var stream = frame.getStreamType() == StreamType.STDERR ? "stderr" : "stdout"; + consumer.accept(stream, new String(frame.getPayload()).trim(), + System.currentTimeMillis()); + } + }); + } + + private void deleteDirectory(Path dir) { + try { + Files.walk(dir) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } catch (IOException e) { + log.warn("Failed to clean up build directory: {}", dir, e); + } + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java b/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java new file mode 100644 index 0000000..1914710 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java @@ -0,0 +1,32 @@ +package net.siegeln.cameleer.saas.runtime; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class DockerRuntimeOrchestratorTest { + + @Test + void runtimeConfig_parseMemoryLimitBytes_megabytes() { + assertEquals(512 * 1024 * 1024L, parseMemoryLimit("512m")); + } + + @Test + void runtimeConfig_parseMemoryLimitBytes_gigabytes() { + assertEquals(1024L * 1024 * 1024, parseMemoryLimit("1g")); + } + + @Test + void runtimeConfig_parseMemoryLimitBytes_bytes() { + assertEquals(536870912L, parseMemoryLimit("536870912")); + } + + private long parseMemoryLimit(String limit) { + var l = limit.trim().toLowerCase(); + if (l.endsWith("g")) { + return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024 * 1024; + } else if (l.endsWith("m")) { + return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024; + } + return Long.parseLong(l); + } +}