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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 21:42:01 +02:00
parent 771e9d1081
commit 96a5b1d9f1

View File

@@ -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<String, String> 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<String> 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<String, String> 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<String> 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;
}
}