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 ee0852b..63720ab 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -40,16 +40,20 @@ public class DockerTenantProvisioner implements TenantProvisioner { public ProvisionResult provision(TenantProvisionRequest req) { String serverName = serverContainerName(req.slug()); String uiName = uiContainerName(req.slug()); + String tenantNetwork = tenantNetworkName(req.slug()); String endpoint = "http://" + serverName + ":8081"; try { pullIfMissing(props.serverImage()); pullIfMissing(props.serverUiImage()); - createServerContainer(req, serverName); + // Create isolated tenant network + ensureNetwork(tenantNetwork); + + createServerContainer(req, serverName, tenantNetwork); docker.startContainerCmd(serverName).exec(); - createUiContainer(req.slug(), uiName, serverName); + createUiContainer(req.slug(), uiName, serverName, tenantNetwork); docker.startContainerCmd(uiName).exec(); if (!waitForHealth(serverName, 60)) { @@ -90,6 +94,7 @@ public class DockerTenantProvisioner implements TenantProvisioner { public void remove(String slug) { removeContainer(uiContainerName(slug)); removeContainer(serverContainerName(slug)); + removeNetwork(tenantNetworkName(slug)); } @Override @@ -112,19 +117,17 @@ public class DockerTenantProvisioner implements TenantProvisioner { return "http://" + serverContainerName(slug) + ":8081"; } - private void createServerContainer(TenantProvisionRequest req, String name) { + private void createServerContainer(TenantProvisionRequest req, String name, String tenantNetwork) { String slug = req.slug(); String prefix = "/t/" + slug; // Traefik labels — need >10 entries, use HashMap var labels = new java.util.HashMap(); labels.put("traefik.enable", "true"); - // Router: match /t/{slug}/api/* and /t/{slug}/actuator/* labels.put("traefik.http.routers.server-" + slug + ".rule", "PathPrefix(`" + prefix + "/api`) || PathPrefix(`" + prefix + "/actuator`)"); labels.put("traefik.http.routers.server-" + slug + ".tls", "true"); labels.put("traefik.http.routers.server-" + slug + ".priority", "10"); - // Strip the /t/{slug} prefix so server sees /api/... and /actuator/... labels.put("traefik.http.middlewares.server-strip-" + slug + ".stripprefix.prefixes", prefix); labels.put("traefik.http.routers.server-" + slug + ".middlewares", "server-strip-" + slug); labels.put("traefik.http.services.server-" + slug + ".loadbalancer.server.port", "8081"); @@ -150,14 +153,15 @@ public class DockerTenantProvisioner implements TenantProvisioner { "CAMELEER_ROUTING_DOMAIN=" + props.publicHost(), "CAMELEER_ROUTING_MODE=path", "CAMELEER_JAR_STORAGE_PATH=/data/jars", - "CAMELEER_DOCKER_NETWORK=" + props.networkName(), + // Apps deployed by this server join the tenant network (isolated) + "CAMELEER_DOCKER_NETWORK=" + tenantNetwork, "CAMELEER_JAR_DOCKER_VOLUME=cameleer-jars-" + slug ); - // Docker socket mount for app deployment within tenant server + // Primary network = tenant-isolated network HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) - .withNetworkMode(props.networkName()) + .withNetworkMode(tenantNetwork) .withBinds(new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock"))) .withGroupAdd(List.of("0")); @@ -174,24 +178,31 @@ public class DockerTenantProvisioner implements TenantProvisioner { .withStartPeriod(15_000_000_000L)) .exec(); - // Connect to traefik network with DNS alias + String containerId = resp.getId(); + + // Connect to shared services network (postgres, clickhouse, logto) + docker.connectToNetworkCmd() + .withNetworkId(props.networkName()) + .withContainerId(containerId) + .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) + .exec(); + + // Connect to traefik network for routing docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) - .withContainerId(resp.getId()) + .withContainerId(containerId) .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) .exec(); } - private void createUiContainer(String slug, String uiName, String serverName) { + private void createUiContainer(String slug, String uiName, String serverName, String tenantNetwork) { String prefix = "/t/" + slug; var labels = new java.util.HashMap(); labels.put("traefik.enable", "true"); - // Router: catch-all for /t/{slug}/* (lower priority than server API) labels.put("traefik.http.routers.ui-" + slug + ".rule", "PathPrefix(`" + prefix + "`)"); labels.put("traefik.http.routers.ui-" + slug + ".tls", "true"); labels.put("traefik.http.routers.ui-" + slug + ".priority", "5"); - // Strip /t/{slug} prefix so UI sees / labels.put("traefik.http.middlewares.ui-strip-" + slug + ".stripprefix.prefixes", prefix); labels.put("traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug); labels.put("traefik.http.services.ui-" + slug + ".loadbalancer.server.port", "80"); @@ -203,9 +214,10 @@ public class DockerTenantProvisioner implements TenantProvisioner { "API_URL=http://" + serverName + ":8081" ); + // Primary network = tenant network (can reach server via DNS) HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) - .withNetworkMode(props.networkName()); + .withNetworkMode(tenantNetwork); CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage()) .withName(uiName) @@ -214,6 +226,7 @@ public class DockerTenantProvisioner implements TenantProvisioner { .withHostConfig(hostConfig) .exec(); + // Connect to traefik for routing docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) .withContainerId(resp.getId()) @@ -269,6 +282,30 @@ public class DockerTenantProvisioner implements TenantProvisioner { } catch (NotFoundException ignored) {} } + private void ensureNetwork(String networkName) { + try { + docker.inspectNetworkCmd().withNetworkId(networkName).exec(); + log.debug("Network '{}' already exists", networkName); + } catch (NotFoundException e) { + docker.createNetworkCmd() + .withName(networkName) + .withDriver("bridge") + .withInternal(true) // no external access — isolated + .exec(); + log.info("Created isolated tenant network: {}", networkName); + } + } + + private void removeNetwork(String networkName) { + try { + docker.removeNetworkCmd(networkName).exec(); + log.info("Removed tenant network: {}", networkName); + } catch (NotFoundException ignored) { + } catch (Exception e) { + log.warn("Failed to remove network '{}': {}", networkName, e.getMessage()); + } + } + private String serverContainerName(String slug) { return "cameleer-server-" + slug; } @@ -276,4 +313,8 @@ public class DockerTenantProvisioner implements TenantProvisioner { private String uiContainerName(String slug) { return "cameleer-server-ui-" + slug; } + + private String tenantNetworkName(String slug) { + return "cameleer-tenant-" + slug; + } }