From 21db92ff00cf6aa797681be9ad779af50d1a8f9d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:18:47 +0200 Subject: [PATCH] fix(traefik): make TLS cert resolver configurable, omit when unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/rules/core-classes.md | 2 +- .claude/rules/docker-orchestration.md | 2 +- .../app/runtime/DeploymentExecutor.java | 9 ++++- .../app/runtime/TraefikLabelBuilder.java | 5 ++- .../src/main/resources/application.yml | 1 + .../app/runtime/TraefikLabelBuilderTest.java | 40 ++++++++++++++++--- .../server/core/runtime/ConfigMerger.java | 6 ++- .../core/runtime/ResolvedContainerConfig.java | 3 +- 8 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index a694d460..3580e6ce 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, 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 - `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 91937c01..c79959f7 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. 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. - **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 9f44b3c1..f6a2e6ee 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 @@ -62,6 +62,9 @@ public class DeploymentExecutor { @Value("${cameleer.server.runtime.serverurl:}") private String globalServerUrl; + @Value("${cameleer.server.runtime.certresolver:}") + private String globalCertResolver; + @Value("${cameleer.server.runtime.jardockervolume:}") private String jarDockerVolume; @@ -131,7 +134,8 @@ public class DeploymentExecutor { globalCpuShares, globalRoutingMode, globalRoutingDomain, - globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl + globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl, + globalCertResolver.isBlank() ? null : globalCertResolver ); ResolvedContainerConfig config = ConfigMerger.resolve( globalDefaults, env.defaultContainerConfig(), app.containerConfig()); @@ -606,6 +610,9 @@ public class DeploymentExecutor { map.put("customArgs", config.customArgs()); map.put("extraNetworks", config.extraNetworks()); map.put("externalRouting", config.externalRouting()); + if (config.certResolver() != null) { + map.put("certResolver", config.certResolver()); + } 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 d7030933..b5298839 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 @@ -55,7 +55,10 @@ public final class TraefikLabelBuilder { if (config.sslOffloading()) { 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; diff --git a/cameleer-server-app/src/main/resources/application.yml b/cameleer-server-app/src/main/resources/application.yml index c4d8281c..671d8029 100644 --- a/cameleer-server-app/src/main/resources/application.yml +++ b/cameleer-server-app/src/main/resources/application.yml @@ -55,6 +55,7 @@ cameleer: routingmode: ${CAMELEER_SERVER_RUNTIME_ROUTINGMODE:path} routingdomain: ${CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN:localhost} serverurl: ${CAMELEER_SERVER_RUNTIME_SERVERURL:} + certresolver: ${CAMELEER_SERVER_RUNTIME_CERTRESOLVER:} jardockervolume: ${CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME:} indexer: debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000} 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 index bf9c0b21..c27d0d2a 100644 --- 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 @@ -10,7 +10,7 @@ import static org.junit.jupiter.api.Assertions.*; class TraefikLabelBuilderTest { - private static ResolvedContainerConfig config(boolean externalRouting) { + private static ResolvedContainerConfig config(boolean externalRouting, String certResolver) { return new ResolvedContainerConfig( 512, null, 500, null, 8080, List.of(), Map.of(), @@ -19,14 +19,15 @@ class TraefikLabelBuilderTest { 1, "blue-green", true, true, "spring-boot", "", List.of(), - externalRouting + externalRouting, + certResolver ); } @Test void build_emitsTraefikLabelsWhenExternalRoutingEnabled() { Map 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("8080", labels.get("traefik.http.services.dev-myapp.loadbalancer.server.port")); @@ -36,7 +37,7 @@ class TraefikLabelBuilderTest { @Test void build_omitsAllTraefikLabelsWhenExternalRoutingDisabled() { Map labels = TraefikLabelBuilder.build( - "myapp", "dev", "acme", config(false), 0, "abcdef01"); + "myapp", "dev", "acme", config(false, null), 0, "abcdef01"); long traefikLabelCount = labels.keySet().stream() .filter(k -> k.startsWith("traefik.")) @@ -47,7 +48,7 @@ class TraefikLabelBuilderTest { @Test void build_preservesIdentityLabelsWhenExternalRoutingDisabled() { Map 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("acme", labels.get("cameleer.tenant")); @@ -57,4 +58,33 @@ class TraefikLabelBuilderTest { assertEquals("abcdef01", labels.get("cameleer.generation")); assertEquals("dev-myapp-2-abcdef01", labels.get("cameleer.instance-id")); } + + @Test + void build_emitsCertResolverLabelWhenConfigured() { + Map 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 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 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"); + } } 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 121429c0..089b70b1 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 @@ -34,7 +34,8 @@ public final class ConfigMerger { stringVal(appConfig, envConfig, "runtimeType", "auto"), stringVal(appConfig, envConfig, "customArgs", ""), 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, String routingMode, String routingDomain, - String serverUrl + String serverUrl, + String certResolver ) {} } 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 b5430414..5b293663 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 @@ -23,7 +23,8 @@ public record ResolvedContainerConfig( String runtimeType, String customArgs, List extraNetworks, - boolean externalRouting + boolean externalRouting, + String certResolver ) { public long memoryLimitBytes() { return (long) memoryLimitMb * 1024 * 1024;