Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/api/src/modules/deployments/compose/deploy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ` +
Expand Down
84 changes: 80 additions & 4 deletions packages/adapters/src/runtime/route-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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> = {}): 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
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
});
Expand Down
21 changes: 17 additions & 4 deletions packages/adapters/src/runtime/route-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -294,4 +307,4 @@ export async function registerResolvedRoutes(
}

return warnings;
}
}