From 96a5b1d9f16dd4c65446601f5b975f19b326f157 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:42:01 +0200 Subject: [PATCH] feat: implement DockerTenantProvisioner with container lifecycle Replace stub with full Docker implementation using docker-java. Manages per-tenant server and UI containers with Traefik labels, health checks, image pull, network attachment, and full lifecycle (provision/start/stop/remove/status). Co-Authored-By: Claude Sonnet 4.6 --- .../provisioning/DockerTenantProvisioner.java | 254 +++++++++++++++++- 1 file changed, 245 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java index 0c7803b..760c725 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -1,21 +1,257 @@ package net.siegeln.cameleer.saas.provisioning; +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.*; import com.github.dockerjava.core.DockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.transport.DockerHttpClient; +import com.github.dockerjava.zerodep.ZerodepDockerHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.List; +import java.util.Map; public class DockerTenantProvisioner implements TenantProvisioner { - private final DockerClientConfig config; + private static final Logger log = LoggerFactory.getLogger(DockerTenantProvisioner.class); + + private final DockerClient docker; private final ProvisioningProperties props; public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) { - this.config = config; this.props = props; + DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder() + .dockerHost(config.getDockerHost()) + .maxConnections(10) + .connectionTimeout(Duration.ofSeconds(5)) + .responseTimeout(Duration.ofSeconds(30)) + .build(); + this.docker = DockerClientImpl.getInstance(config, httpClient); } - @Override public boolean isAvailable() { return true; } - @Override public ProvisionResult provision(TenantProvisionRequest request) { throw new UnsupportedOperationException("Not yet implemented"); } - @Override public void start(String slug) { throw new UnsupportedOperationException("Not yet implemented"); } - @Override public void stop(String slug) { throw new UnsupportedOperationException("Not yet implemented"); } - @Override public void remove(String slug) { throw new UnsupportedOperationException("Not yet implemented"); } - @Override public ServerStatus getStatus(String slug) { return ServerStatus.notFound(); } - @Override public String getServerEndpoint(String slug) { return "http://cameleer-server-" + slug + ":8081"; } + @Override + public boolean isAvailable() { return true; } + + @Override + public ProvisionResult provision(TenantProvisionRequest req) { + String serverName = serverContainerName(req.slug()); + String uiName = uiContainerName(req.slug()); + String endpoint = "http://" + serverName + ":8081"; + + try { + pullIfMissing(props.serverImage()); + pullIfMissing(props.serverUiImage()); + + createServerContainer(req, serverName); + docker.startContainerCmd(serverName).exec(); + + createUiContainer(req.slug(), uiName, serverName); + docker.startContainerCmd(uiName).exec(); + + if (!waitForHealth(serverName, 60)) { + return ProvisionResult.fail("Server did not become healthy within 60s"); + } + + log.info("Provisioned tenant '{}': server={}, ui={}", req.slug(), serverName, uiName); + return ProvisionResult.ok(endpoint); + } catch (Exception e) { + log.error("Failed to provision tenant '{}'", req.slug(), e); + return ProvisionResult.fail(e.getMessage()); + } + } + + @Override + public void start(String slug) { + try { + docker.startContainerCmd(serverContainerName(slug)).exec(); + docker.startContainerCmd(uiContainerName(slug)).exec(); + } catch (Exception e) { + log.error("Failed to start containers for '{}'", slug, e); + throw new RuntimeException("Start failed: " + e.getMessage(), e); + } + } + + @Override + public void stop(String slug) { + try { + stopIfRunning(serverContainerName(slug)); + stopIfRunning(uiContainerName(slug)); + } catch (Exception e) { + log.error("Failed to stop containers for '{}'", slug, e); + throw new RuntimeException("Stop failed: " + e.getMessage(), e); + } + } + + @Override + public void remove(String slug) { + removeContainer(uiContainerName(slug)); + removeContainer(serverContainerName(slug)); + } + + @Override + public ServerStatus getStatus(String slug) { + try { + InspectContainerResponse info = docker.inspectContainerCmd(serverContainerName(slug)).exec(); + String state = info.getState().getStatus(); + String id = info.getId(); + if ("running".equals(state)) return ServerStatus.running(id); + return ServerStatus.stopped(id); + } catch (NotFoundException e) { + return ServerStatus.notFound(); + } catch (Exception e) { + return ServerStatus.error(e.getMessage()); + } + } + + @Override + public String getServerEndpoint(String slug) { + return "http://" + serverContainerName(slug) + ":8081"; + } + + private void createServerContainer(TenantProvisionRequest req, String name) { + String slug = req.slug(); + Map labels = Map.of( + "traefik.enable", "true", + "traefik.http.routers.server-" + slug + ".rule", "PathPrefix(`/t/" + slug + "/api`) || PathPrefix(`/t/" + slug + "/actuator`)", + "traefik.http.routers.server-" + slug + ".tls", "true", + "traefik.http.services.server-" + slug + ".loadbalancer.server.port", "8081", + "cameleer.tenant", slug, + "cameleer.role", "server" + ); + + List env = List.of( + "SPRING_DATASOURCE_URL=" + props.datasourceUrl(), + "SPRING_DATASOURCE_USERNAME=cameleer", + "SPRING_DATASOURCE_PASSWORD=cameleer_dev", + "CAMELEER_TENANT_ID=" + slug, + "CAMELEER_OIDC_ISSUER_URI=" + props.oidcIssuerUri(), + "CAMELEER_OIDC_JWK_SET_URI=" + props.oidcJwkSetUri(), + "CAMELEER_OIDC_TLS_SKIP_VERIFY=true", + "CAMELEER_CORS_ALLOWED_ORIGINS=" + props.corsOrigins(), + "CAMELEER_LICENSE_TOKEN=" + req.licenseToken(), + "CAMELEER_RUNTIME_ENABLED=true", + "CAMELEER_SERVER_URL=http://" + name + ":8081", + "CAMELEER_ROUTING_DOMAIN=" + props.publicHost(), + "CAMELEER_ROUTING_MODE=path" + ); + + HostConfig hostConfig = HostConfig.newHostConfig() + .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) + .withNetworkMode(props.networkName()); + + CreateContainerResponse resp = docker.createContainerCmd(props.serverImage()) + .withName(name) + .withLabels(labels) + .withEnv(env) + .withHostConfig(hostConfig) + .withHealthcheck(new HealthCheck() + .withTest(List.of("CMD-SHELL", "wget -q -O- http://localhost:8081/actuator/health || exit 1")) + .withInterval(5_000_000_000L) + .withTimeout(3_000_000_000L) + .withRetries(12)) + .exec(); + + // Connect to traefik network + docker.connectToNetworkCmd() + .withNetworkId(props.traefikNetwork()) + .withContainerId(resp.getId()) + .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) + .exec(); + } + + private void createUiContainer(String slug, String uiName, String serverName) { + Map labels = Map.of( + "traefik.enable", "true", + "traefik.http.routers.ui-" + slug + ".rule", "PathPrefix(`/t/" + slug + "`)", + "traefik.http.routers.ui-" + slug + ".tls", "true", + "traefik.http.routers.ui-" + slug + ".priority", "1", + "traefik.http.services.ui-" + slug + ".loadbalancer.server.port", "80", + "traefik.http.middlewares.ui-strip-" + slug + ".stripprefix.prefixes", "/t/" + slug, + "traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug, + "cameleer.tenant", slug, + "cameleer.role", "server-ui" + ); + + List env = List.of( + "BASE_PATH=/t/" + slug, + "API_URL=http://" + serverName + ":8081" + ); + + HostConfig hostConfig = HostConfig.newHostConfig() + .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) + .withNetworkMode(props.networkName()); + + CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage()) + .withName(uiName) + .withLabels(labels) + .withEnv(env) + .withHostConfig(hostConfig) + .exec(); + + docker.connectToNetworkCmd() + .withNetworkId(props.traefikNetwork()) + .withContainerId(resp.getId()) + .exec(); + } + + private boolean waitForHealth(String containerName, int timeoutSeconds) { + long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L; + while (System.currentTimeMillis() < deadline) { + try { + InspectContainerResponse info = docker.inspectContainerCmd(containerName).exec(); + InspectContainerResponse.ContainerState state = info.getState(); + if (state.getHealth() != null && "healthy".equals(state.getHealth().getStatus())) { + return true; + } + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + log.debug("Health check poll for '{}': {}", containerName, e.getMessage()); + try { Thread.sleep(2000); } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return false; + } + + private void pullIfMissing(String image) { + try { + docker.inspectImageCmd(image).exec(); + } catch (NotFoundException e) { + log.info("Pulling image: {}", image); + try { + docker.pullImageCmd(image).start().awaitCompletion(); + } catch (Exception ex) { + log.warn("Failed to pull {}: {}", image, ex.getMessage()); + } + } + } + + private void stopIfRunning(String name) { + try { + docker.stopContainerCmd(name).withTimeout(30).exec(); + } catch (NotFoundException ignored) {} + } + + private void removeContainer(String name) { + try { + docker.removeContainerCmd(name).withForce(true).exec(); + } catch (NotFoundException ignored) {} + } + + private String serverContainerName(String slug) { + return "cameleer-server-" + slug; + } + + private String uiContainerName(String slug) { + return "cameleer-server-ui-" + slug; + } }