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;
|
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.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 {
|
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;
|
private final ProvisioningProperties props;
|
||||||
|
|
||||||
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) {
|
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) {
|
||||||
this.config = config;
|
|
||||||
this.props = props;
|
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
|
||||||
@Override public ProvisionResult provision(TenantProvisionRequest request) { throw new UnsupportedOperationException("Not yet implemented"); }
|
public boolean isAvailable() { return true; }
|
||||||
@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
|
||||||
@Override public void remove(String slug) { throw new UnsupportedOperationException("Not yet implemented"); }
|
public ProvisionResult provision(TenantProvisionRequest req) {
|
||||||
@Override public ServerStatus getStatus(String slug) { return ServerStatus.notFound(); }
|
String serverName = serverContainerName(req.slug());
|
||||||
@Override public String getServerEndpoint(String slug) { return "http://cameleer-server-" + slug + ":8081"; }
|
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