diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java new file mode 100644 index 00000000..62233ac5 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java @@ -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 getLogs(String id, int tail) { return Stream.empty(); } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java new file mode 100644 index 00000000..d61bd217 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java @@ -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 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 getLogs(String containerId, int tailLines) { + List logLines = new ArrayList<>(); + try { + dockerClient.logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withTail(tailLines) + .withTimestamps(true) + .exec(new ResultCallback.Adapter() { + @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(); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java new file mode 100644 index 00000000..10c569ef --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java @@ -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(); + } +}