feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment

- DockerRuntimeOrchestrator: docker-java based container lifecycle
- DisabledRuntimeOrchestrator: no-op for observability-only mode
- RuntimeOrchestratorAutoConfig: auto-detects Docker socket availability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 23:44:32 +02:00
parent 585e078667
commit b05b7e5597
3 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.ContainerRequest;
import com.cameleer3.server.core.runtime.ContainerStatus;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import java.util.stream.Stream;
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
@Override public boolean isEnabled() { return false; }
@Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); }
@Override public Stream<String> getLogs(String id, int tail) { return Stream.empty(); }
}

View File

@@ -0,0 +1,150 @@
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.ContainerRequest;
import com.cameleer3.server.core.runtime.ContainerStatus;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.AccessMode;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HealthCheck;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
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()
.withDockerHost("unix:///var/run/docker.sock")
.build();
var httpClient = new ZerodepDockerHttpClient.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 boolean isEnabled() {
return true;
}
@Override
public String startContainer(ContainerRequest request) {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Volume bind: mount JAR into container
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withBinds(jarBind);
var container = dockerClient.createContainerCmd(request.baseImage())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels() != null ? request.labels() : Map.of())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/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();
var health = state.getHealth();
var healthStatus = health != null ? health.getStatus() : null;
// Use health status if available, otherwise fall back to container state
var effectiveState = healthStatus != null ? healthStatus : state.getStatus();
return new ContainerStatus(
effectiveState,
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 Stream<String> getLogs(String containerId, int tailLines) {
List<String> logLines = new ArrayList<>();
try {
dockerClient.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withTail(tailLines)
.withTimestamps(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
logLines.add(new String(frame.getPayload()).trim());
}
}).awaitCompletion();
} catch (Exception e) {
log.warn("Failed to get logs for container {}: {}", containerId, e.getMessage());
}
return logLines.stream();
}
}

View File

@@ -0,0 +1,28 @@
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class RuntimeOrchestratorAutoConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class);
@Bean
public RuntimeOrchestrator runtimeOrchestrator() {
// Auto-detect: Docker socket available?
if (Files.exists(Path.of("/var/run/docker.sock"))) {
log.info("Docker socket detected - enabling Docker runtime orchestrator");
return new DockerRuntimeOrchestrator();
}
// TODO: K8s detection (check for service account token)
log.info("No Docker socket or K8s detected - runtime management disabled (observability-only mode)");
return new DisabledRuntimeOrchestrator();
}
}