From 165c9f10e3c87f52c5d60b86463431ce7426361a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:03:48 +0200 Subject: [PATCH] feat(deploy): externalRouting toggle to keep apps off Traefik Adds a boolean `externalRouting` flag (default `true`) on ResolvedContainerConfig. When `false`, TraefikLabelBuilder emits only the identity labels (`managed-by`, `cameleer.*`) and skips every `traefik.*` label, so the container is not published by Traefik. Sibling containers on `cameleer-traefik` / `cameleer-env-{tenant}-{env}` can still reach it via Docker DNS on whatever port the app listens on. TDD: new TraefikLabelBuilderTest covers enabled (default labels present), disabled (zero traefik.* labels), and disabled (identity labels retained) cases. Full module unit suite: 208/0/0. Plumbed through ConfigMerger read, DeploymentExecutor snapshot, UI form state, Resources tab toggle, POST payload, and snapshot-to-form mapping. Rule files updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/core-classes.md | 2 +- .claude/rules/docker-orchestration.md | 2 +- .../app/runtime/DeploymentExecutor.java | 1 + .../app/runtime/TraefikLabelBuilder.java | 6 +- .../app/runtime/TraefikLabelBuilderTest.java | 60 +++++++++++++++++++ .../server/core/runtime/ConfigMerger.java | 3 +- .../core/runtime/ResolvedContainerConfig.java | 3 +- .../ConfigPanel.test.tsx | 2 +- .../CheckpointDetailDrawer/snapshotToForm.ts | 1 + .../ConfigTabs/ResourcesTab.tsx | 19 ++++++ .../ConfigTabs/readOnly-contract.test.tsx | 1 + .../hooks/useDeploymentPageState.ts | 4 +- .../pages/AppsTab/AppDeploymentPage/index.tsx | 1 + 13 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/TraefikLabelBuilderTest.java diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index e4b04b88..a694d460 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -37,7 +37,7 @@ paths: - `RuntimeDetector` — probes JAR files at upload time: detects runtime from manifest Main-Class (Spring Boot loader, Quarkus entry point, plain Java) or native binary (non-ZIP magic bytes) - `ContainerRequest` — record: 20 fields for Docker container creation (includes runtimeType, customArgs, mainClass) - `ContainerStatus` — record: state, running, exitCode, error -- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, memoryReserveMb, cpuRequest, cpuLimit, appPort, exposedPorts, customEnvVars, stripPathPrefix, sslOffloading, routingMode, routingDomain, serverUrl, replicas, deploymentStrategy, routeControlEnabled, replayEnabled, runtimeType, customArgs, extraNetworks +- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, memoryReserveMb, cpuRequest, cpuLimit, appPort, exposedPorts, customEnvVars, stripPathPrefix, sslOffloading, routingMode, routingDomain, serverUrl, replicas, deploymentStrategy, routeControlEnabled, replayEnabled, runtimeType, customArgs, extraNetworks, externalRouting (default `true`; when `false`, `TraefikLabelBuilder` strips all `traefik.*` labels so the container is not publicly routed) - `RoutingMode` — enum for routing strategies - `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig - `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture diff --git a/.claude/rules/docker-orchestration.md b/.claude/rules/docker-orchestration.md index b06aca51..91937c01 100644 --- a/.claude/rules/docker-orchestration.md +++ b/.claude/rules/docker-orchestration.md @@ -13,7 +13,7 @@ paths: When deployed via the cameleer-saas platform, this server orchestrates customer app containers using Docker. Key components: - **ConfigMerger** (`core/runtime/ConfigMerger.java`) — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig. Three-layer merge: global (application.yml) -> environment (defaultContainerConfig JSONB) -> app (containerConfig JSONB). Includes `runtimeType` (default `"auto"`) and `customArgs` (default `""`). -- **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles. Per-replica identity labels: `cameleer.replica` (index), `cameleer.generation` (8-char deployment UUID prefix — pin Prometheus/Grafana deploy boundaries with this), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Traefik router/service keys deliberately omit the generation so load balancing spans old + new replicas during a blue/green overlap. +- **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles. Per-replica identity labels: `cameleer.replica` (index), `cameleer.generation` (8-char deployment UUID prefix — pin Prometheus/Grafana deploy boundaries with this), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Traefik router/service keys deliberately omit the generation so load balancing spans old + new replicas during a blue/green overlap. When `ResolvedContainerConfig.externalRouting()` is `false` (UI: Resources → External Routing, default `true`), the builder emits ONLY the identity labels (`managed-by`, `cameleer.*`) and skips every `traefik.*` label — the container stays on `cameleer-traefik` and the per-env network (so sibling containers can still reach it via Docker DNS) but is invisible to Traefik. - **PrometheusLabelBuilder** (`app/runtime/PrometheusLabelBuilder.java`) — generates Prometheus `docker_sd_configs` labels per resolved runtime type: Spring Boot `/actuator/prometheus:8081`, Quarkus/native `/q/metrics:9000`, plain Java `/metrics:9464`. Labels merged into container metadata alongside Traefik labels at deploy time. - **DockerNetworkManager** (`app/runtime/DockerNetworkManager.java`) — manages two Docker network tiers: - `cameleer-traefik` — shared network; Traefik, server, and all app containers attach here. Server joined via docker-compose with `cameleer-server` DNS alias. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java index 680f9c4d..9f44b3c1 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java @@ -605,6 +605,7 @@ public class DeploymentExecutor { map.put("runtimeType", config.runtimeType()); map.put("customArgs", config.customArgs()); map.put("extraNetworks", config.extraNetworks()); + map.put("externalRouting", config.externalRouting()); return map; } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java index 6b1ae2c0..d7030933 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java @@ -19,7 +19,6 @@ public final class TraefikLabelBuilder { String instanceId = envSlug + "-" + appSlug + "-" + replicaIndex + "-" + generation; Map labels = new LinkedHashMap<>(); - labels.put("traefik.enable", "true"); labels.put("managed-by", "cameleer-server"); labels.put("cameleer.tenant", tenantId); labels.put("cameleer.app", appSlug); @@ -28,6 +27,11 @@ public final class TraefikLabelBuilder { labels.put("cameleer.generation", generation); labels.put("cameleer.instance-id", instanceId); + if (!config.externalRouting()) { + return labels; + } + + labels.put("traefik.enable", "true"); labels.put("traefik.http.services." + svc + ".loadbalancer.server.port", String.valueOf(config.appPort())); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/TraefikLabelBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/TraefikLabelBuilderTest.java new file mode 100644 index 00000000..bf9c0b21 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/TraefikLabelBuilderTest.java @@ -0,0 +1,60 @@ +package com.cameleer.server.app.runtime; + +import com.cameleer.server.core.runtime.ResolvedContainerConfig; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class TraefikLabelBuilderTest { + + private static ResolvedContainerConfig config(boolean externalRouting) { + return new ResolvedContainerConfig( + 512, null, 500, null, + 8080, List.of(), Map.of(), + true, true, + "path", "example.com", "https://cameleer.example.com", + 1, "blue-green", + true, true, + "spring-boot", "", List.of(), + externalRouting + ); + } + + @Test + void build_emitsTraefikLabelsWhenExternalRoutingEnabled() { + Map labels = TraefikLabelBuilder.build( + "myapp", "dev", "acme", config(true), 0, "abcdef01"); + + assertEquals("true", labels.get("traefik.enable")); + assertEquals("8080", labels.get("traefik.http.services.dev-myapp.loadbalancer.server.port")); + assertEquals("PathPrefix(`/dev/myapp/`)", labels.get("traefik.http.routers.dev-myapp.rule")); + } + + @Test + void build_omitsAllTraefikLabelsWhenExternalRoutingDisabled() { + Map labels = TraefikLabelBuilder.build( + "myapp", "dev", "acme", config(false), 0, "abcdef01"); + + long traefikLabelCount = labels.keySet().stream() + .filter(k -> k.startsWith("traefik.")) + .count(); + assertEquals(0, traefikLabelCount, "expected no traefik.* labels but found: " + labels); + } + + @Test + void build_preservesIdentityLabelsWhenExternalRoutingDisabled() { + Map labels = TraefikLabelBuilder.build( + "myapp", "dev", "acme", config(false), 2, "abcdef01"); + + assertEquals("cameleer-server", labels.get("managed-by")); + assertEquals("acme", labels.get("cameleer.tenant")); + assertEquals("myapp", labels.get("cameleer.app")); + assertEquals("dev", labels.get("cameleer.environment")); + assertEquals("2", labels.get("cameleer.replica")); + assertEquals("abcdef01", labels.get("cameleer.generation")); + assertEquals("dev-myapp-2-abcdef01", labels.get("cameleer.instance-id")); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java index f9ee6251..121429c0 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java @@ -33,7 +33,8 @@ public final class ConfigMerger { boolVal(appConfig, envConfig, "replayEnabled", true), stringVal(appConfig, envConfig, "runtimeType", "auto"), stringVal(appConfig, envConfig, "customArgs", ""), - stringList(appConfig, envConfig, "extraNetworks") + stringList(appConfig, envConfig, "extraNetworks"), + boolVal(appConfig, envConfig, "externalRouting", true) ); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java index 77b58e66..b5430414 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java @@ -22,7 +22,8 @@ public record ResolvedContainerConfig( boolean replayEnabled, String runtimeType, String customArgs, - List extraNetworks + List extraNetworks, + boolean externalRouting ) { public long memoryLimitBytes() { return (long) memoryLimitMb * 1024 * 1024; diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx index bd03a27a..75bcc21d 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx @@ -67,7 +67,7 @@ describe('ConfigPanel', () => { resources: { memoryLimit: '256', memoryReserve: '', cpuRequest: '500', cpuLimit: '', appPort: '8080', replicas: '1', deployStrategy: 'blue-green', - stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '', + stripPrefix: true, sslOffloading: true, externalRouting: true, runtimeType: 'auto', customArgs: '', extraNetworks: [], }, variables: { envVars: [] }, diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts index 1c00320a..dcdfb061 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts @@ -41,6 +41,7 @@ export function snapshotToForm( deployStrategy: (c.deploymentStrategy as string) ?? defaults.resources.deployStrategy, stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : defaults.resources.stripPrefix, sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : defaults.resources.sslOffloading, + externalRouting: c.externalRouting !== undefined ? (c.externalRouting as boolean) : defaults.resources.externalRouting, runtimeType: (c.runtimeType as string) ?? defaults.resources.runtimeType, customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : defaults.resources.customArgs, extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : defaults.resources.extraNetworks, diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx index fae0cfc3..5440035a 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx @@ -171,6 +171,25 @@ export function ResourcesTab({ value, onChange, disabled, isProd = false }: Prop + External Routing +
+
+ !disabled && update('externalRouting', !value.externalRouting)} + disabled={disabled} + /> + + {value.externalRouting ? 'Enabled' : 'Disabled'} + +
+ + {value.externalRouting + ? 'Traefik publishes the app at the configured path/subdomain.' + : 'No Traefik labels emitted — the app is reachable only by sibling containers via Docker DNS.'} + +
+ Extra Networks
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/readOnly-contract.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/readOnly-contract.test.tsx index 8678dfeb..29ac95dd 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/readOnly-contract.test.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/readOnly-contract.test.tsx @@ -52,6 +52,7 @@ const defaultResources: ResourcesFormState = { deployStrategy: 'blue-green', stripPrefix: true, sslOffloading: true, + externalRouting: true, runtimeType: 'auto', customArgs: '', extraNetworks: [], diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts index 615c92ca..e4e614fe 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts +++ b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts @@ -28,6 +28,7 @@ export interface ResourcesFormState { deployStrategy: string; stripPrefix: boolean; sslOffloading: boolean; + externalRouting: boolean; runtimeType: string; customArgs: string; extraNetworks: string[]; @@ -66,7 +67,7 @@ export const defaultForm: DeploymentPageFormState = { resources: { memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '', appPort: '8080', replicas: '1', deployStrategy: 'blue-green', - stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '', + stripPrefix: true, sslOffloading: true, externalRouting: true, runtimeType: 'auto', customArgs: '', extraNetworks: [], }, variables: { envVars: [] }, @@ -112,6 +113,7 @@ export function useDeploymentPageState( deployStrategy: String(merged.deploymentStrategy ?? defaultForm.resources.deployStrategy), stripPrefix: merged.stripPathPrefix !== false, sslOffloading: merged.sslOffloading !== false, + externalRouting: merged.externalRouting !== false, runtimeType: String(merged.runtimeType ?? defaultForm.resources.runtimeType), customArgs: String(merged.customArgs ?? defaultForm.resources.customArgs), extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : defaultForm.resources.extraNetworks, diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx index 3019eb72..89ac7498 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx @@ -208,6 +208,7 @@ export default function AppDeploymentPage() { deploymentStrategy: r.deployStrategy, stripPathPrefix: r.stripPrefix, sslOffloading: r.sslOffloading, + externalRouting: r.externalRouting, runtimeType: r.runtimeType, customArgs: r.customArgs || null, extraNetworks: r.extraNetworks,