fix(traefik): make TLS cert resolver configurable, omit when unset
Previously `TraefikLabelBuilder` hardcoded `tls.certresolver=default` on every router. That assumes a resolver literally named `default` exists in the Traefik static config — true for ACME-backed installs, false for dev/local installs that use a file-based TLS store. Traefik logs "Router uses a nonexistent certificate resolver" for the bogus resolver on every managed app, and any future attempt to define a differently- named real resolver would silently skip these routers. Server-wide setting via `CAMELEER_SERVER_RUNTIME_CERTRESOLVER` (empty by default) flows through `ConfigMerger.GlobalRuntimeDefaults.certResolver` into `ResolvedContainerConfig.certResolver`. When blank the `tls.certresolver` label is omitted entirely; `tls=true` is still emitted so Traefik serves the default TLS-store cert. When set, the label is emitted with the configured resolver name. Not per-app/per-env configurable: there is one Traefik per server instance and one resolver config; app-level override would only let users break their own routers. TDD: TraefikLabelBuilderTest gains 3 cases (resolver set, null, blank). Full unit suite 211/0/0. 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, externalRouting (default `true`; when `false`, `TraefikLabelBuilder` strips all `traefik.*` labels so the container is not publicly routed)
|
- `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), certResolver (server-wide, sourced from `CAMELEER_SERVER_RUNTIME_CERTRESOLVER`; when blank the `tls.certresolver` label is omitted — use for dev installs with a static TLS store)
|
||||||
- `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. 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.
|
- **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. The `tls.certresolver` label is emitted only when `CAMELEER_SERVER_RUNTIME_CERTRESOLVER` is set to a non-blank resolver name (matching a resolver configured in the Traefik static config). When unset (dev installs backed by a static TLS store) only `tls=true` is emitted and Traefik serves the default cert from the TLS store.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ public class DeploymentExecutor {
|
|||||||
@Value("${cameleer.server.runtime.serverurl:}")
|
@Value("${cameleer.server.runtime.serverurl:}")
|
||||||
private String globalServerUrl;
|
private String globalServerUrl;
|
||||||
|
|
||||||
|
@Value("${cameleer.server.runtime.certresolver:}")
|
||||||
|
private String globalCertResolver;
|
||||||
|
|
||||||
@Value("${cameleer.server.runtime.jardockervolume:}")
|
@Value("${cameleer.server.runtime.jardockervolume:}")
|
||||||
private String jarDockerVolume;
|
private String jarDockerVolume;
|
||||||
|
|
||||||
@@ -131,7 +134,8 @@ public class DeploymentExecutor {
|
|||||||
globalCpuShares,
|
globalCpuShares,
|
||||||
globalRoutingMode,
|
globalRoutingMode,
|
||||||
globalRoutingDomain,
|
globalRoutingDomain,
|
||||||
globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl
|
globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl,
|
||||||
|
globalCertResolver.isBlank() ? null : globalCertResolver
|
||||||
);
|
);
|
||||||
ResolvedContainerConfig config = ConfigMerger.resolve(
|
ResolvedContainerConfig config = ConfigMerger.resolve(
|
||||||
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
|
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
|
||||||
@@ -606,6 +610,9 @@ public class DeploymentExecutor {
|
|||||||
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());
|
map.put("externalRouting", config.externalRouting());
|
||||||
|
if (config.certResolver() != null) {
|
||||||
|
map.put("certResolver", config.certResolver());
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ public final class TraefikLabelBuilder {
|
|||||||
|
|
||||||
if (config.sslOffloading()) {
|
if (config.sslOffloading()) {
|
||||||
labels.put("traefik.http.routers." + svc + ".tls", "true");
|
labels.put("traefik.http.routers." + svc + ".tls", "true");
|
||||||
labels.put("traefik.http.routers." + svc + ".tls.certresolver", "default");
|
if (config.certResolver() != null && !config.certResolver().isBlank()) {
|
||||||
|
labels.put("traefik.http.routers." + svc + ".tls.certresolver",
|
||||||
|
config.certResolver());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return labels;
|
return labels;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ cameleer:
|
|||||||
routingmode: ${CAMELEER_SERVER_RUNTIME_ROUTINGMODE:path}
|
routingmode: ${CAMELEER_SERVER_RUNTIME_ROUTINGMODE:path}
|
||||||
routingdomain: ${CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN:localhost}
|
routingdomain: ${CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN:localhost}
|
||||||
serverurl: ${CAMELEER_SERVER_RUNTIME_SERVERURL:}
|
serverurl: ${CAMELEER_SERVER_RUNTIME_SERVERURL:}
|
||||||
|
certresolver: ${CAMELEER_SERVER_RUNTIME_CERTRESOLVER:}
|
||||||
jardockervolume: ${CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME:}
|
jardockervolume: ${CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME:}
|
||||||
indexer:
|
indexer:
|
||||||
debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000}
|
debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class TraefikLabelBuilderTest {
|
class TraefikLabelBuilderTest {
|
||||||
|
|
||||||
private static ResolvedContainerConfig config(boolean externalRouting) {
|
private static ResolvedContainerConfig config(boolean externalRouting, String certResolver) {
|
||||||
return new ResolvedContainerConfig(
|
return new ResolvedContainerConfig(
|
||||||
512, null, 500, null,
|
512, null, 500, null,
|
||||||
8080, List.of(), Map.of(),
|
8080, List.of(), Map.of(),
|
||||||
@@ -19,14 +19,15 @@ class TraefikLabelBuilderTest {
|
|||||||
1, "blue-green",
|
1, "blue-green",
|
||||||
true, true,
|
true, true,
|
||||||
"spring-boot", "", List.of(),
|
"spring-boot", "", List.of(),
|
||||||
externalRouting
|
externalRouting,
|
||||||
|
certResolver
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void build_emitsTraefikLabelsWhenExternalRoutingEnabled() {
|
void build_emitsTraefikLabelsWhenExternalRoutingEnabled() {
|
||||||
Map<String, String> labels = TraefikLabelBuilder.build(
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
"myapp", "dev", "acme", config(true), 0, "abcdef01");
|
"myapp", "dev", "acme", config(true, null), 0, "abcdef01");
|
||||||
|
|
||||||
assertEquals("true", labels.get("traefik.enable"));
|
assertEquals("true", labels.get("traefik.enable"));
|
||||||
assertEquals("8080", labels.get("traefik.http.services.dev-myapp.loadbalancer.server.port"));
|
assertEquals("8080", labels.get("traefik.http.services.dev-myapp.loadbalancer.server.port"));
|
||||||
@@ -36,7 +37,7 @@ class TraefikLabelBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
void build_omitsAllTraefikLabelsWhenExternalRoutingDisabled() {
|
void build_omitsAllTraefikLabelsWhenExternalRoutingDisabled() {
|
||||||
Map<String, String> labels = TraefikLabelBuilder.build(
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
"myapp", "dev", "acme", config(false), 0, "abcdef01");
|
"myapp", "dev", "acme", config(false, null), 0, "abcdef01");
|
||||||
|
|
||||||
long traefikLabelCount = labels.keySet().stream()
|
long traefikLabelCount = labels.keySet().stream()
|
||||||
.filter(k -> k.startsWith("traefik."))
|
.filter(k -> k.startsWith("traefik."))
|
||||||
@@ -47,7 +48,7 @@ class TraefikLabelBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
void build_preservesIdentityLabelsWhenExternalRoutingDisabled() {
|
void build_preservesIdentityLabelsWhenExternalRoutingDisabled() {
|
||||||
Map<String, String> labels = TraefikLabelBuilder.build(
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
"myapp", "dev", "acme", config(false), 2, "abcdef01");
|
"myapp", "dev", "acme", config(false, null), 2, "abcdef01");
|
||||||
|
|
||||||
assertEquals("cameleer-server", labels.get("managed-by"));
|
assertEquals("cameleer-server", labels.get("managed-by"));
|
||||||
assertEquals("acme", labels.get("cameleer.tenant"));
|
assertEquals("acme", labels.get("cameleer.tenant"));
|
||||||
@@ -57,4 +58,33 @@ class TraefikLabelBuilderTest {
|
|||||||
assertEquals("abcdef01", labels.get("cameleer.generation"));
|
assertEquals("abcdef01", labels.get("cameleer.generation"));
|
||||||
assertEquals("dev-myapp-2-abcdef01", labels.get("cameleer.instance-id"));
|
assertEquals("dev-myapp-2-abcdef01", labels.get("cameleer.instance-id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void build_emitsCertResolverLabelWhenConfigured() {
|
||||||
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
|
"myapp", "dev", "acme", config(true, "letsencrypt"), 0, "abcdef01");
|
||||||
|
|
||||||
|
assertEquals("true", labels.get("traefik.http.routers.dev-myapp.tls"));
|
||||||
|
assertEquals("letsencrypt", labels.get("traefik.http.routers.dev-myapp.tls.certresolver"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void build_omitsCertResolverLabelWhenNull() {
|
||||||
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
|
"myapp", "dev", "acme", config(true, null), 0, "abcdef01");
|
||||||
|
|
||||||
|
assertEquals("true", labels.get("traefik.http.routers.dev-myapp.tls"),
|
||||||
|
"sslOffloading=true should still mark the router TLS-enabled");
|
||||||
|
assertNull(labels.get("traefik.http.routers.dev-myapp.tls.certresolver"),
|
||||||
|
"cert resolver label must be omitted when none is configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void build_omitsCertResolverLabelWhenBlank() {
|
||||||
|
Map<String, String> labels = TraefikLabelBuilder.build(
|
||||||
|
"myapp", "dev", "acme", config(true, " "), 0, "abcdef01");
|
||||||
|
|
||||||
|
assertNull(labels.get("traefik.http.routers.dev-myapp.tls.certresolver"),
|
||||||
|
"whitespace-only cert resolver must be treated as unset");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ public final class ConfigMerger {
|
|||||||
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)
|
boolVal(appConfig, envConfig, "externalRouting", true),
|
||||||
|
global.certResolver()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ public final class ConfigMerger {
|
|||||||
int cpuRequest,
|
int cpuRequest,
|
||||||
String routingMode,
|
String routingMode,
|
||||||
String routingDomain,
|
String routingDomain,
|
||||||
String serverUrl
|
String serverUrl,
|
||||||
|
String certResolver
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ public record ResolvedContainerConfig(
|
|||||||
String runtimeType,
|
String runtimeType,
|
||||||
String customArgs,
|
String customArgs,
|
||||||
List<String> extraNetworks,
|
List<String> extraNetworks,
|
||||||
boolean externalRouting
|
boolean externalRouting,
|
||||||
|
String certResolver
|
||||||
) {
|
) {
|
||||||
public long memoryLimitBytes() {
|
public long memoryLimitBytes() {
|
||||||
return (long) memoryLimitMb * 1024 * 1024;
|
return (long) memoryLimitMb * 1024 * 1024;
|
||||||
|
|||||||
Reference in New Issue
Block a user