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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
- `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)
|
- `ContainerRequest` — record: 20 fields for Docker container creation (includes runtimeType, customArgs, mainClass)
|
||||||
- `ContainerStatus` — record: state, running, exitCode, error
|
- `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
|
- `RoutingMode` — enum for routing strategies
|
||||||
- `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig
|
- `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig
|
||||||
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture
|
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ paths:
|
|||||||
When deployed via the cameleer-saas platform, this server orchestrates customer app containers using Docker. Key components:
|
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 `""`).
|
- **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.
|
- **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:
|
- **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.
|
- `cameleer-traefik` — shared network; Traefik, server, and all app containers attach here. Server joined via docker-compose with `cameleer-server` DNS alias.
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ public class DeploymentExecutor {
|
|||||||
map.put("runtimeType", config.runtimeType());
|
map.put("runtimeType", config.runtimeType());
|
||||||
map.put("customArgs", config.customArgs());
|
map.put("customArgs", config.customArgs());
|
||||||
map.put("extraNetworks", config.extraNetworks());
|
map.put("extraNetworks", config.extraNetworks());
|
||||||
|
map.put("externalRouting", config.externalRouting());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ public final class TraefikLabelBuilder {
|
|||||||
String instanceId = envSlug + "-" + appSlug + "-" + replicaIndex + "-" + generation;
|
String instanceId = envSlug + "-" + appSlug + "-" + replicaIndex + "-" + generation;
|
||||||
Map<String, String> labels = new LinkedHashMap<>();
|
Map<String, String> labels = new LinkedHashMap<>();
|
||||||
|
|
||||||
labels.put("traefik.enable", "true");
|
|
||||||
labels.put("managed-by", "cameleer-server");
|
labels.put("managed-by", "cameleer-server");
|
||||||
labels.put("cameleer.tenant", tenantId);
|
labels.put("cameleer.tenant", tenantId);
|
||||||
labels.put("cameleer.app", appSlug);
|
labels.put("cameleer.app", appSlug);
|
||||||
@@ -28,6 +27,11 @@ public final class TraefikLabelBuilder {
|
|||||||
labels.put("cameleer.generation", generation);
|
labels.put("cameleer.generation", generation);
|
||||||
labels.put("cameleer.instance-id", instanceId);
|
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",
|
labels.put("traefik.http.services." + svc + ".loadbalancer.server.port",
|
||||||
String.valueOf(config.appPort()));
|
String.valueOf(config.appPort()));
|
||||||
|
|
||||||
|
|||||||
@@ -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<String, String> 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<String, String> 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<String, String> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ public final class ConfigMerger {
|
|||||||
boolVal(appConfig, envConfig, "replayEnabled", true),
|
boolVal(appConfig, envConfig, "replayEnabled", true),
|
||||||
stringVal(appConfig, envConfig, "runtimeType", "auto"),
|
stringVal(appConfig, envConfig, "runtimeType", "auto"),
|
||||||
stringVal(appConfig, envConfig, "customArgs", ""),
|
stringVal(appConfig, envConfig, "customArgs", ""),
|
||||||
stringList(appConfig, envConfig, "extraNetworks")
|
stringList(appConfig, envConfig, "extraNetworks"),
|
||||||
|
boolVal(appConfig, envConfig, "externalRouting", true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ public record ResolvedContainerConfig(
|
|||||||
boolean replayEnabled,
|
boolean replayEnabled,
|
||||||
String runtimeType,
|
String runtimeType,
|
||||||
String customArgs,
|
String customArgs,
|
||||||
List<String> extraNetworks
|
List<String> extraNetworks,
|
||||||
|
boolean externalRouting
|
||||||
) {
|
) {
|
||||||
public long memoryLimitBytes() {
|
public long memoryLimitBytes() {
|
||||||
return (long) memoryLimitMb * 1024 * 1024;
|
return (long) memoryLimitMb * 1024 * 1024;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('ConfigPanel', () => {
|
|||||||
resources: {
|
resources: {
|
||||||
memoryLimit: '256', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
|
memoryLimit: '256', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
|
||||||
appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
|
appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
|
||||||
stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
|
stripPrefix: true, sslOffloading: true, externalRouting: true, runtimeType: 'auto', customArgs: '',
|
||||||
extraNetworks: [],
|
extraNetworks: [],
|
||||||
},
|
},
|
||||||
variables: { envVars: [] },
|
variables: { envVars: [] },
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function snapshotToForm(
|
|||||||
deployStrategy: (c.deploymentStrategy as string) ?? defaults.resources.deployStrategy,
|
deployStrategy: (c.deploymentStrategy as string) ?? defaults.resources.deployStrategy,
|
||||||
stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : defaults.resources.stripPrefix,
|
stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : defaults.resources.stripPrefix,
|
||||||
sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : defaults.resources.sslOffloading,
|
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,
|
runtimeType: (c.runtimeType as string) ?? defaults.resources.runtimeType,
|
||||||
customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : defaults.resources.customArgs,
|
customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : defaults.resources.customArgs,
|
||||||
extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : defaults.resources.extraNetworks,
|
extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : defaults.resources.extraNetworks,
|
||||||
|
|||||||
@@ -171,6 +171,25 @@ export function ResourcesTab({ value, onChange, disabled, isProd = false }: Prop
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>External Routing</span>
|
||||||
|
<div>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Toggle
|
||||||
|
checked={value.externalRouting}
|
||||||
|
onChange={() => !disabled && update('externalRouting', !value.externalRouting)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className={value.externalRouting ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||||
|
{value.externalRouting ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.configHint}>
|
||||||
|
{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.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className={styles.configLabel}>Extra Networks</span>
|
<span className={styles.configLabel}>Extra Networks</span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.portPills}>
|
<div className={styles.portPills}>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const defaultResources: ResourcesFormState = {
|
|||||||
deployStrategy: 'blue-green',
|
deployStrategy: 'blue-green',
|
||||||
stripPrefix: true,
|
stripPrefix: true,
|
||||||
sslOffloading: true,
|
sslOffloading: true,
|
||||||
|
externalRouting: true,
|
||||||
runtimeType: 'auto',
|
runtimeType: 'auto',
|
||||||
customArgs: '',
|
customArgs: '',
|
||||||
extraNetworks: [],
|
extraNetworks: [],
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ResourcesFormState {
|
|||||||
deployStrategy: string;
|
deployStrategy: string;
|
||||||
stripPrefix: boolean;
|
stripPrefix: boolean;
|
||||||
sslOffloading: boolean;
|
sslOffloading: boolean;
|
||||||
|
externalRouting: boolean;
|
||||||
runtimeType: string;
|
runtimeType: string;
|
||||||
customArgs: string;
|
customArgs: string;
|
||||||
extraNetworks: string[];
|
extraNetworks: string[];
|
||||||
@@ -66,7 +67,7 @@ export const defaultForm: DeploymentPageFormState = {
|
|||||||
resources: {
|
resources: {
|
||||||
memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
|
memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
|
||||||
appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
|
appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
|
||||||
stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
|
stripPrefix: true, sslOffloading: true, externalRouting: true, runtimeType: 'auto', customArgs: '',
|
||||||
extraNetworks: [],
|
extraNetworks: [],
|
||||||
},
|
},
|
||||||
variables: { envVars: [] },
|
variables: { envVars: [] },
|
||||||
@@ -112,6 +113,7 @@ export function useDeploymentPageState(
|
|||||||
deployStrategy: String(merged.deploymentStrategy ?? defaultForm.resources.deployStrategy),
|
deployStrategy: String(merged.deploymentStrategy ?? defaultForm.resources.deployStrategy),
|
||||||
stripPrefix: merged.stripPathPrefix !== false,
|
stripPrefix: merged.stripPathPrefix !== false,
|
||||||
sslOffloading: merged.sslOffloading !== false,
|
sslOffloading: merged.sslOffloading !== false,
|
||||||
|
externalRouting: merged.externalRouting !== false,
|
||||||
runtimeType: String(merged.runtimeType ?? defaultForm.resources.runtimeType),
|
runtimeType: String(merged.runtimeType ?? defaultForm.resources.runtimeType),
|
||||||
customArgs: String(merged.customArgs ?? defaultForm.resources.customArgs),
|
customArgs: String(merged.customArgs ?? defaultForm.resources.customArgs),
|
||||||
extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : defaultForm.resources.extraNetworks,
|
extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : defaultForm.resources.extraNetworks,
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export default function AppDeploymentPage() {
|
|||||||
deploymentStrategy: r.deployStrategy,
|
deploymentStrategy: r.deployStrategy,
|
||||||
stripPathPrefix: r.stripPrefix,
|
stripPathPrefix: r.stripPrefix,
|
||||||
sslOffloading: r.sslOffloading,
|
sslOffloading: r.sslOffloading,
|
||||||
|
externalRouting: r.externalRouting,
|
||||||
runtimeType: r.runtimeType,
|
runtimeType: r.runtimeType,
|
||||||
customArgs: r.customArgs || null,
|
customArgs: r.customArgs || null,
|
||||||
extraNetworks: r.extraNetworks,
|
extraNetworks: r.extraNetworks,
|
||||||
|
|||||||
Reference in New Issue
Block a user