From 50f8251459932a022a5483011da89562a6150000 Mon Sep 17 00:00:00 2001 From: Devenio Date: Tue, 18 Aug 2026 19:16:18 +0330 Subject: [PATCH] fix(adapters): log that HTTP is live while SSL is still provisioning The 443 block is only emitted once the cert exists, so operators saw a ~1 min HTTPS gap and filed it as broken SSL. Issuance is best-effort by design; the silence was the bug. Co-authored-by: Cursor --- .../deployments/compose/deploy.service.ts | 10 ++- .../src/runtime/route-registration.test.ts | 84 ++++++++++++++++++- .../src/runtime/route-registration.ts | 21 ++++- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/deployments/compose/deploy.service.ts b/apps/api/src/modules/deployments/compose/deploy.service.ts index e24f60949..09f0da375 100644 --- a/apps/api/src/modules/deployments/compose/deploy.service.ts +++ b/apps/api/src/modules/deployments/compose/deploy.service.ts @@ -1651,9 +1651,13 @@ export async function deployComposeServices( // Kept OUT of the try above so a cert failure can never be reported as a // route-registration failure. if (route.provisionSsl) { - logger.log(`Checking SSL for ${route.hostname}...\n`, "info", { - serviceName: svc.name, - }); + logger.log( + `Route live on HTTP for ${route.hostname} — provisioning the certificate, HTTPS in ~1 min\n`, + "info", + { + serviceName: svc.name, + }, + ); await routeContext.trackedSsl.provisionCert(route.hostname).catch((err) => { logger.log( `SSL provisioning failed for ${route.hostname} (route is up on HTTP, retry from ` + diff --git a/packages/adapters/src/runtime/route-registration.test.ts b/packages/adapters/src/runtime/route-registration.test.ts index d1959c7d1..ba51a828c 100644 --- a/packages/adapters/src/runtime/route-registration.test.ts +++ b/packages/adapters/src/runtime/route-registration.test.ts @@ -4,7 +4,11 @@ import { explainEdgeDown } from "../system/edge-exec-error"; const logger = { log: vi.fn(), step: vi.fn() } as any; const routeTarget = { targetUrl: "http://127.0.0.1:12345" }; -const domain = (hostname: string): RoutedDomainInput => ({ hostname, tls: false, targetPort: 3000 }); +const domain = (hostname: string): RoutedDomainInput => ({ + hostname, + tls: false, + targetPort: 3000, +}); const RESTARTING = "Error response from daemon: Container abc123 is restarting, wait until the container is running"; @@ -136,6 +140,60 @@ describe("registerResolvedRoutes — transient container-restart handling", () = }); }); +describe("registerResolvedRoutes — SSL provisioning is visible in the deploy log", () => { + beforeEach(() => vi.clearAllMocks()); + + const tlsDomain = (over: Partial = {}): RoutedDomainInput => ({ + hostname: "app.example.com", + tls: true, + provisionSsl: true, + targetPort: 3000, + ...over, + }); + + it("logs that HTTP is live before requesting the cert, then issues it", async () => { + const order: string[] = []; + const routing = { + registerRoute: vi.fn(async () => { + order.push("register"); + }), + } as any; + const ssl = { + provisionCert: vi.fn(async () => { + order.push("provision"); + return { verified: true }; + }), + } as any; + + const warnings = await registerResolvedRoutes(logger, routing, ssl, [tlsDomain()], routeTarget); + + expect(warnings).toEqual([]); + expect(order).toEqual(["register", "provision"]); + const logged = logger.log.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(logged).toContain( + "Route live on HTTP for app.example.com — provisioning the certificate, HTTPS in ~1 min", + ); + expect(ssl.provisionCert).toHaveBeenCalledWith("app.example.com"); + }); + + it("does not claim a cert is coming when provisionSsl is off", async () => { + const routing = { registerRoute: vi.fn(async () => {}) } as any; + const ssl = { provisionCert: vi.fn(async () => ({ verified: true })) } as any; + + await registerResolvedRoutes( + logger, + routing, + ssl, + [tlsDomain({ provisionSsl: false })], + routeTarget, + ); + + const logged = logger.log.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(logged).not.toContain("HTTPS in ~1 min"); + expect(ssl.provisionCert).not.toHaveBeenCalled(); + }); +}); + /** * Reverse-proxy tunables are a property of the PROJECT, not of any one upstream, so * they arrive as a registration option and land on every domain's vhost. Threading @@ -168,7 +226,13 @@ describe("registerResolvedRoutes — proxy tunables", () => { it("omits `proxy` entirely when none is configured, so nginx defaults apply", async () => { const routing = { registerRoute: vi.fn(async () => {}) } as any; - await registerResolvedRoutes(logger, routing, undefined, [domain("a.example.com")], routeTarget); + await registerResolvedRoutes( + logger, + routing, + undefined, + [domain("a.example.com")], + routeTarget, + ); expect(routing.registerRoute.mock.calls[0][0].proxy).toBeUndefined(); }); @@ -216,7 +280,13 @@ describe("registerResolvedRoutes — compiled vercel.json rules", () => { }, ], redirects: [ - { path: "/blog/", exact: false, statusCode: 308, destination: "/news/$1", pattern: "/blog/(.*)" }, + { + path: "/blog/", + exact: false, + statusCode: 308, + destination: "/news/$1", + pattern: "/blog/(.*)", + }, ], headerRules: [{ path: "/api/", headers: [{ key: "Cache-Control", value: "no-store" }] }], cleanUrls: true, @@ -274,7 +344,13 @@ describe("registerResolvedRoutes — compiled vercel.json rules", () => { const bare = { registerRoute: vi.fn(async () => {}) } as any; await registerResolvedRoutes(logger, bare, undefined, [domain("c.example.com")], routeTarget); const cfg = bare.registerRoute.mock.calls[0][0]; - for (const key of ["proxyLocations", "redirects", "headerRules", "cleanUrls", "trailingSlash"]) { + for (const key of [ + "proxyLocations", + "redirects", + "headerRules", + "cleanUrls", + "trailingSlash", + ]) { expect(cfg).not.toHaveProperty(key); } }); diff --git a/packages/adapters/src/runtime/route-registration.ts b/packages/adapters/src/runtime/route-registration.ts index de9803e3f..915a47084 100644 --- a/packages/adapters/src/runtime/route-registration.ts +++ b/packages/adapters/src/runtime/route-registration.ts @@ -146,7 +146,7 @@ export async function registerResolvedRoutes( const resolvedRouteTarget = domain.targetPort !== undefined - ? routeTargetsByPort?.get(domain.targetPort) ?? baseRouteTarget + ? (routeTargetsByPort?.get(domain.targetPort) ?? baseRouteTarget) : baseRouteTarget; const targetUrl = (resolvedRouteTarget as { targetUrl?: string }).targetUrl; const staticRoot = (resolvedRouteTarget as { staticRoot?: string }).staticRoot; @@ -212,14 +212,27 @@ export async function registerResolvedRoutes( if (options?.trailingSlash !== undefined) routeConfig.trailingSlash = options.trailingSlash; // Add webhook proxy location if this domain is the project's webhook domain - if (options?.webhookDomain && domain.hostname === options.webhookDomain && options.webhookProxy) { + if ( + options?.webhookDomain && + domain.hostname === options.webhookDomain && + options.webhookProxy + ) { routeConfig.webhookProxy = options.webhookProxy; } await routingProvider.registerRoute(routeConfig); if (domain.provisionSsl && ssl) { - logger.log(`Checking SSL for ${domain.hostname}...\n`); + // The 443 block is only emitted once the cert exists, so there is a + // ~1 minute window where the site answers HTTP and nothing (or a + // bootstrap self-signed cert) on HTTPS. Issuance is best-effort by + // design — domains never fail a deploy — which is why the *silence* + // was the bug: operators filed it as broken SSL. Say so in the deploy + // log at registration, then let the tracked provider log when the + // cert lands (or fails). + logger.log( + `Route live on HTTP for ${domain.hostname} — provisioning the certificate, HTTPS in ~1 min\n`, + ); // SSL is best-effort. The HTTP route is already written to disk // and reachable on port 80, which is what serves the ACME HTTP-01 // challenge — so even when certbot fails right now (rate limit, @@ -294,4 +307,4 @@ export async function registerResolvedRoutes( } return warnings; -} \ No newline at end of file +}