fix(traefik): make TLS cert resolver configurable, omit when unset
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 1m3s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s

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:
hsiegeln
2026-04-23 18:18:47 +02:00
parent 165c9f10e3
commit 21db92ff00
8 changed files with 56 additions and 12 deletions

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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<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("8080", labels.get("traefik.http.services.dev-myapp.loadbalancer.server.port"));
@@ -36,7 +37,7 @@ class TraefikLabelBuilderTest {
@Test
void build_omitsAllTraefikLabelsWhenExternalRoutingDisabled() {
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()
.filter(k -> k.startsWith("traefik."))
@@ -47,7 +48,7 @@ class TraefikLabelBuilderTest {
@Test
void build_preservesIdentityLabelsWhenExternalRoutingDisabled() {
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("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<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");
}
}