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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user